diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2fa05da4..c7fe317dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,29 @@ jobs: # Keep the checkout credential only until the selective LFS pull in # the next step. That step removes it before any project code runs. persist-credentials: true + - name: Let npm read the private UI package (Pro) + # @offgrid/operator-ui is a private Wednesday package that only Pro uses. Core lists it as an + # optional dependency, so without access npm skips it and core still installs; Pro needs it. + shell: bash + env: + UI_TOKEN: ${{ secrets.OPERATOR_UI_TOKEN || secrets.CI_CROSS_REPO_TOKEN }} + run: | + # A pasted secret can carry a newline or spaces, which break token authentication. + UI_TOKEN="$(printf '%s' "$UI_TOKEN" | tr -d '[:space:]')" + if [ -n "$UI_TOKEN" ]; then + git config --global url."https://x-access-token:${UI_TOKEN}@github.com/wednesday-solutions/".insteadOf "ssh://git@github.com/wednesday-solutions/" + # actions/checkout saves this repo's own GitHub login as a header for all of github.com, + # and it overrides the token above. Clear it for wednesday-solutions only (the most specific + # address wins), so everything else keeps the checkout login. + if git rev-parse --git-dir >/dev/null 2>&1; then + git config --local http.https://github.com/wednesday-solutions/.extraheader "" + fi + fi + # npm skips an optional package it cannot read without saying so: say it here, plainly. + if ! git ls-remote --exit-code "https://x-access-token:${UI_TOKEN}@github.com/wednesday-solutions/component-library-animations.git" HEAD >/dev/null 2>&1; then + status="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${UI_TOKEN}" https://api.github.com/repos/wednesday-solutions/component-library-animations)" + echo "::error::CI cannot read wednesday-solutions/component-library-animations (GitHub answered ${status}: 401 bad token, 403 org policy or IP allow list, 404 no access to the repo). Pro will not build without it." + fi - name: Put the speech runtime beside this checkout if: ${{ !cancelled() }} run: | @@ -432,6 +455,29 @@ jobs: if: runner.os == 'Windows' with: python-version: '3.12' + - name: Let npm read the private UI package (Pro) + # @offgrid/operator-ui is a private Wednesday package that only Pro uses. Core lists it as an + # optional dependency, so without access npm skips it and core still installs; Pro needs it. + shell: bash + env: + UI_TOKEN: ${{ secrets.OPERATOR_UI_TOKEN || secrets.CI_CROSS_REPO_TOKEN }} + run: | + # A pasted secret can carry a newline or spaces, which break token authentication. + UI_TOKEN="$(printf '%s' "$UI_TOKEN" | tr -d '[:space:]')" + if [ -n "$UI_TOKEN" ]; then + git config --global url."https://x-access-token:${UI_TOKEN}@github.com/wednesday-solutions/".insteadOf "ssh://git@github.com/wednesday-solutions/" + # actions/checkout saves this repo's own GitHub login as a header for all of github.com, + # and it overrides the token above. Clear it for wednesday-solutions only (the most specific + # address wins), so everything else keeps the checkout login. + if git rev-parse --git-dir >/dev/null 2>&1; then + git config --local http.https://github.com/wednesday-solutions/.extraheader "" + fi + fi + # npm skips an optional package it cannot read without saying so: say it here, plainly. + if ! git ls-remote --exit-code "https://x-access-token:${UI_TOKEN}@github.com/wednesday-solutions/component-library-animations.git" HEAD >/dev/null 2>&1; then + status="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${UI_TOKEN}" https://api.github.com/repos/wednesday-solutions/component-library-animations)" + echo "::error::CI cannot read wednesday-solutions/component-library-animations (GitHub answered ${status}: 401 bad token, 403 org policy or IP allow list, 404 no access to the repo). Pro will not build without it." + fi - name: Install workspace dependencies run: | mv _shared ../shared diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91f4557e7..1eac430a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -292,6 +292,29 @@ jobs: fi rm -rf ../executorch-speech mv _executorch_speech ../executorch-speech + - name: Let npm read the private UI package (Pro) + # @offgrid/operator-ui is a private Wednesday package that only Pro uses. Core lists it as an + # optional dependency, so without access npm skips it and core still installs; Pro needs it. + shell: bash + env: + UI_TOKEN: ${{ secrets.OPERATOR_UI_TOKEN || secrets.CI_CROSS_REPO_TOKEN }} + run: | + # A pasted secret can carry a newline or spaces, which break token authentication. + UI_TOKEN="$(printf '%s' "$UI_TOKEN" | tr -d '[:space:]')" + if [ -n "$UI_TOKEN" ]; then + git config --global url."https://x-access-token:${UI_TOKEN}@github.com/wednesday-solutions/".insteadOf "ssh://git@github.com/wednesday-solutions/" + # actions/checkout saves this repo's own GitHub login as a header for all of github.com, + # and it overrides the token above. Clear it for wednesday-solutions only (the most specific + # address wins), so everything else keeps the checkout login. + if git rev-parse --git-dir >/dev/null 2>&1; then + git config --local http.https://github.com/wednesday-solutions/.extraheader "" + fi + fi + # npm skips an optional package it cannot read without saying so: say it here, plainly. + if ! git ls-remote --exit-code "https://x-access-token:${UI_TOKEN}@github.com/wednesday-solutions/component-library-animations.git" HEAD >/dev/null 2>&1; then + status="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${UI_TOKEN}" https://api.github.com/repos/wednesday-solutions/component-library-animations)" + echo "::error::CI cannot read wednesday-solutions/component-library-animations (GitHub answered ${status}: 401 bad token, 403 org policy or IP allow list, 404 no access to the repo). Pro will not build without it." + fi - name: Install dependencies run: npm ci # Stamp the resolved version into package.json so electron-builder picks the @@ -302,6 +325,13 @@ jobs: - name: Stamp build version run: npm version "${{ needs.version.outputs.version }}" --no-git-tag-version --allow-same-version - name: Stage native files and bundle + env: + # Connect with Off Grid AI (scripts/oauth-build-config.mjs). The build fails without them. + OFFGRID_REQUIRE_OAUTH: '1' + GOOGLE_OAUTH_CLIENT_TYPE: desktop + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + MICROSOFT_CLIENT_ID: ${{ vars.MICROSOFT_CLIENT_ID }} run: node scripts/stage-native.mjs && npx electron-vite build # The version job has already created this build's draft tag. Exclude it # when finding the previous release for the changelog. @@ -587,6 +617,29 @@ jobs: fi rm -rf ../executorch-speech mv _executorch_speech ../executorch-speech + - name: Let npm read the private UI package (Pro) + # @offgrid/operator-ui is a private Wednesday package that only Pro uses. Core lists it as an + # optional dependency, so without access npm skips it and core still installs; Pro needs it. + shell: bash + env: + UI_TOKEN: ${{ secrets.OPERATOR_UI_TOKEN || secrets.CI_CROSS_REPO_TOKEN }} + run: | + # A pasted secret can carry a newline or spaces, which break token authentication. + UI_TOKEN="$(printf '%s' "$UI_TOKEN" | tr -d '[:space:]')" + if [ -n "$UI_TOKEN" ]; then + git config --global url."https://x-access-token:${UI_TOKEN}@github.com/wednesday-solutions/".insteadOf "ssh://git@github.com/wednesday-solutions/" + # actions/checkout saves this repo's own GitHub login as a header for all of github.com, + # and it overrides the token above. Clear it for wednesday-solutions only (the most specific + # address wins), so everything else keeps the checkout login. + if git rev-parse --git-dir >/dev/null 2>&1; then + git config --local http.https://github.com/wednesday-solutions/.extraheader "" + fi + fi + # npm skips an optional package it cannot read without saying so: say it here, plainly. + if ! git ls-remote --exit-code "https://x-access-token:${UI_TOKEN}@github.com/wednesday-solutions/component-library-animations.git" HEAD >/dev/null 2>&1; then + status="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${UI_TOKEN}" https://api.github.com/repos/wednesday-solutions/component-library-animations)" + echo "::error::CI cannot read wednesday-solutions/component-library-animations (GitHub answered ${status}: 401 bad token, 403 org policy or IP allow list, 404 no access to the repo). Pro will not build without it." + fi - name: Install dependencies run: npm ci - name: Fetch Windows native binaries (llama/whisper/sd/ffmpeg) @@ -598,6 +651,13 @@ jobs: - name: Stamp build version run: npm version "${{ needs.version.outputs.version }}" --no-git-tag-version --allow-same-version - name: Stage native files and bundle + env: + # Connect with Off Grid AI (scripts/oauth-build-config.mjs). The build fails without them. + OFFGRID_REQUIRE_OAUTH: '1' + GOOGLE_OAUTH_CLIENT_TYPE: desktop + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + MICROSOFT_CLIENT_ID: ${{ vars.MICROSOFT_CLIENT_ID }} run: node scripts/stage-native.mjs && npx electron-vite build && node scripts/check-windows-onnx-preload.mjs - name: Package Windows web installer, payload, and update metadata shell: bash @@ -789,6 +849,29 @@ jobs: install -Dm644 resources/bin/kev-local-server.py build/linux-bin/kev-local-server.py install -Dm755 build/linux-bin/executorch-speech \ ../executorch-speech/native/bin/executorch-speech + - name: Let npm read the private UI package (Pro) + # @offgrid/operator-ui is a private Wednesday package that only Pro uses. Core lists it as an + # optional dependency, so without access npm skips it and core still installs; Pro needs it. + shell: bash + env: + UI_TOKEN: ${{ secrets.OPERATOR_UI_TOKEN || secrets.CI_CROSS_REPO_TOKEN }} + run: | + # A pasted secret can carry a newline or spaces, which break token authentication. + UI_TOKEN="$(printf '%s' "$UI_TOKEN" | tr -d '[:space:]')" + if [ -n "$UI_TOKEN" ]; then + git config --global url."https://x-access-token:${UI_TOKEN}@github.com/wednesday-solutions/".insteadOf "ssh://git@github.com/wednesday-solutions/" + # actions/checkout saves this repo's own GitHub login as a header for all of github.com, + # and it overrides the token above. Clear it for wednesday-solutions only (the most specific + # address wins), so everything else keeps the checkout login. + if git rev-parse --git-dir >/dev/null 2>&1; then + git config --local http.https://github.com/wednesday-solutions/.extraheader "" + fi + fi + # npm skips an optional package it cannot read without saying so: say it here, plainly. + if ! git ls-remote --exit-code "https://x-access-token:${UI_TOKEN}@github.com/wednesday-solutions/component-library-animations.git" HEAD >/dev/null 2>&1; then + status="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${UI_TOKEN}" https://api.github.com/repos/wednesday-solutions/component-library-animations)" + echo "::error::CI cannot read wednesday-solutions/component-library-animations (GitHub answered ${status}: 401 bad token, 403 org policy or IP allow list, 404 no access to the repo). Pro will not build without it." + fi - name: Install dependencies run: npm ci - name: Install Linux GPU runtime dependencies @@ -809,6 +892,12 @@ jobs: - name: Build Linux packages env: OFFGRID_FORCE_CORE: '0' + # Connect with Off Grid AI (scripts/oauth-build-config.mjs). The build fails without them. + OFFGRID_REQUIRE_OAUTH: '1' + GOOGLE_OAUTH_CLIENT_TYPE: desktop + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + MICROSOFT_CLIENT_ID: ${{ vars.MICROSOFT_CLIENT_ID }} run: npm run build:linux:pinned - name: Report Linux native payload sizes run: du -sh dist/linux-unpacked/resources/bin/* | sort -h | tail -25 diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 644700093..b890b3b42 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -177,6 +177,29 @@ jobs: npm --prefix ../shared/packages/speech run build npm --prefix ../shared/packages/ui run build + - name: Let npm read the private UI package (Pro) + # @offgrid/operator-ui is a private Wednesday package that only Pro uses. Core lists it as an + # optional dependency, so without access npm skips it and core still installs; Pro needs it. + shell: bash + env: + UI_TOKEN: ${{ secrets.OPERATOR_UI_TOKEN || secrets.CI_CROSS_REPO_TOKEN }} + run: | + # A pasted secret can carry a newline or spaces, which break token authentication. + UI_TOKEN="$(printf '%s' "$UI_TOKEN" | tr -d '[:space:]')" + if [ -n "$UI_TOKEN" ]; then + git config --global url."https://x-access-token:${UI_TOKEN}@github.com/wednesday-solutions/".insteadOf "ssh://git@github.com/wednesday-solutions/" + # actions/checkout saves this repo's own GitHub login as a header for all of github.com, + # and it overrides the token above. Clear it for wednesday-solutions only (the most specific + # address wins), so everything else keeps the checkout login. + if git rev-parse --git-dir >/dev/null 2>&1; then + git config --local http.https://github.com/wednesday-solutions/.extraheader "" + fi + fi + # npm skips an optional package it cannot read without saying so: say it here, plainly. + if ! git ls-remote --exit-code "https://x-access-token:${UI_TOKEN}@github.com/wednesday-solutions/component-library-animations.git" HEAD >/dev/null 2>&1; then + status="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${UI_TOKEN}" https://api.github.com/repos/wednesday-solutions/component-library-animations)" + echo "::error::CI cannot read wednesday-solutions/component-library-animations (GitHub answered ${status}: 401 bad token, 403 org policy or IP allow list, 404 no access to the repo). Pro will not build without it." + fi - name: Install dependencies run: npm ci @@ -324,6 +347,29 @@ jobs: rm -rf ../executorch-speech mv _executorch_speech ../executorch-speech + - name: Let npm read the private UI package (Pro) + # @offgrid/operator-ui is a private Wednesday package that only Pro uses. Core lists it as an + # optional dependency, so without access npm skips it and core still installs; Pro needs it. + shell: bash + env: + UI_TOKEN: ${{ secrets.OPERATOR_UI_TOKEN || secrets.CI_CROSS_REPO_TOKEN }} + run: | + # A pasted secret can carry a newline or spaces, which break token authentication. + UI_TOKEN="$(printf '%s' "$UI_TOKEN" | tr -d '[:space:]')" + if [ -n "$UI_TOKEN" ]; then + git config --global url."https://x-access-token:${UI_TOKEN}@github.com/wednesday-solutions/".insteadOf "ssh://git@github.com/wednesday-solutions/" + # actions/checkout saves this repo's own GitHub login as a header for all of github.com, + # and it overrides the token above. Clear it for wednesday-solutions only (the most specific + # address wins), so everything else keeps the checkout login. + if git rev-parse --git-dir >/dev/null 2>&1; then + git config --local http.https://github.com/wednesday-solutions/.extraheader "" + fi + fi + # npm skips an optional package it cannot read without saying so: say it here, plainly. + if ! git ls-remote --exit-code "https://x-access-token:${UI_TOKEN}@github.com/wednesday-solutions/component-library-animations.git" HEAD >/dev/null 2>&1; then + status="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${UI_TOKEN}" https://api.github.com/repos/wednesday-solutions/component-library-animations)" + echo "::error::CI cannot read wednesday-solutions/component-library-animations (GitHub answered ${status}: 401 bad token, 403 org policy or IP allow list, 404 no access to the repo). Pro will not build without it." + fi - name: Install dependencies run: npm ci diff --git a/.gitignore b/.gitignore index 210f47399..3ad691495 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,9 @@ resources/bin/location.node # Local env (telemetry/keys) — never commit .env .env.local +.env.*.local +# App registration credentials (scripts/oauth-build-config.mjs) +.env.keygen test-results/ outputs/video-verification/ diff --git a/docs/AGENT_LANDSCAPE_2026-10-06.html b/docs/AGENT_LANDSCAPE_2026-10-06.html new file mode 100644 index 000000000..7fe94f9d2 --- /dev/null +++ b/docs/AGENT_LANDSCAPE_2026-10-06.html @@ -0,0 +1,70 @@ + +Personal AI agents — feature comparison and God priorities + + +
OFF GRID / PRODUCT RESEARCHPersonal AI agents · evidence checked 6 October 2026Open sources ↓
+
+
+

Competitive comparison / 06 Oct 2026

What personal agents do.
What God needs to deliver.

A feature-by-feature comparison of Meta Muse, Instinct, Grok Bot, OpenAI dots, Gemini Spark and Manus Cue, with Off Grid God’s current development source. Use the matrix to find gaps. Use the review evidence and priorities to decide what to build and demonstrate.

+
31feature rows
11products in scope
8God priorities
5manual demo scripts
+
Baseline to meet

Own work between chats

Muse exposes goals and plans. Dots tracks ongoing responsibilities. God now has scheduled-task code, but a schedule alone does not establish continuing goal ownership.

Position to earn

Local context with clear control

God combines local records and a local model option. OpenClaw already supports local models; Perplexity now brings agents into Mac apps. Ease of use, evidence and dependable actions must carry the difference.

User pain to solve

Make failures and decisions clear

Early dots users report slow work and hard-to-resolve pauses. God has approval and task-history code. The next proof is a complete journey that returns a result or a useful failure reason.

+
Corrections to the earlier material: Muse documents editable memory files, schedules and event responses. Spark has expanded to India and added Mac beta support. Grok Bot lists $20 Cursor Pro and $30 SuperGrok access. God’s checkout now contains wired scheduled-task code. These updates are reflected below.
+
+

02 / Feature comparison

Compare the work each agent can do

Each cell states the feature, its limit and the source. “Documented” means the maker describes it; it does not mean we measured its reliability. God’s blue entries describe development source.

31 rows × 7 products
+
+
Feature / user needMeta MusePersonal errandsInstinctText and callsGrok BotMultiple work agentsOpenAI dotsOngoing responsibilitiesGemini SparkWorkspace routinesManus CueIndependent identitiesOff Grid GodLocal context · in developmentDecision for God
Access and costAvailabilityCan a user actually get the product?DocumentedUS rolloutiOS, Android, web; glasses announced.M1 ↗PartialInvite accessBeta in supplied research; current region list not published.I1 ↗DocumentedPaid plan accessMac, Windows, iOS; eligible Cursor/Grok plans.G1 ↗PartialGradual rolloutPro outside EEA/UK/CH; Business/Enterprise worldwide.D2 ↗PartialPro / UltraIndia expansion confirmed; Mac Ultra beta starts US.S1 ↗ S2 ↗PartialFree invite accessWeb, desktop, mobile; iOS pending in announcement.C1 ↗In developmentDevelopment checkoutmacOS Desktop; paid Pro action features.L1 ↗Define a release scope and supported Mac hardware before launch.
Access and costEntry priceCompare the cost of access with the cost of work.PartialFree / $20 / $100Paid tier prices from 14 Sep review; check checkout.M1 ↗ R1 ↗UnconfirmedUnconfirmedNo current public price table. Beta free claim is supplied, unverified.Documented$20 or $30 / moCursor Pro or SuperGrok entry; extra tokens can cost more.G1 ↗DocumentedEligible Pro planPro 100/200/500 listed; first dot included, allowance applies.D2 ↗ D1 ↗DocumentedPro / Ultra includedPlan price depends on country; not a standalone Spark price.S1 ↗DocumentedFree early accessInvite code required; future cost unspecified.C1 ↗In developmentPro licenseNo God-specific public price confirmed. Hardware and optional APIs cost money.L1 ↗ L3 ↗Publish license cost, model requirements and any optional API charges.
Access and costUsage limitsBackground work can use a large allowance.UnconfirmedUnconfirmedNot established in reviewed sources.UnconfirmedUnconfirmedNot established in reviewed sources.DocumentedWeekly + token billingG1 ↗DocumentedDeeper-work allowanceWork/Codex delegation uses their limits.D1 ↗UnconfirmedUnconfirmedShared-plan limits described in supplied research; amount not verified.UnconfirmedUnconfirmedNot established in reviewed sources.In developmentLocal compute / optional APINo vendor token fee for local inference; speed and power still constrain it.L3 ↗Show local resource use and disclose remote-model charges.
Continuity and proactivityKeeps working with your device offCloud work and local device work have different limits.DocumentedCloud VMM1 ↗PartialReported background workUser describes multi-day monitoring; hosting detail not verified.R3 ↗Documented24/7 cloudG1 ↗DocumentedCloud work continuesLocal tasks still need the connected computer online.D2 ↗ D3 ↗DocumentedCloud work continuesS1 ↗PartialOwn agent computerAlways-on uptime not explicitly established in reviewed announcement.C1 ↗Not foundDesktop must be onRoutine runner is in the app process.L4 ↗Use an always-on user-owned Mac option; show device availability.
Continuity and proactivityOngoing goal ownershipA goal needs a plan and a next step between chats.DocumentedGoals tab + planM2 ↗PartialMulti-day follow-upUser monitoring report; dedicated goal UI not established.R3 ↗DocumentedPersistent Bot workG1 ↗DocumentedTracks responsibilitiesPauses, wakes and delegates continuing work.D3 ↗PartialScheduled tasksIndependent long-term goal state not established here.S1 ↗PartialShared goal teamsC1 ↗PartialTasks + saved routinesTask history exists; a standing goal planner was not found.L2 ↗ L4 ↗Show goal, next step, wait reason, last result and completion condition.
Continuity and proactivityMessages firstDoes useful work arrive without a new prompt?DocumentedUnprompted messagesM2 ↗DocumentedTexts / calls firstI1 ↗DocumentedMain Bot checks inAdded 2 Oct.G2 ↗DocumentedResearch + follow-upBackground research uses read-only tools.D3 ↗DocumentedMonitoring updatesS2 ↗UnconfirmedUnconfirmedOwn email is documented; unprompted suggestion rules are not.PartialNeeds-you railBrief, due items, meeting, approvals, paused tasks.L2 ↗Generate a specific proposed action with the evidence that caused it.
Continuity and proactivityControls interruptionsUseful alerts need a threshold and quiet hours.DocumentedAdjust proactivityTurn off, down or up in conversation.M2 ↗UnconfirmedUnconfirmedNot established in reviewed sources.UnconfirmedUnconfirmedNot established in reviewed sources.PartialSpecify notification rulesTell it which results deserve an update.D3 ↗PartialConditional alertsUser specifies topics and thresholds.S2 ↗UnconfirmedUnconfirmedNot established in reviewed sources.PartialDismiss rail itemsNo God-wide quiet-hours or interruption policy found.L2 ↗Add user choices for urgency, quiet hours and repeated alerts.
Continuity and proactivityTime-based routinesSave work once and run it again.DocumentedSchedulesM2 ↗PartialMonitoring reportedUser reports recurring flight checks; schedule controls unclear.R3 ↗DocumentedScheduled routinesG1 ↗DocumentedSaved schedulesD3 ↗DocumentedRecurring tasksS1 ↗UnconfirmedUnconfirmedAutomations in supplied research; not verified for Cue specifically.In developmentOnce / daily / intervalRunner and chat tools are wired; manual proof pending.L4 ↗ L5 ↗ L6 ↗Verify creation, listing, cancellation, result routing and approval holds.
Continuity and proactivityExternal event triggersReact when an account changes.DocumentedRelevant eventsM2 ↗PartialDropped-thread follow-upExact supported event list unavailable.I1 ↗DocumentedEvent routine fixesPR event scope documented in changelog.G2 ↗DocumentedSupported service eventsConnecting an app alone does not create a monitor.D3 ↗DocumentedEmail + topic eventsS1 ↗ S2 ↗UnconfirmedUnconfirmedDo not transfer Manus Automations claims to Cue without proof.Not foundNot foundTime polling exists; dedicated God event subscriptions not found.Start with new mail and calendar changes; avoid duplicate action proposals.
Continuity and proactivityWhere users can reach itAn approval must reach the user when needed.DocumentedApp / web / WhatsAppM1 ↗DocumentedText / phoneI1 ↗DocumentedDesktop / iOS / SlackSlack Team Bot approval cards in changelog.G1 ↗ G2 ↗DocumentedChatGPT / Slack / TeamsVoice calls; texting was announced as coming soon.D1 ↗DocumentedGemini web / mobile / MacS1 ↗ S2 ↗DocumentedCue / email / phoneC1 ↗PartialDesktop + sync foundationRemote approvals exist; God notification journey not proven.L1 ↗ L7 ↗Connect God requests to paired mobile delivery with a clear decision path.
Execution and reachBrowser tasksUse sites even when no API exists.DocumentedVM browserM1 ↗DocumentedComputer use claimedI1 ↗DocumentedCloud apps / websitesG1 ↗DocumentedCloud browserD1 ↗DocumentedRemote / Mac browserMac and connected app features vary by plan.S2 ↗ R12 ↗PartialOwn computerBrowser details not given in announcement.C1 ↗In developmentWeb UseExisting task actions; running journey not verified here.L3 ↗Offer clear browser takeover and resume after sign-in.
Execution and reachNative computer tasksLocal app access is now a competitive requirement.UnconfirmedUnconfirmedMac access reported in supplied research; scope not rechecked.PartialPhone / computer claimActual supported device paths are unclear.I1 ↗PartialOwn cloud computerDesktop client is not proof of native local app control.G1 ↗DocumentedOptional local accessD5 ↗DocumentedMac Ultra betaOnly permitted files; starts in US.S2 ↗UnconfirmedUnconfirmedDesktop availability is not proof of native app control.In developmentComputer UsePro tool route exists.L3 ↗Describe supported local tasks and permissions, with visible progress.
Execution and reachIntegrationsCount useful actions and permission scopes, not logos.PartialConnected accountsReviewed launch confirms app access; complete catalog not audited.M1 ↗DocumentedGoogle WorkspaceMail, calendar, Drive, Docs, Sheets, Slides, Tasks.I2 ↗DocumentedPlugins + website loginsG1 ↗ G2 ↗Documented4,000+ app ecosystemVendor count; does not establish every action.D1 ↗DocumentedWorkspace + MCPKeep, Tasks, Canva, Dropbox and others.S1 ↗ S2 ↗UnconfirmedUnconfirmedEmail identity documented; connector list not established for Cue.In developmentConnected tools + memoryMCP/account tools exist; action coverage must be listed.L3 ↗Publish per-connector read/write scope and supported actions.
Execution and reachSends emailDrafting and sending are different permissions.DocumentedApprove before sendM1 ↗PartialActions through accountsApproval coverage not fully documented.I2 ↗ I3 ↗DocumentedDraft / direct settingCan disable draft review per Bot.G2 ↗DocumentedRules + reviewD4 ↗DocumentedAsks firstS1 ↗DocumentedOwn agent emailC1 ↗In developmentApproval-based actionsApproval and execution pipeline exists; specific mail route needs proof.L9 ↗Preview recipients, subject, content and attachments before send.
Execution and reachBooks / buysThe payment boundary must be clear.DocumentedLink one-use cardShop Pay was coming soon in launch source.M1 ↗PartialTransactions supportedPolicy covers payments; current card controls unclear.I2 ↗ I3 ↗UnconfirmedUnconfirmedGeneral site use does not prove payment completion.PartialAccount actionsRules apply; merchant-specific payment paths not verified.D4 ↗DocumentedSpend approvalS1 ↗DocumentedWallet with budgetC1 ↗PartialWeb task + approvalNo God payment wallet or virtual-card feature found.L3 ↗ L9 ↗Begin with research and prepared checkout; earn automatic spend later.
Execution and reachPhone callsVoice chat is not a telephone call to a business.UnconfirmedUnconfirmedOutbound business calls reported in supplied research; not confirmed.DocumentedText / call assistantProactive calling claimed; business-call details sparse.I1 ↗PartialVoice chatPhone-network calling not established.G2 ↗PartialCall your dotOutbound calling not established by reviewed docs.D1 ↗UnconfirmedUnconfirmedNot established in reviewed sources.DocumentedOwn phone numberCan take calls and leave a summary.C1 ↗Not foundVoice mode onlyNo telephone-network calling found.L1 ↗Keep voice chat distinct from future phone-call services.
Execution and reachMultiple agents and handoffsMore agents need clear ownership and access boundaries.PartialOne user agentSeveral tasks, not separate named user agents.M2 ↗UnconfirmedUnconfirmedGroup chat report is separate from a named agent team.DocumentedNamed Bots + coordinationOne shared computer per user.G1 ↗PartialOne primary dotBackground agents now; more personal dots planned.D1 ↗ D3 ↗UnconfirmedUnconfirmedNot established in reviewed sources.DocumentedTeam group chatC1 ↗PartialOne God + task engineSeveral tasks are not separate personal agents.L1 ↗ L3 ↗Define task ownership before adding user-facing agent personas.
Execution and reachTeach by demonstrationTurn a performed workflow into repeatable work.UnconfirmedUnconfirmedNot established in reviewed sources.UnconfirmedUnconfirmedNot established in reviewed sources.DocumentedWatch once → routineG1 ↗PartialFeedback + skillsRecorded workflow teaching not established.D1 ↗PartialDescribe a skillExample: infer writing style from emails.S1 ↗UnconfirmedUnconfirmedNot established in reviewed sources.Not foundNot foundPrepared workflows exist; record-to-routine teaching not found.Defer until action execution and recovery are dependable.
Memory and personal contextLearns personal facts / preferencesCaptured records and learned beliefs need separate controls.DocumentedPersistent memoryM2 ↗PartialPersonal contextProduct claim; one user reports durable preferences.I1 ↗ R3 ↗DocumentedContext / correctionsG1 ↗DocumentedChatGPT + private notesD3 ↗PartialAccount context + skillsReviewed sources do not establish a full learned-facts editor.S1 ↗UnconfirmedUnconfirmedNot established in reviewed sources.PartialLocal records + preferencesSecretary learning exists; God-wide learned-fact store not established.L3 ↗ L8 ↗Connect relevant learned preferences to God and expose their sources.
Memory and personal contextView and edit memoryCan the user correct a wrong belief?DocumentedEditable memory filesExplicit in official design page.M2 ↗UnconfirmedUnconfirmedNot established in reviewed sources.UnconfirmedUnconfirmedNot established in reviewed sources.PartialSeparate private notesDocs distinguish notes from ChatGPT memory; note editor not established.D3 ↗UnconfirmedUnconfirmedNot established in reviewed sources.UnconfirmedUnconfirmedNot established in reviewed sources.PartialUser rules / local recordsNo unified God learned-facts editor found.L10 ↗ L8 ↗Make facts editable with source, date, correction and delete actions.
Memory and personal contextScreen / audio / location contextAmbient access can add value and increase trust cost.UnconfirmedUnconfirmedLocal-device claims need a fresh platform-specific audit.DocumentedScreen / audio / locationAccess depends on grants and enabled features.I1 ↗ I2 ↗UnconfirmedUnconfirmedCloud computer visibility differs from ambient personal-device capture.PartialOptional computer accessNo general ambient capture claim established.D5 ↗PartialPermitted Mac filesNo verified continuous personal capture feature.S2 ↗UnconfirmedUnconfirmedNot established in reviewed sources.In developmentOpt-in captures / meetingsLocal recall tools; no God location feed established.L3 ↗Show recording state and let the user inspect or exclude capture sources.
Memory and personal contextRecall a meeting with evidenceAnswer from what was said, not a guessed summary.UnconfirmedUnconfirmedNot established in reviewed sources.UnconfirmedUnconfirmedNot established in reviewed sources.UnconfirmedUnconfirmedNot established in reviewed sources.PartialConnected sourcesDepends on captured meeting data being available.D3 ↗PartialWorkspace contextMeeting-audio memory not established.S1 ↗UnconfirmedUnconfirmedNot established in reviewed sources.In developmentLocal meeting searchMemory tools search meetings, chats and screen history.L3 ↗Use meeting timestamp and source for each recalled action or decision.
Memory and personal contextLocal model optionLocal device control does not imply local inference.Not foundVendor modelM1 ↗Not foundHosted serviceI1 ↗ I2 ↗Not foundVendor agentG1 ↗Not foundHosted modelD1 ↗Not foundHosted modelS1 ↗UnconfirmedUnconfirmedNo local-model mode established.In developmentLocal or remote modelSource explicitly supports a selected remote model too.L3 ↗Make active model and data route visible; do not imply all modes are local.
Trust and user controlApproval modelThe user must know what a decision permits.DocumentedSensitive action gateSentinel and structured cards.M1 ↗ M3 ↗PartialSafeguards not guaranteedTerms allow controls but disclaim prevention of bad actions.I3 ↗DocumentedAuto Review + cardsDraft controls can permit direct sends.G1 ↗ G2 ↗DocumentedRules + auto-reviewRead-only research; follow-up acts under permission.D4 ↗DocumentedHigh-stakes approvalS1 ↗PartialWallet budgetOther approval rules not established.C1 ↗In developmentQueue + task gateApprove/reject controls and result projection.L2 ↗ L9 ↗Show exact action, target, data, cost and scope before approval.
Trust and user controlGraded autonomySeparate harmless reads from sensitive writes.DocumentedAdjust caution defaultsM2 ↗UnconfirmedUnconfirmedNot established in reviewed sources.PartialPer-Bot draft settingsIndependent permission isolation is still requested.G2 ↗ R5 ↗DocumentedFour action choicesAct; act when asked; ask; hand off. Rules can still fail.D4 ↗PartialSensitive action asksFull rule editor not established.S1 ↗PartialSpending budgetC1 ↗PartialStanding rules + approvalsNatural-language rules are not an enforced access boundary.L10 ↗ L9 ↗Use enforceable scope controls alongside user instructions.
Trust and user controlCredentials and isolationWhere are logins kept, and who can use them?DocumentedSecure store + SentinelPasswords hidden from agent; dedicated user VM.M3 ↗PartialConnected credentialsPolicy allows account/password access; technical isolation unclear.I2 ↗PartialShared user VMAll Bots share files, browser and logins; not isolated roles.G1 ↗DocumentedSeparate dot computerSupported saved passwords hidden from model.D1 ↗PartialCloud + permitted filesDetailed credential isolation not audited.S1 ↗ S2 ↗PartialComputer per agentCross-agent permission boundary not established.C1 ↗PartialUser machine / connectorsNo claim of total credential invisibility made from this review.L3 ↗State connector scopes and ensure private sources cannot leak through actions.
Trust and user controlActivity and result recordA completed label must link to the actual result.DocumentedFull activity logM2 ↗UnconfirmedUnconfirmedText results exist; audit UI not established.DocumentedTasks + audit fixesG2 ↗DocumentedActivity + tasksD2 ↗ D4 ↗PartialTask outputFull action audit detail not reviewed.S1 ↗PartialCall summaryComplete audit model not established.C1 ↗In developmentTask history + railApproval execution status recorded.L2 ↗ L9 ↗Link each completed action to its output and record any partial failure.
Trust and user controlPause / stop / cancelStopping work and cancelling future runs are different.UnconfirmedUnconfirmedActivity controls shown; cascade behavior not audited.UnconfirmedUnconfirmedNot established in reviewed sources.DocumentedDelegated stop fix17 Sep changelog says stop also stops handed-off work.G2 ↗DocumentedSeparate controlsPause does not cancel delegated tasks or schedules.D4 ↗UnconfirmedUnconfirmedNot established in reviewed sources.UnconfirmedUnconfirmedNot established in reviewed sources.In developmentTasks + cancel scheduleSchedule cancel tool exists; global stop behavior not proven.L6 ↗ L3 ↗Make active work and future runs independently visible and stoppable.
Trust and user controlTraining and data useUse current policies rather than old allegations.DocumentedOpt out; no ad sharingConfidential VM is a future promise.M1 ↗PartialOpt out with exceptionsGoogle Workspace API data excluded from training and ads.I2 ↗DocumentedTraining opt-outCurrent page says Cursor privacy mode applies.G1 ↗DocumentedPlan data controlsBusiness data not trained by default; notes/research have specific rules.D1 ↗ D4 ↗UnconfirmedUnconfirmedProduct sources reviewed do not settle retention/training settings.UnconfirmedUnconfirmedCue-specific policy not reviewed.In developmentLocal optionOptional remote models and account actions can transmit data.L3 ↗Publish a route-by-route privacy table: inference, capture, sync and actions.
Trust and user controlDisconnect and deleteRevoking access need not erase old data.PartialDisconnect / forgetExact retention timeline not reviewed.M1 ↗DocumentedExplicit deletion neededDisconnect does not erase indexed data; deletion controls provided.I2 ↗UnconfirmedUnconfirmedNot established in reviewed sources.PartialDelete confirmationDoes not undo actions already made in connected apps.D4 ↗UnconfirmedUnconfirmedNot established in reviewed sources.UnconfirmedUnconfirmedNot established in reviewed sources.PartialUser-owned local dataGod-wide erase/export contract not established in this review.L3 ↗Give separate controls for access, stored history and learned facts.
Trust and user controlRecovery after sleep or failureA user must know what ran and what did not.UnconfirmedUnconfirmedBackground VM reduces desktop dependence; failure policy not audited.PartialHuman intervention reportedOTP and inaccessible websites still block work.R3 ↗DocumentedRecent reliability fixesHung commands stop with note; next message continues.G2 ↗PartialContinue / inspectPublic safety-pause report shows recovery friction.D4 ↗ R11 ↗UnconfirmedUnconfirmedNot established in reviewed sources.UnconfirmedUnconfirmedNot established in reviewed sources.In developmentOne missed-run catch-upWithin 24 hours; schedule attempt recorded before execution.L4 ↗ L5 ↗Show missed, failed and completed runs; retry without duplicate actions.
+ +
DocumentedMaker claimPartialLimited scope or indirect evidenceIn developmentPresent in God sourceUnconfirmedEvidence insufficientNot foundReviewed scope only
+

God gaps filter: shows recommended product gaps, including narrower features. It is an editorial selection, not a numerical performance score. “Not found” does not prove a competitor has no such feature. Sources were checked on 6 Oct; local source reflects checkout 62485eb27 plus working changes inspected during this session.

+

03 / Reviews and sentiment

What users value, and what gets in their way

Selected first-hand reports and named reviews. Sentiment labels describe these sources only. Vote counts, star ratings and completion percentages are not comparable across these products.

Evidence + sample limits
Product / sentimentUseful resultComplaint or limitEvidence scopeLesson for God
Meta MuseUseful, with trust concernsA reviewer delegated shopping and a utility-email reply; users value follow-up after mail/calendar connection.Users ask for more integrations and report Amazon failures. Personal account access makes some users uneasy.One press trial and one discussion. Sep trial predates later site blocking.Show the action draft and source. Treat blocked sites as a clear limitation.
InstinctUseful but hard to trust fullyA user reports orders, flight-price watches and research. Several commenters describe durable follow-through.Slow steps, OTP handoffs and payment trust concerns.One user trial thread. Not a completion-rate study.Keep a visible task state and show why human input is needed.
Grok BotBackground work praised; scopes requestedA user reports routines continued with PC off, using a custom CLI.A forum user wants enforced file/database limits per Bot; another user diary raises allowance questions.Two anecdotal reports and one explicit request; custom setup affects the success story.Provide scoped tools and explain resource limits before creating several agents.
OpenAI dotsMixed launch feedbackA user reports an unprompted warning about duplicate data. Another values coordination of parallel tasks.Slow tasks, silent failures, awkward calls and repeated safety pauses are reported.Four independent discussion threads, 1–3 Oct. Cross-posts of the same story counted once.A result, failure reason and recovery action should always reach the user.
Gemini SparkStrong retrieval; weak personal inferenceWIRED found real reservation details and a useful party itinerary from account context.It inferred the reviewer’s relationship incorrectly and omitted the host from the guest list.One May beta trial. This is older than the June/July updates.Distinguish sourced facts from inferred beliefs; allow a correction to persist.
Manus CueToo little evidenceOfficial announcement shows agent identity, calls and shared-goal teams.No reliable independent hands-on review found in this pass.Official claims only. No sentiment score assigned.Inspect actual access and controls before treating agent identities as proven value.
Off Grid GodDevelopment statusSource supports local recall, context, approvals and newly wired routines.No external God review or running-build evidence gathered here.Local code review; no release or reliability rating.Prove complete user journeys before marketing operating claims.
Instinct recommendation backlash: shopping suggestions drew complaints. R4 ↗
Current privacy policy: Workspace API data is excluded from model training and ads; other training has an opt-out with exceptions. Disconnecting an integration does not erase indexed data. I2 ↗
+

04 / Feature requests

Requests that can shape God’s product decisions

An explicit request is a user’s stated ask. An inferred requirement is our recommendation based on a reported failure or observed error. Priority is an editorial judgment for God.

Request / evidence typeWhat the evidence saysPriorityConcrete response for God
Enforced access per agentExplicit requestGrok Bot forum asks for per-Bot folders and database access, enforced by the system.HighFor God: scope each tool and connected account. User rules alone cannot enforce isolation.
More useful integrationsExplicit requestMuse users ask for more integrations; a site failure also limits practical use.HighList useful read/write operations. Prioritize accounts users already need for daily work.
Faster and more dependable workReported pain → inferred requirementdots users report slow tasks and silent failure; Instinct users report long gaps.HighShow progress and the wait cause; route simple reads through direct tools where available.
Explain and recover safety pausesReported pain → inferred requirementOne dots user cannot inspect a safety-pause report or restore stable work.HighShow the blocked action, reason and available user decision in the same task.
Mobile shortcuts and several dotsExplicit requestA dots user asks for widgets, control-centre actions and multiple dots.MediumFirst make paired-device approvals fast. Extra personas come after durable task ownership.
Correct personal assumptionsObserved error → inferred requirementSpark’s party trial shows wrong relationship inference despite rich data.HighSeparate inferred facts from confirmed facts and make both editable.
Opt-in recommendationsReported backlashShopping suggestions drew complaints.HighFor God: no commercial suggestion feed by default; tie offers to a user goal.
+

05 / Other notable players

Four rivals that change the comparison

These products compete for related work. Their deployment, access and intended users differ from the six personal agents in the main matrix.

Player / categoryWhere work runsBackground work / reachLimit to keep in viewWhy it matters to God
OpenClawSelf-operated agentLocal, server or selected cloud-model route; local-only Ollama supported.Configurable heartbeats, channels and recurring work.Operator controls configuration; setup and upkeep remain a user job.Direct rival to “local agent.” God needs simpler setup and clearer controls.
Perplexity Personal ComputerHybrid desktop agentLocal Mac apps/files/Comet plus Computer model orchestration.Mac mini can keep work going; iPhone remote access.Host Mac must remain available for local work. “On-device authorization” is not local model inference.Very close desktop rival. Mac access expanded to everyone on 5 Oct. Compare local workflows directly.
Claude / CoworkCloud tasks + optional local accessNew Pro/Max tasks move to cloud on 6 Oct; local access still needs Desktop open.Remote schedules can run with devices off.Computer use is Pro/Max beta. Older Academy pages still describe local schedule limits; current Help Center takes precedence.A real rival for scheduled work and files. Do not exclude Anthropic from this landscape.
Copilot AutopilotEnterprise work agentMicrosoft organizational environment.Continues independent work; private-preview expansion announced.Access and enterprise context make it a different buying decision. No independent trial found here.Watch for work buyers. Local deployment may help some buyers, but does not establish legal compliance.

The current official Claude Help Center describes cloud schedules; an older Academy lesson still describes local-only schedules. This report uses the current Help Center. Perplexity’s pages disagree on the minimum macOS version, so this report does not quote one.

+

06 / God priorities

A practical build order

Complete the code already present, then add the missing responsibility and delivery features. These are product recommendations. The proof column describes a future manual demonstration; none was run for this report.

P0 prove · P1 add · P2 extend
Priority / outcomeCurrent source stateMinimum useful behaviorProof to show a user
P0Make routines reviewableRoutine model, runner, clock and chat tools exist.Create/list/cancel, next run, last result, timezone, missed-run state and approval hold.Ask for a weekday brief; close and reopen the app; show one result, one catch-up and no repeated action.
P0Close the action and recovery journeyApproval queue, task history and result projection exist.Draft → approval → execution → linked result; sign-in and failure recovery stay with the same task.Approve one synthetic local task. Interrupt it, resume it, and show one final output with its action history.
P1Give continuing goals a clear ownerTasks/routines exist; a standing goal planner was not found.Goal, completion condition, next step, last evidence, blocked reason, cancel and resume.“Prepare my Friday meeting.” Add one new input on Thursday; show plan changed without starting a duplicate task.
P1Reach the paired deviceApproval sync is present; God outbound delivery is not proven.One alert per decision, result deep link, approval expiry and offline-device status.Leave Desktop running; use the paired phone to inspect and decide the same approval.
P1Make personal memory correctableLocal recall and Secretary learned preferences exist.Fact/preference, source, confidence, date, edit, forget and exclusion from future use.Correct a mistaken preference; a later task uses the correction and cites its source.
P1Add narrow event-driven workSaved time routines now exist; dedicated God event subscriptions were not found.Start with new mail/calendar events, duplicate suppression and safe wake catch-up.Two updates to one event yield one revised plan and one approval request.
P2Add scoped autonomy controlsStanding natural-language rules and approval gates exist.Separate read, draft, write and share scopes; show which rule allowed an action.Allow draft creation, require send approval, block a chosen folder; each boundary holds.
P2Make data and model routes clearLocal and optional remote models exist.Visible model route, capture indicators, connector scope and route-specific privacy statement.A user can show which parts of a task stayed local and which account calls crossed a network.

Where God can compete

  • Local model + personal records: offer a clear local path for meetings, captures and chat recall.
  • Evidence behind an action: show the meeting, message or record that caused a proposal.
  • Simple controls: let a user inspect, correct and remove remembered context.
  • Predictable local operation: show whether the host is available and which run was missed.

Features to defer until the core works

  • Phone-network calls: separate service, number, cost and approval decisions.
  • Automatic spending: earn trust with prepared purchases and review first.
  • Many named agents: define ownership and hard permissions before adding personas.
  • Demonstration teaching: build it on dependable execution and recovery.

Positioning limit: local execution does not automatically mean all data stays local, zero operating cost, or regulatory approval. God’s source supports optional remote models, connected accounts and sync. State the active route and its limits.

+

07 / Demo scripts

Five tasks to use in a real comparison

Use the same synthetic inputs for each product. Record the output, human interventions, wait time and recovery behavior. These are ready-to-use prompts and visible acceptance criteria.

01

Morning brief

Every weekday at 08:00, use my calendar and open action items to prepare a brief. Show urgent decisions and their sources. Do not send or modify account data.

Visible proof: Saved schedule, next run, source links, one result per run. Show what happens if the device was asleep.

God routines; compare dots, Spark, Claude and Muse.

02

A continuing meeting goal

Prepare me for Friday’s supplier meeting. Keep a list of open questions. Update the plan when I add a new note. Ask before contacting anyone.

Visible proof: One goal persists across conversations. New input changes the same plan. Approval is separate from preparation.

Compare Muse Goals and dots responsibilities.

03

A correction that sticks

My preferred meeting time is 10:00, not 09:00. Show the source of the old preference, correct it, and use 10:00 in the next draft.

Visible proof: Old evidence remains inspectable; active preference changes; next draft uses the correction.

Compare Muse editable memory; God learned preferences.

04

Approve once, recover once

Create a summary file from these synthetic notes. Before sharing it, show the recipients and exact content. If a step fails, explain what ran and what still needs me.

Visible proof: No send before approval; clear failure state; retry does not duplicate a completed action.

Compare all action agents; use synthetic data and a local output first.

05

Out-of-app decision

While Desktop is running, prepare the next action and send its approval request to my paired phone. Let me inspect the evidence before I decide.

Visible proof: Same approval ID, one delivery, expiry, offline state and result link.

God remote sync; compare Grok mobile/Slack and dots channels.

+

08 / Sources and method

Inspect the evidence behind every claim

Source codes in the tables link directly to the evidence. Local source links need this repository; web links travel with the HTML file.

Method. Checked official product pages, documentation, changelogs and current policies. Used first-hand posts and named hands-on reviews for feedback. Read God’s screen, context, tools, schedules, approvals and preference-learning source.

Evidence boundary. We did not log in to external agents, run comparison tasks or run Off Grid. Maker claims describe intended capability. Code presence describes implementation work. Neither establishes a measured success rate.

Supplied research. The pasted research guided the feature list. Unverified prices, aggregate ratings, old privacy allegations and claimed Cue automations were not promoted to confirmed facts. The original material’s “no schedules” and “memory cannot be edited” claims were corrected where current primary sources contradict them.

Review method. Sources were selected for concrete tasks, failures or requests. This is a qualitative sample; it is not a survey. Duplicate posts of the same experience were counted once. There was too little independent Cue feedback to assign sentiment.

Source state. Checked 6 Oct 2026. God is under development and the working checkout can change. Private Pro links are for internal review; do not publish this file without reviewing those links.

M1
Meta · launch ↗Official · 8 Sep; updated 30 Sep

Goals, cloud VM, approvals, payment cards, US rollout.

M2
Meta · design ↗Official · accessed 6 Oct

Schedules, event responses, editable memory files, interruption controls, Goals tab.

M3
Meta · safety ↗Official · accessed 6 Oct

Sentinel, credentials, VM access boundaries.

I2
Instinct · privacy ↗Official · revised 26 Aug

Training exceptions, Workspace exclusions, deletion and disconnect behavior.

I3
Instinct · terms ↗Official · revised 26 Aug

Action safeguards, responsibility, fees; no current price table.

G2
Grok Bot · changelog ↗Official · latest 2 Oct, v0.66.0

Main Bot, Slack approvals, draft controls, failure status and notifications.

D2
OpenAI · dots access ↗Official · accessed 6 Oct

Pro 100/200/500; personal region restrictions; Business and Enterprise rollout.

D4
OpenAI · controls ↗Official · accessed 6 Oct

Four custom rule choices; separate pause, delegated stop and schedule cancellation.

C1
Manus · Cue announcement ↗Official · accessed 6 Oct

Agent identity, email, phone, wallet, computer, team chat; free invite access. Search metadata has an inconsistent publish date; date omitted.

A2
Claude · schedules ↗Official · accessed 6 Oct

Cloud schedules on paid plans; separate runs and review surfaces.

R1
Muse · hands-on review ↗TechRadar · 14 Sep

Shopping and utility-email trial; convenience and access concerns; published tier prices.

L4
God · routine runner ↗Local development source · 6 Oct

Saved routines, minute clock, conversation per routine, approval context.

L5
God · schedule rules ↗Local development source · 6 Oct

Once/daily/interval; 15-minute minimum; one catch-up within 24 hours.

L6
God · schedule tools ↗Local development source · 6 Oct

Schedule, list and cancel through tools. Runner registered in main/index.ts.

L7
Off Grid · approval sync ↗Private Pro development source · 6 Oct

Remote decision replication exists; mobile notification delivery not verified.

L10
God · user rules ↗Local development source · 6 Oct

Saved name, standing rules and chat/voice choice.

+ +
+ + \ No newline at end of file diff --git a/docs/API.md b/docs/API.md index 3bc9d3bf2..fbbbceff8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -408,6 +408,16 @@ When `status` is `completed`, `result` holds the same payload the synchronous ca have returned; when `failed`, an `error` object is present. `GET /v1/requests` lists recent requests. (Sync calls behave exactly as documented above — async is purely opt-in.) +Stop an image request with `DELETE /v1/requests/{request_id}`. It stops only the +named request, including work waiting for the image runtime. The response is +`{ "request_id": "…", "status": "failed", "cancelled": true }`. Polling then returns +`status: "failed"` with `error.type: "cancelled"` and +`error.message: "Image generation cancelled."`. Repeated cancellation is safe. +A completed request stays completed and returns `cancelled: false`. Unknown IDs +return 404. Other modalities return 409 because they do not support this endpoint. +Closing the async POST response does not stop its background work. Disconnecting +a synchronous image request stops its own image generation. + ## Performance, memory & timeouts The gateway is built for a single machine running everything locally, so it manages diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 8c885c0f9..ed6147ec5 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,6 +1,6 @@ # Off Grid AI Desktop — Design -The desktop adaptation of the Off Grid AI design philosophy. The brand canon is the mobile docs (`../../mobile/docs/design/DESIGN_PHILOSOPHY_SYSTEM.md` + `VISUAL_HIERARCHY_STANDARD.md`); **this doc keeps the same soul and adapts it for a desktop app.** Where this conflicts with the mobile docs on _layout/interaction_, desktop wins; where it conflicts on _brand_ (font, color, voice), the brand wins. +Read [DESIGN_PHILOSOPHY.md](DESIGN_PHILOSOPHY.md) before any Desktop UI change. It is synchronized from the canonical sibling `brand/DESIGN_PHILOSOPHY.md`. Shared brand principles follow that source; this document supplies Desktop layout and interaction details. --- diff --git a/docs/DESIGN_PHILOSOPHY.md b/docs/DESIGN_PHILOSOPHY.md index bb22d7d5b..50d93551c 100644 --- a/docs/DESIGN_PHILOSOPHY.md +++ b/docs/DESIGN_PHILOSOPHY.md @@ -1,71 +1,175 @@ -# Off Grid AI Desktop — Design Philosophy (whole app) +# Off Grid — Design Philosophy (shared source of truth) -The binding design philosophy for **every screen** of Off Grid AI Desktop — chat, Day, -Replay, Reflect, Actions, Connectors, Meetings, Models, Entities, Settings, Onboarding, -and everything built hereafter. Decided 2026-06-23. Pairs with `docs/DESIGN.md` (brand -canon) and the memory `ui-component-standard`. +This is the **canonical, cross-platform design philosophy** for Off Grid. Every surface — Off +Grid Desktop, Off Grid Mobile, the Console, the website, and every landing page — inherits from +here. When a platform doc and this doc disagree on a shared principle, this doc wins; platform +docs own only the platform-specific mechanics (component libraries, token accessors, layout +density) and link back here. -> This is not a chat-only spec. It governs the entire application. New surfaces and -> refactors of existing ones must follow it. +Read this before designing or reviewing any Off Grid surface. It pairs with the copy guides in +this folder (`README.md`, `brand_tone_voice.md`) — the look and the words carry the same +disposition: give the user something clean and honest, show the mechanism, never decorate to +impress. -## 1. North star +--- -**Smooth. Easy. Inevitable.** Every surface should feel effortless and alive — the right -thing appears at the right moment, nothing feels like a tool you must operate. The product -should feel breathtaking through restraint and motion, not clutter. It is private, -on-device, and fast — the UI should feel that way too: calm, dense, immediate. +## The disposition -## 2. Components — reuse, never build (binding) +The interface is quiet on purpose. It is private, on-device, and fast — and it should feel that +way: calm, dense, immediate. Nothing shouts. The product earns trust by being clear and by +getting out of the way, not by ornament. Restraint and motion do the work that gradients and +color usually do elsewhere. Let the content — the memory, the AI response, the captured moment — +be the thing that shines. -- **Use ONLY the approved libraries.** No custom UI components, anywhere: - - **shadcn/ui** — foundation (buttons, inputs, dialogs, menus, tabs, tooltips, …) - - **Aceternity UI** — high-end effects - - **Magic UI** — text & button animations - - **Motion Primitives** — advanced transitions -- **Pull, don't write:** `npx shadcn add ` / `@aceternity/` / `@magicui/`. - Choose from the catalog index `component-library-animations/skills/component-library-index.md` - (the repo's component files are demo stubs — pull the real one from the registry). -- `components.json` is configured with the `@aceternity` + `@magicui` registries + shadcn. +**Silence, clarity, and function over form.** -## 3. Brand — Off Grid AI identity (binding) +--- -- **Typeface:** Menlo (monospace), everywhere. Terminal/brutalist. -- **Accent:** emerald — `#34D399` (dark) / `#059669` (light). The only accent. -- **Base:** black / `#0A0A0A` + white; neutral grays. Flat, sharp, dense. No gradients - beyond brand, no decorative tiles, no emojis in UI. -- **Theme-aware tokens:** shadcn semantic tokens (`--color-primary`, `--muted`, `--border`, - `--ring`, …) are mapped to the `--og-*` tokens in `src/renderer/src/assets/main.css` - `@theme`. Result: any approved-library component inherits the brand with **zero - per-component styling**, and flips light/dark automatically. Always rely on this mapping - rather than hardcoding colors. +## Shared principles (binding on every platform) -## 4. Motion (binding) +### 1. Brutalist / terminal aesthetic +Minimal, functional, terminal-inspired. Flat, sharp, dense. Every element earns its place; +remove before adding. No decorative tiles, no 3D effects, no emojis in the UI, no gradients +beyond the brand. -- Animate **only** `transform` and `opacity`. Never layout-thrashing properties. -- Timings: micro 100–150ms · hover/spring 200–300ms · reveal 300–500ms. -- Always honor `prefers-reduced-motion`. -- Motion clarifies state and guides attention — it never decorates. +### 2. Typeface: Menlo, monospace, everywhere +One font family across the whole product. Weights stay light (roughly 200–400). Hierarchy comes +from size, weight, and spacing — never from mixing fonts. -## 5. Interaction principles (app-wide) +### 3. Accent: emerald, and only emerald +- Dark mode: `#34D399` +- Light mode: `#059669` -- **No needless modes/toggles.** Infer intent; expose advanced controls only when wanted, - tucked away. -- **Stream everything** that takes time; never a frozen "thinking" wall — use tasteful - in-progress states (skeletons, shimmer, progress). -- **Sensible defaults.** Nothing should require configuration before it works. -- **Desktop-first density.** Multi-column, side panels, hover affordances, dense lists — - never mobile-first. +The single accent color. Used sparingly — active states, focus, the one important action on a +screen. Everything else is a monochrome hierarchy. Do not introduce a second accent; do not +color-code information (use position, size, and weight instead). Semantic colors (error/red) +exist only for their exact purpose. -## 6. Code standard (standards-kit, binding) +### 4. Base: black / white + neutral grays +Dark mode background is pure-ish black (`#0A0A0A`); light mode is clean white (`#FFFFFF`). +Build depth with a small tiered surface system (background → surface → nested/input), not with +shadow. High contrast in both themes. -- Cyclomatic complexity **< 8**. -- **PascalCase** for UI/Types, **camelCase** for logic. -- Strict import ordering; no `console.log`, magic numbers, or unused imports. -- Accessibility: `aria-label` on icon buttons, `alt` on images, 4.5:1 contrast. +### 5. Tokens, not magic numbers +All color, spacing, and typography come from **`@offgrid/design`** (and each platform's token +layer that maps to it). No hardcoded hex, no magic numbers in component styles. Components +inherit the brand and flip light/dark automatically through the token mapping — never hardcode a +color that a token already expresses. -## 7. Applications of this philosophy +### 6. Visual hierarchy through size and weight, not color +Primary → secondary → tertiary is expressed by typographic scale and spacing. Labels are quiet +uppercase "whispers" in muted tone; metadata is small and muted. Give content breathing room at +the top level and tighten within components. Respect information density where it serves scanning. -- **Chat / conversational surface:** `docs/CHAT_UX_SPEC.md` — one conversation where the - model generates images, artifacts, and clarifying questions inline at the right point. -- **Every other screen:** the **UI standardization audit** in `ROADMAP_DESKTOP.md` tracks - bringing the whole app to this philosophy, screen by screen. +### 7. Motion clarifies, never decorates +- Animate **only** `transform` and `opacity`. Never layout-thrashing properties. +- Timings: micro 100–150ms · hover/spring 200–300ms · reveal 300–500ms. +- Always honor `prefers-reduced-motion`. +- Stream anything that takes time; never a frozen wall — use skeletons/shimmer/progress. +- Functional animation only. If it doesn't clarify state or guide attention, cut it. + +### 8. Sensible defaults, no needless modes +Nothing should require configuration before it works. Infer intent; tuck advanced controls away +until wanted. Accessible by default: `aria-label` on icon buttons, `alt` on images, ≥4.5:1 +contrast, touch targets ≥44px. + +### 9. Use the space of the host surface +Do not make one platform imitate another. Desktop uses its wide canvas for responsive grids, +master-detail views, tables, and side panels. Mobile uses a focused vertical flow and progressive +disclosure. In both cases, avoid purposeless empty space and horizontal page overflow. + +### Anti-patterns (avoid everywhere) +Colorful gradients or heavy shadows · multiple accent colors · rounded pill shapes (use minimal +~8px radius) · decorative animation · mixed font families · 3D effects · color-coded +information · purposeless empty space · cluttered layouts. + +--- + +## Desktop profile + +Desktop is a wide, mouse-driven, keyboard-driven, multi-window surface. + +- Fill the canvas with responsive multi-column grids, dense tables, master-detail views, and side + panels. Do not center a phone-width column on a wide window. Keep controls near their content. +- Use tight 4/8/12px spacing inside dense groups and clearer separation between groups. Keep + headers, filters, tabs, and column labels visible while their body scrolls. +- Treat hover and keyboard input as first-class states. Reveal secondary actions on hover and + provide shortcuts for repeated navigation and submission. +- Use the shared `SidePanel` for settings, editors, previews, and multi-step details. It must close + by Escape, outside click, and its close control. Reserve a centered dialog for a short blocking + confirmation. +- Desktop typography roles are: title 18px; body and subtitle 14px; description 12px; metadata and + labels 10–11px. Labels are uppercase, muted, and tracked. +- In Tailwind, use `font-mono`, `rounded-md`, neutral surface and border tokens, and the emerald + token for active, focus, and primary-action states. Use `@phosphor-icons/react` for icons. +- Interactive controls transition in 100–150ms and use a small pressed scale. Panels and dialogs + use opacity plus scale or slide over 120–200ms. Keep exit animation and reduced-motion behavior. +- Radix dialogs are centered with the CSS `translate` property. Their keyframes may animate only + `transform: scale()` and opacity; adding a transform translation causes the dialog to jump. + +## Mobile profile + +Mobile is a touch-driven, narrow surface. Use a clear vertical flow, compact groups, and progressive +disclosure. Touch targets are at least 44px. + +### Mobile tokens + +- Access colors and shadows through `useTheme()` and styles through `useThemedStyles()`. +- Use `TYPOGRAPHY`, `SPACING`, and `FONTS` from `src/constants`; never hardcode their values in a + component. +- The spacing scale is 4, 8, 12, 16, 24, and 32px (`xs` through `xxl`). Standard screen padding is + 24px, card padding is 16px, and normal control or list spacing is 8–12px. +- Surface tokens are `background`, `surface`, and `surfaceLight`. Text tokens are `text`, + `textSecondary`, and `textMuted`. Use `borderFocus` for focus and `error` only for errors. + +### Mobile typography hierarchy + +| Role | Token | Size | Weight | Use | +| --- | --- | ---: | ---: | --- | +| Display | `TYPOGRAPHY.display` | 22px | 200 | Large numeric values | +| Title | `TYPOGRAPHY.h2` | 16px | 400 | One screen title | +| Body | `TYPOGRAPHY.body` | 14px | 400 | Messages, inputs, buttons, and primary content | +| Subtitle | `TYPOGRAPHY.h3` | 13px | 400 | Sections, cards, and modal titles | +| Description | `TYPOGRAPHY.bodySmall` | 13px | 400 | Help and explanatory text | +| Metadata | `TYPOGRAPHY.meta` / `label` | 9–10px | 300–400 | Timestamps, metrics, and uppercase labels | + +`h1` is reserved for rare hero text and is not a normal screen title. Hierarchy comes from size, +weight, and tone. Metadata must remain quiet. + +### Mobile components + +- Top-level tabs use the shared `ScreenHeader` with 12px horizontal and 8px vertical padding. + Pushed screens use 16px horizontal and 12px vertical padding, a back control, a title, and an + optional trailing action. Headers use the surface token, a subtle divider, and a stable stacking + level. Onboarding, lock, and model-download screens may use full-screen layouts. +- Buttons and inputs use an 8px radius, token spacing, and clear focus, pressed, disabled, loading, + and error states. Cards use `surface`, a subtle border, and at most a small theme shadow. +- Use `react-native-vector-icons`; Feather is the default. Do not use emojis as interface icons. +- Empty states contain one direct message and, when useful, one clear action. + +## Component ownership + +Use `@offgrid/design` for tokens and `wednesday-solutions/component-library-animations` for reusable +visual and interaction primitives. Search the existing product components before adding anything. +Extend a shared primitive when the concept is the same; do not fork a local visual copy. + +Console, website, and landing pages inherit the shared principles directly. + +--- + +**Remember:** the UI should feel like the product — private, quiet, fast. Let the AI's +capabilities and the user's content speak, not the interface. + +## Desktop source record + +Synchronized on 30 September 2026 from the sibling `brand/DESIGN_PHILOSOPHY.md`, +commit `15e6b2f` (7 September 2026). The previous Desktop guide last changed at +`332e48397` (27 August 2026). Use this guide before each Desktop UI change. +Shared principles follow the brand source; Desktop implementation details also +follow `docs/DESIGN.md`. + +Desktop uses `@offgrid/design` tokens and the shared operator primitives from +`wednesday-solutions/component-library-animations`. Consume the package export; +do not install separate registry copies or use catalogue demos as controls. +Keep Tailwind layers in `theme, base, components, utilities` order so resets cannot +override the shared controls. diff --git a/docs/OFF_GRID_WORK_LOG_AUDIT_2026-10-06.md b/docs/OFF_GRID_WORK_LOG_AUDIT_2026-10-06.md new file mode 100644 index 000000000..a932d429f --- /dev/null +++ b/docs/OFF_GRID_WORK_LOG_AUDIT_2026-10-06.md @@ -0,0 +1,235 @@ +# Off Grid AI work log audit + +Date: 2026-10-06 + +Reported by Codex. + +This document stores the source audit of the supplied work log, including Desktop, Pro, the browser extension, and related mobile code. It includes privacy, correctness, UI, and consumer experience findings. + +The UI review uses Off Grid AI's focus on simple actions, clear status, useful recovery, and user control. Sources include the shared brand design philosophy, Desktop design guide, mobile design guides, and the Off Grid UI skill. The component library checkout inspected was `b2268673cf980ce8d9a4a1563d49bd1babc10c0f`. No component was selected or changed. + +## Scope and evidence + +| Repository | Source inspected | +|---|---| +| Desktop account work | Existing checkout at `/Users/user/.codex/worktrees/communication-research/desktop`, branch `feat/direct-account-connections`, commit `0b1e49ef8` | +| Desktop Pro account work | Its private `pro/` submodule, commit `f358ec284b2b03f3d8218b78072b4761f25fc720` | +| Desktop extension bridge | Source available in `/Users/user/wednesday/off-grid-ai/desktop` during the audit | +| God Twin | Git source on `feature/god-twin-current-main`, commit `45e587c5f`; inspected without changing branches | +| Browser extension | `/Users/user/wednesday/off-grid-ai/browser-extension`, branch `feature/browser-companion`, commit `65590e8` | +| Mobile | `/Users/user/wednesday/off-grid-ai/mobile`, branch `feat/uat-latest-main-pro-20261005`, commit `b1428ab3`, and its private Pro code | + +Mobile work was not listed in the supplied log. The mobile review covered related tool routing, remote image generation, and deletion paths. It was not a review of every mobile feature. + +Evidence labels: + +- **Source:** The behavior is visible in code. It has not been reproduced in a running app during this audit. +- **Risk:** The source shows a failure condition that still needs a live reproduction. +- **Gap:** The inspected branches do not support the work log's claim. + +Priorities in this document: + +- **P0:** Immediate privacy issue. +- **P1:** Major function, trust, recovery, or release issue. +- **P2:** Usability, accessibility, or less severe failure issue. + +All findings are open. No fixes were made. No tests, live app checks, or packaged builds were run. The Response column was added on 6 Oct 2026 by the follow-up work: it says what changed for each row, or why no fix is needed, with the commits. The local file links identify the exact source inspected; they may need to be updated if those checkouts move. + +## Findings + +| # | Priority | Product / area | Finding and effect on the user | Evidence | Required result | Response (6 Oct) | +|---:|:---:|---|---|---|---|---| +| 1 | P0 | Desktop / live-only accounts | **Manual sync can save live-only account data into memory.** Automatic sync checks the flag; manual ingestion does not. | Source: [ingestion handler](/Users/user/.codex/worktrees/communication-research/desktop/pro/main/crm-ipc.ts:354), [automatic guard](/Users/user/.codex/worktrees/communication-research/desktop/pro/main/ingest.ts:80) | Enforce live-only access in the ingestion entry point, including manual calls. | Fixed. Pro `72960c2`: the ingestion entry point refuses a live-only account, so manual Sync recent cannot save one into memory either. OGAD `ba8581fd1`: the connector screen says accounts are read live. | +| 2 | P1 | Extension / autofill | **The cross-site frame check always passes at this call site.** Autofill supplies the frame URL as both URLs. The script can run in all frames after all-sites access is granted. | Source: [fill.ts](/Users/user/wednesday/off-grid-ai/browser-extension/src/shared/autofill/fill.ts:114), [frame rule](/Users/user/wednesday/off-grid-ai/browser-extension/src/shared/autofill/safety.ts:167), [frame registration](/Users/user/wednesday/off-grid-ai/browser-extension/src/background/inline-register.ts:19) | Check the actual top page and frame before releasing credentials. | Fixed. Ext `4650710`: autofill compares the real top page with the frame before releasing credentials. Ext `b641a52` tests it with real iframes; the cross-site case fails against the old code. | +| 3 | P1 | Mobile / tools | **Tools with the same name can run on the wrong server.** A second server replaces the first tool's owner. Tool switches also use the name alone. | Source: [tool ownership](/Users/user/wednesday/off-grid-ai/mobile/pro/mcp/mcpStore.ts:70), [tool switches](/Users/user/wednesday/off-grid-ai/mobile/pro/ui/McpToolsScreen.tsx:57), [execution route](/Users/user/wednesday/off-grid-ai/mobile/pro/mcp/mcpService.ts:332) | Keep the selected server attached to each enabled tool and execution. | Fixed in mobile, not yet merged. mobile-pro `5f498c72` and OGAM `7128f923` ([OGAM#701](https://github.com/off-grid-ai/OGAM/pull/701), [mobile-pro#86](https://github.com/off-grid-ai/mobile-pro/pull/86)): each enabled tool keeps the server it was turned on for, and that server runs it. Device check pending. | +| 4 | P1 | Desktop / Google removal | **Removing an account does not revoke its grant at Google in the inspected removal path.** Local deletion leaves the provider grant active. | Source: [removal path](/Users/user/.codex/worktrees/communication-research/desktop/src/main/mcp.ts:151), [caller](/Users/user/.codex/worktrees/communication-research/desktop/pro/renderer/AccountQuickConnection.tsx:115) | Revoke the Google grant and report any failure clearly. | Fixed. Pro `bb89261` and OGAD `0ee4da77d`: Remove revokes the sign-in at Google. Pro `c6275e8` (6 Oct): when Google cannot be reached, a notification names the account and says where to remove the access. | +| 5 | P1 | Desktop / Google refresh | **A late refresh can restore a removed token.** The response writes a secret without checking whether the account still exists or its credentials changed. | Source: [google-rest.ts](/Users/user/.codex/worktrees/communication-research/desktop/pro/main/google-rest.ts:34) | Discard refresh results after removal or a change of credentials. | Fixed. Pro `d6eb667`: a refresh that finishes after removal or after a newer sign-in is discarded and saves nothing. | +| 6 | P1 | Desktop / account permissions | **Partial consent still fails sign-in.** The user cannot connect with fewer permissions than selected. This conflicts with the log's Partial access claim. | Source: [Google](/Users/user/.codex/worktrees/communication-research/desktop/pro/main/google-authorization.ts:55), [Microsoft](/Users/user/.codex/worktrees/communication-research/desktop/pro/main/microsoft-authorization.ts:60) | Save granted access and show which services remain unavailable. | Fixed. Pro `bb89261`: a partial grant connects with what was granted, for Google and Microsoft, and the card lists what was not granted. | +| 7 | P1 | Desktop / account recovery | **Broken and disabled accounts disappear from their card.** The user loses the account's status and recovery action. | Source: [account filter](/Users/user/.codex/worktrees/communication-research/desktop/pro/renderer/AccountQuickConnection.tsx:74) | Keep every account visible with its state and a clear recovery action. | Fixed. Pro `998c9f2`: every account stays on its card with its state and a Sign in again action. | +| 8 | P1 | Desktop / Obsidian | **The quick connection manages only one vault.** The handler chooses the first connector; the UI stores one vault. | Source: [status handler](/Users/user/.codex/worktrees/communication-research/desktop/pro/main/obsidian-ipc.ts:41), [UI state](/Users/user/.codex/worktrees/communication-research/desktop/pro/renderer/ObsidianQuickConnection.tsx:24) | List and manage each vault separately. | Fixed. Pro `84edd81`: each vault is listed, added, changed and removed on its own; the same folder cannot be added twice. Checked in the real app on 6 Oct (`e2e/accounts-lifecycle.spec.ts`). | +| 9 | P1 | Desktop + extension / link | **Concurrent encrypted messages can be sent out of sequence.** A valid frame can then fail authentication and close the link. Sequence numbers are assigned before asynchronous encryption finishes. | Risk: [sequence assignment](/Users/user/wednesday/off-grid-ai/browser-extension/src/shared/bridge/socket.ts:94), [extension send](/Users/user/wednesday/off-grid-ai/browser-extension/src/shared/bridge/socket-client.ts:203), [Desktop send](/Users/user/wednesday/off-grid-ai/desktop/src/main/extension-bridge/bridge-socket.ts:122) | Preserve sequence order through encryption and sending. | Fixed. Ext `4650710` and OGAD `641d65e63`: frames are sealed and sent in sequence order. Ext `4dbcb9e` tests out-of-order encryption; the link stays open. | +| 10 | P2 | Extension / Web Use | **Key commands can report success without performing the action.** Synthetic events do not provide normal Tab, Backspace, or arrow-key behavior. Enter has a separate form-submit action. | Source: [pressKey](/Users/user/wednesday/off-grid-ai/browser-extension/src/shared/agent/aria.ts:103) | Perform supported actions or return a clear unsupported result. | Fixed. Ext `f64fa8c`: keys perform their action or report that they could not. Ext `8f5cecd` adds tests, which found more cases fixed in `15321eb` (Enter on buttons, links and text areas; Backspace in email fields and never in a password). | +| 11 | P1 | Extension / chat deletion | **Deleted chats can return after sync.** Offline deletion is not retained for the next connection; sync imports the Desktop copy again. Delete all changes only the local library. | Source: [local deletion](/Users/user/wednesday/off-grid-ai/browser-extension/src/sidepanel/library-store.ts:107), [merge behavior](/Users/user/wednesday/off-grid-ai/browser-extension/src/shared/desktop/merge.ts:106), [remote delete](/Users/user/wednesday/off-grid-ai/browser-extension/src/background/chats.ts:78) | Keep deletion effective after reconnecting. Show whether it applies locally or across devices. | Fixed. Ext `6ee5a8b`: deletions are kept until the desktop confirms them and are not imported again; Delete all is local only and says so. Ext `e46015d` adds 8 tests. | +| 12 | P1 | Extension / privacy copy | **Nothing is sent anywhere is false when paired.** Saved chats are pushed to Desktop. This gives the user a wrong account of data movement. | Source: [settings copy](/Users/user/wednesday/off-grid-ai/browser-extension/src/sidepanel/SettingsScreen.tsx:151), [chat push](/Users/user/wednesday/off-grid-ai/browser-extension/src/sidepanel/library-store.ts:100) | Explain local storage and paired-device sync accurately. | Fixed. Ext `6ee5a8b`: Settings says chats are saved here and sync with the paired desktop. | +| 13 | P2 | Extension / destructive action | **Deleting one chat has no confirmation or undo.** A small trash button removes the chat at once. The supplied log already lists deletion confirmation as open work. | Source: [history action](/Users/user/wednesday/off-grid-ai/browser-extension/src/sidepanel/HistoryDrawer.tsx:153) | Provide a short confirmation or a reliable undo action. | Fixed. Ext `e1c150a`: deleting a chat asks first; a chat can also be renamed in place. | +| 14 | P2 | Desktop / destructive action | **Account removal has no confirmation.** The button calls removal immediately, despite the log claiming confirmed removal. | Source: [Remove button](/Users/user/.codex/worktrees/communication-research/desktop/pro/renderer/AccountQuickConnection.tsx:421) | Name the account and explain the effect before removal. | Fixed. Pro `998c9f2`: Remove names the account and explains the effect before removing it. OGAD `432d8f377` (6 Oct) does the same for MCP connectors. | +| 15 | P2 | Mobile / image cancellation | **Cancel during file transfer can still save the image.** Cancellation is checked before file work, but not after it. | Source: [remote image flow](/Users/user/wednesday/off-grid-ai/mobile/src/services/remoteImageGeneration.ts:66) | Stop the transfer where possible and check cancellation before publishing the result. | Fixed in mobile, not yet merged. OGAM `5962357b`: Cancel stops the transfer, checks again after the file is written and deletes the partial file. Device check pending. | +| 16 | P2 | Mobile / failed image transfer | **A failed transfer can leave an untracked file.** HTTP or format errors occur after the file has been written, without cleanup. | Source: [download and error paths](/Users/user/wednesday/off-grid-ai/mobile/src/services/remoteImageGeneration.ts:79) | Remove incomplete files and keep a useful retry path. | Fixed in mobile, not yet merged. OGAM `cce8e478`: any failure after the transfer starts deletes the partial file, so Retry starts clean. | +| 17 | P2 | Mobile / delete feedback | **Chat deletion can claim that images were deleted when file removal failed.** Records are removed first and errors are ignored. | Source: [chat deletion](/Users/user/wednesday/off-grid-ai/mobile/src/screens/ChatsListScreen.tsx:99) | Report incomplete deletion and retain enough information to retry it. | Fixed in mobile, not yet merged. OGAM `55b6a9c2`: each image file is removed before its record; if one stays, the user is told how many and that they can delete them from the Gallery. The same pattern in the in-chat delete (`useChatGenerationActions.ts`) is noted as follow-up. | +| 18 | P2 | Extension / keyboard access | **Settings tabs have incomplete keyboard behavior.** They use tab roles, but have no arrow-key navigation or focus management. | Source: [PillTabs](/Users/user/wednesday/off-grid-ai/browser-extension/src/sidepanel/ui.tsx:39) | Support the keyboard behavior expected of tabs. | Fixed. Ext `9edfe6c`: the tabs follow the tabs pattern (roving focus, arrows, Home and End, panels labelled by their tab), with unit and end-to-end tests. | +| 19 | P2 | Mobile / tool recovery | **The tools screen combines loading, missing data, and no tools into one empty state.** It says connect the server first without showing the actual connection state or a connect action. | Source: [tool data](/Users/user/wednesday/off-grid-ai/mobile/pro/ui/McpToolsScreen.tsx:41), [empty state](/Users/user/wednesday/off-grid-ai/mobile/pro/ui/McpToolsScreen.tsx:173) | Show the actual state and one useful action: wait, reconnect, retry, or return. | Fixed in mobile, not yet merged. mobile-pro `ae3a304f` and OGAM `6b912def`: the tools screen shows the real state with one action (wait, Connect, Try again or Go back). | +| 20 | P1 | Desktop / Ares microphone | **The Listen control shows an active state but cannot stop listening.** Clicking it always sends a wake request; the voice handler only starts capture when idle. | Source on `feature/god-twin-current-main`: `src/renderer/src/components/GodTwinCompanion.tsx:303`, `src/renderer/src/components/use-chat-voice-turns.ts:439` | Give the user a direct Stop action while listening. | Fixed. OGAD `a4a3c5b41`: the Talk button stops listening when pressed again. | +| 21 | P2 | Desktop / Ares interruption | **Ares appears by default above other applications.** Its large window stays on top and follows the active display. This can interrupt the user's work. | UX risk on `feature/god-twin-current-main`: `src/main/god-twin-window.ts:59`, `:172` | Make the first appearance clear and easy to dismiss; verify that it does not block normal work. | Fixed. OGAD `0599fc553` (6 Oct): Ares on the desktop is off until turned on in God settings, and when on it floats above windows but not over full-screen apps. | +| 22 | P2 | Desktop / Ares controls | **The control bar depends on small symbols and hover labels.** Controls are about 24px high, and the bar uses fixed light colors. | Source on `feature/god-twin-current-main`: `src/renderer/src/components/GodTwinCompanion.tsx:295` | Use clear actions, adequate targets, visible focus, and theme tokens. | Fixed. OGAD `a4a3c5b41`: controls are labelled with visible focus. OGAD `aea6cbeb2` (6 Oct) shares one control row between the God screen and the desktop, each a full-height target. The desktop bar keeps a dark fill because it floats over any app. | +| 23 | P1 | Desktop / integration state | **The named account branch does not contain the claimed God or extension bridge work.** There is no single inspected branch that represents the log's complete result. | Gap: Git trees for `feat/direct-account-connections` and `feature/god-twin-current-main` | Identify and review the exact Desktop and Pro commits intended for release. | No fix needed. The branch now holds the God work, the extension bridge and the wake word, and it is the branch under review (OGAD#176, desktop-pro#90). | +| 24 | P1 | Desktop / God claims | **The claimed wake-word journey is not present in the inspected account or God Twin branches.** The God Twin branch provides a manual wake action; no wake-word detector or its settings were found. | Gap: branch source searches and the God Twin diff | Locate the implementation or change the work log's status from Done. | No fix needed. The wake word is on the branch: `src/main/god-twin-wake.ts`, its settings in God settings, and `src/main/__tests__/god-twin-wake.integration.test.ts` (7 cases, including free builds). | + +Rows 4 and 14 cover separate parts of the same removal journey: provider revocation and the user's confirmation. Rows 15 and 16 cover cancellation and failed-transfer cleanup. Fixes may share a focused change, but each result must be verified. + +## Verification that remains + +| Area | Verification that remains | +|---|---| +| God chat, current context, proactive posts, and workflows | Review the exact branch that contains these features, then verify the complete user journey. | +| Account sign-in and recovery | Verify real Google and Microsoft consent, cancellation, expiry, reconnect, and removal. | +| Extension experience | Verify Chrome and Firefox, narrow panel widths, keyboard use, themes, queues, tab switching, replay, and Stop. | +| Mobile experience | Verify the affected journeys on iOS and Android, including touch targets, text scaling, interruptions, and recovery. | +| Accessibility and visual layout | Inspect rendered screens for contrast, focus, clipping, motion, and readable status. | +| Packaging | Verify a packaged Desktop build and its private Pro code. The work log already marks this as blocked. | +| Work log status | Reconcile each Done claim with the exact release commits and its verification evidence. | + +This is the complete source findings list from this audit so far. It does not establish that all product behavior is correct. The remaining checks above are required before making that claim. + +## Original work log + +The supplied work log is preserved below so the claims can be compared with the findings. Its Done labels and test claims are statements from the supplied log, not verification completed by this audit. +```text +Off Grid AI work log +Everything done in this session across the browser extension, Off Grid AI Desktop (OGAD) and Pro, ranked P0 to P3, with what is still open. P0 is what you asked for directly or what protects privacy and correctness; P3 is cleanup. + +Ext: feature/browser-companion +OGAD: feat/direct-account-connections +Pro: feat/direct-account-connections +32 +Done +3 +Open +2 +Blocked +15 +P0 +13 +P1 +5 +P2 +4 +P3 +Status +All +done +open +blocked +Priority +All +P0 +P1 +P2 +P3 +Repo +All +Ext +OGAD +Pro +Pri Area What Repo Status +P0 God +God is a chat with Ares +Conversation with God beside the 3D Ares (listening, thinking, speaking), markdown answers, mic and text box. Replaces the old Assistant tab, open to everyone. OGAD Done +P0 God +God has context +A Right now card (time, next meeting, open to-dos, approvals waiting, accounts read), refreshed every minute; the same context goes with every question. OGAD, Pro Done +P0 God +God is proactive +Posts the day's plan, prep for a meeting starting within 20 minutes, and approvals waiting, each once; offers what fits now (prep me, what first, review, plan my day). OGAD, Pro Done +P0 God +Wake word "Aries" +Opt-in always listening; hears Aries, Ares, Aires, Ari's. Short clips are transcribed on this machine only, even when transcription is remote, and never saved. Settings: wake word, sensitivity, microphone. OGAD Done +P0 God +God is the assistant +One name: the chat switch, its Pro dialog and the Settings tool group say God. With Pro, God acts with Web Use and Computer Use. OGAD Done +P0 Setup +Branches merged +Checked out the accounts PRs (OGAD#176, desktop-pro#90), merged latest main and the God Twin PR (OGAD#151), resolved 5 conflicts keeping both sides. OGAD, Pro Done +P0 Accounts +Partial access +Unticked permissions at sign-in make a smaller connection, not a failed one; what is missing is shown. Google keeps earlier grants on update. Pro Done +P0 Accounts +Every account on its card +Broken and switched-off accounts stay visible with what each reads and a Sign in again button. Pro Done +P0 Accounts +Delete done properly +Remove asks first and revokes the Google sign-in at Google; a refresh finishing after removal no longer leaves a token behind. OGAD, Pro Done +P0 Accounts +Expired sign-in detected +A sign-in Google rejects marks the account as needing a new sign-in instead of looking connected while every call fails. Pro Done +P0 Accounts +Live-only stays live +Accounts are never copied into memory, including from the manual Sync recent; the connector screen says so. OGAD, Pro Done +P0 Extension +Private desktop link +Sealed end-to-end channel between the extension and the desktop: pairing, chats synced both ways, vault, desktop tools. Ext, OGAD Done +P0 Extension +Web tasks in this browser +The desktop's Web Use runs in the user's own tab through the extension, on Chrome without the debugger and on Firefox through the page path. Ext, OGAD Done +P0 Extension +Agent hands off to the desktop +Agent mode goes straight to the desktop's Web Use in the chat's own tab; the browser's own loop is the fallback. Ext Done +P0 Extension +Vault and in-field autofill +Key icon inside sign-in fields (LastPass style), two-step sign-ins, every safety rule enforced, Bitwarden corner cases covered. Ext Done +P1 Accounts +Duplicates +Signing in to a connected account updates it and says so; duplicate rows are flagged; a duplicate no longer blocks reconnect; Atlassian keyed on the person. Pro Done +P1 Accounts +Several Obsidian vaults +List, add, change and remove each; the same folder cannot be added twice; each vault is named. Pro Done +P1 Accounts +Onboarding copy and Reconnect +Onboarding says accounts are read live; a failed card load says so; Reconnect fixes the broken account, not the first. OGAD Done +P1 Extension +Accounts in Settings > Connectors +What each reads, what is missing, which need a sign-in, on/off and confirmed remove. A browser cannot add an account address as a web connector. Ext, OGAD Done +P1 Extension +Web Use row like OGAM +Using Web Use: goal with a live view and Stop; afterwards a step replay with scrubber, Play and full screen. The plan is hidden. Ext Done +P1 Extension +Next step after an Agent run +A suggested next instruction shows as the placeholder; Tab puts it in the box. Ext Done +P1 Extension +One chat per tab +Each browser tab keeps its own chat, as ChatGPT's panel does; a run drives its own tab only. Ext Done +P1 Extension +Queue and send now +Enter queues while a reply runs; Cmd/Ctrl+Enter sends now; queued messages can be sent or deleted. Ext Done +P1 Extension +Pointer and working bar +The agent pointer moves on the page and a bar says Off Grid AI is working in this tab. Ext Done +P1 Extension +Desktop's settings in the panel +Text, Tasks, Tools, Connectors, Image, Transcription, Voice and Remote tabs read and change the desktop over the link. Ext, OGAD Done +P1 Extension +Chat parity with the desktop +Tool rows, answer versions, thinking, message actions, retry, metrics, Stop shown as stopped. Ext Done +P1 Desktop chat +Answer versions on desktop +Regenerate and Resend keep previous and current answers (1/2), like the extension. OGAD Done +P2 God +Workflows and fallbacks +Prepared workflows under All workflows (Pro); desktop Ares mirrors state; a glow replaces the 3D Ares without WebGL. OGAD Done +P2 Extension +Models and polish +Models on four tabs including Computer Use; toolbar icon opens the panel; themes; close button; design canon. Ext Done +P2 Fixes +Connect your work crash +The section no longer crashes when the connector list comes back missing. OGAD Done +P3 Tests +Stale tests aligned +Theme default Dark, Pro slot list, OAuth cancel test, Google card tests, Workspace request count, account lifecycle DB tests added. OGAD, Pro Done +P3 Builds +Chrome builds +v31 (Web Use row) and v32 (accounts) sent. Ext Done +P1 PRs +Update PR descriptions +OGAD#176, desktop-pro#90 and ext#9 with screenshots and test output for God and accounts. All Open +P2 Extension +Remaining OGAM audit +Expandable rows, edit and resend details, history rename and delete confirm, generation details setting, attachment thumbnails. Ext Open +P2 Builds +Next Chrome build +v33 with the Agent next-step suggestion. Ext Open +P3 Tests +Real-desktop end-to-end rerun +Blocked here by the model cold-loading too slowly in this container. Ext, OGAD Blocked +P3 Tests +Packaging checks +Need a packaged app build; run on a machine or CI that builds the app. OGAD Blocked +``` diff --git a/docs/RELEASE_DESKTOP.md b/docs/RELEASE_DESKTOP.md index 7b275a9e7..6af1a1c6a 100644 --- a/docs/RELEASE_DESKTOP.md +++ b/docs/RELEASE_DESKTOP.md @@ -1,11 +1,72 @@ # Off Grid AI Desktop — Release Runbook -How we ship Off Grid AI Desktop: two artifacts (**core** = free, **pro** = -license-gated), signed + notarized, with Keygen license activation. Mirrors the +How we ship Off Grid AI Desktop: one app per platform with Pro code bundled and +locked until Keygen license activation. macOS is signed and notarized. Mirrors the mobile licensing model (same Keygen account/product — a key works on both). --- +## Release preparation — 8 October 2026 + +Scope: [OGAD #176](https://github.com/off-grid-ai/OGAD/pull/176), +[Desktop Pro #90](https://github.com/off-grid-ai/desktop-pro/pull/90), and +[Shared #46](https://github.com/off-grid-ai/shared/pull/46). +Other open PRs are outside this preparation scope. + +Support contact: `support@getoffgridai.co`. Author and package-maintainer metadata +are separate from the support address. + +Merge Shared #46 first, then Desktop Pro #90, then OGAD #176. Pro uses the new lazy +sync payload option from Shared. Release builds from `main` use Shared `main`. +OGAD pins Pro commit `86a14b9528781120b3762497a3404ee3f44d035e`; Shared #46 is at +`fde4f0b0d17add736e4da7fbccf76321cb3a300e`. Keep the pinned Pro commit available +after merging. An empty `pro_ref` uses that pin; it does not use Pro `main`. + +Current release gates: + +- OGAD CI run `37763437151` failed in DB journeys and lint. The source fixes + remove the two invalid image-size fallbacks. Focused lint passes. +- The Pro expiry/recovery test now expects the app to select another free port. + It verifies a real TCP connection to that port after paid features close. + All four service activation tests pass against a temporary SQLite profile. +- All eleven guide tests pass, including a regression for nested markup removal. +- Four image gateway journeys pass using real HTTP, SQLite, and a synthetic native + runtime. Image requests support `DELETE /v1/requests/{request_id}`. Cancelling a + queued request does not stop another running request. Mobile Stop must use this + endpoint, so record the paired Mobile and Desktop source commits together. +- Synchronous image work stops on HTTP disconnect, which matches the browser's + existing Stop behavior. Desktop detaches CDP before releasing a browser tab and + waits for detach during task cleanup. Retained result tabs keep their chat mapping. +- CodeQL alert 25 is closed after the iterative element-removal fix. SonarCloud + passes on cancellation source `f315a6ea1`. Check the final dependency-patch source + again before release. +- CI run `37774358922` passes fast coverage, real SQLite DB journeys, and both + native recording jobs. Heavy integration and UI checks are still running. +- Compatible dependency patches include `tar` 7.5.22, `proxy-addr` 2.0.8, + Electron 39.8.10, the MCP SDK, image libraries, and build tools. Two local native + archive install checks, a real HTTP proxy-address check, and native PNG + encode/decode pass. The built app starts with a fresh synthetic profile. +- The candidate production audit has zero critical, three high, and fourteen + moderate findings. The remaining high paths include Electron, `extract-zip`, + and `@xmldom/xmldom`. Major-version or forced downgrade fixes need compatibility + evidence. These findings remain open; a green CI result does not resolve them. +- `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` secret names are present in OGAD. + Their values and provider consent remain unverified in the release artifact. + +After manual verification and required checks pass, use **Build and Release** on +the selected source with `channel=beta`, empty `pro_ref`, and `artifact_only=true` +for a signed macOS candidate without publishing or sending the Slack announcement. +Verify the installed candidate with a fresh synthetic profile. Check launch, chat, +locked Pro, activation, account consent, guide output, and pairing recovery. +Record the source commits, workflow run, artifact checksum, OS, and manual results. +Do not mark the release approved while required checks or manual evidence are missing. + +The latest stable release is `v0.0.54`; the latest listed beta is +`v0.0.55-beta.114`. Let the workflow resolve the next version. Do not change the +version solely to clear the checks. + +--- + ## 0. Mental model - **Core build** — no `pro/` submodule code bundled (`__OFFGRID_PRO__ = false`). @@ -87,9 +148,12 @@ fingerprint and reclaim their slot. --- -## 3. Real release (signed + notarized, two artifacts) +## 3. Real release (one app per platform) -> Not yet wired into CI as two artifacts — see TODO at the bottom. The mechanics: +The manual `.github/workflows/release.yml` builds macOS, Windows, and Linux from +one resolved Core commit and its pinned Pro commit. `artifact_only=true` builds +only the signed macOS candidate. A normal release can send a Slack announcement; +obtain explicit authorization before dispatching that path. ### macOS (working today) @@ -98,19 +162,18 @@ Signing uses the Apple Developer ID + notarization (`electron-builder.yml` `APPLE_API_KEY*`. Do **not** add an afterSign re-sign hook — it invalidates the notarization staple (this was a past bug). -### Windows — PARKED +### Windows -Windows is on hold and owned elsewhere (the bundled llama-server doesn't start in -the Windows binary; being fixed separately). Azure Trusted Signing setup (§4) is -kept below for when Windows resumes, but it is **not** on the current path. Focus -is macOS core + pro. +The current workflow builds Windows and publishes its installer and update feed. +The Azure Trusted Signing notes in §4 are historical setup guidance, not evidence +that current Windows artifacts are signed or verified. ### Per-artifact config -Core and pro differ only by `OFFGRID_FORCE_CORE`, `productName`, `appId`, and -`artifactName` (see `scripts/build-mac-local.sh` for the exact overrides). They -must publish to **separate update channels** so electron-updater never feeds a -core user a pro binary (or vice versa). +The separate Core and Pro variants in `scripts/build-mac-local.sh` are local +diagnostic builds. Production ships the Pro-capable app locked by default. +macOS publishes `latest-mac.yml` and a compatibility copy, `pro-mac.yml`, for +older Pro installations. Stable and beta release paths are selected by the workflow. --- @@ -167,8 +230,7 @@ quickly. No EV needed. ## TODO before first paid release (macOS) -- [ ] Split `.github/workflows/release.yml` into **macOS** core + pro build jobs, - using `OFFGRID_FORCE_CORE` and the per-artifact overrides; separate update channels. +- [x] Use one production app per platform with Pro bundled and locked until activation. - [ ] Confirm the RevenueCat offering/products issue desktop-valid keys (they're product-scoped, so likely already do) and the key email copy isn't mobile-only. - [ ] Device-management UI (list/deactivate machines) — backend is ready diff --git a/docs/pr-screenshots/2026-10-06/god-screen.png b/docs/pr-screenshots/2026-10-06/god-screen.png new file mode 100644 index 000000000..456aa6ee0 Binary files /dev/null and b/docs/pr-screenshots/2026-10-06/god-screen.png differ diff --git a/docs/pr-screenshots/2026-10-06/google-choose-services.png b/docs/pr-screenshots/2026-10-06/google-choose-services.png new file mode 100644 index 000000000..ce5fde4fb Binary files /dev/null and b/docs/pr-screenshots/2026-10-06/google-choose-services.png differ diff --git a/docs/pr-screenshots/2026-10-06/google-own-app.png b/docs/pr-screenshots/2026-10-06/google-own-app.png new file mode 100644 index 000000000..e4954b981 Binary files /dev/null and b/docs/pr-screenshots/2026-10-06/google-own-app.png differ diff --git a/docs/pr-screenshots/2026-10-06/mcp-connector-edited.png b/docs/pr-screenshots/2026-10-06/mcp-connector-edited.png new file mode 100644 index 000000000..4b0280428 Binary files /dev/null and b/docs/pr-screenshots/2026-10-06/mcp-connector-edited.png differ diff --git a/docs/pr-screenshots/2026-10-06/microsoft-choose-services.png b/docs/pr-screenshots/2026-10-06/microsoft-choose-services.png new file mode 100644 index 000000000..7fe00fa5a Binary files /dev/null and b/docs/pr-screenshots/2026-10-06/microsoft-choose-services.png differ diff --git a/docs/pr-screenshots/2026-10-06/microsoft-own-app.png b/docs/pr-screenshots/2026-10-06/microsoft-own-app.png new file mode 100644 index 000000000..b1803afe0 Binary files /dev/null and b/docs/pr-screenshots/2026-10-06/microsoft-own-app.png differ diff --git a/docs/pr-screenshots/2026-10-06/obsidian-two-vaults.png b/docs/pr-screenshots/2026-10-06/obsidian-two-vaults.png new file mode 100644 index 000000000..ac82ba6b6 Binary files /dev/null and b/docs/pr-screenshots/2026-10-06/obsidian-two-vaults.png differ diff --git a/docs/research/assets/connection-onboarding.png b/docs/research/assets/connection-onboarding.png new file mode 100644 index 000000000..287c84ec1 Binary files /dev/null and b/docs/research/assets/connection-onboarding.png differ diff --git a/docs/research/assets/google-apis-enabled.png b/docs/research/assets/google-apis-enabled.png new file mode 100644 index 000000000..06fc2069a Binary files /dev/null and b/docs/research/assets/google-apis-enabled.png differ diff --git a/docs/research/assets/microsoft-registration-created.png b/docs/research/assets/microsoft-registration-created.png new file mode 100644 index 000000000..e9f235c8a Binary files /dev/null and b/docs/research/assets/microsoft-registration-created.png differ diff --git a/docs/research/assets/microsoft-registration-ready.png b/docs/research/assets/microsoft-registration-ready.png new file mode 100644 index 000000000..1a227644a Binary files /dev/null and b/docs/research/assets/microsoft-registration-ready.png differ diff --git a/docs/research/direct-communication-connections.md b/docs/research/direct-communication-connections.md new file mode 100644 index 000000000..dace31696 --- /dev/null +++ b/docs/research/direct-communication-connections.md @@ -0,0 +1,609 @@ +# Direct communication connections for Off Grid AI Desktop + +Research date: 30 September 2026. This report is for internal product and engineering use. + +## RICE priority for consumer setup + +These are planning assumptions for 100 knowledge workers over one quarter, not measured product usage. RICE = reach × impact × confidence ÷ effort. Reach is users per quarter; impact is 0.5–3; confidence is a fraction; effort is engineer-weeks for an initial direct reader, excluding provider approval time and full synchronization. Replace these values with customer data before making delivery commitments. + +| Order | Connection | Reach | Impact | Confidence | Effort | RICE | Customer setup | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 1 | Local files / Obsidian | 65 | 2 | 90% | 0.5 | 234 | Select a vault folder; no plugin or login required | +| 2 | Notion | 60 | 2 | 85% | 0.5 | 204 | Connect to the official MCP and approve access | +| 3 | Google: Gmail, Calendar, Drive, Contacts | 80 | 3 | 75% | 1 | 180 | One registered application sign-in; own-client alternative | +| 4 | Jira / Confluence | 45 | 2 | 85% | 0.5 | 153 | One official MCP sign-in; tenant approval can apply | +| 5 | Linear | 25 | 1.5 | 90% | 0.5 | 67.5 | Official MCP sign-in through the existing connector path | +| 6 | Microsoft: Outlook, calendars, OneDrive | 60 | 3 | 75% | 3 | 45 | One public-client Graph sign-in; tenant consent can apply | +| 7 | Slack | 55 | 2 | 65% | 2 | 35.8 | Official MCP after application and workspace access requirements are met | +| 8 | Dropbox | 30 | 1.5 | 80% | 1.5 | 24 | Registered app plus native PKCE sign-in | +| 9 | Trello | 20 | 1 | 80% | 1 | 16 | Registered public API key plus user approval | +| 10 | Other email / calendars | 30 | 2 | 60% | 3 | 12 | IMAP / CalDAV; provider-specific account setup | + +The current implementation starts with the existing hosted MCP routes and Google API reader. The worktree now includes the local-vault reader described below. WhatsApp, WeChat, personal Teams history, and a complete social inbox have no verified universal consumer sign-in route under the direct-device rule. They must not appear as working one-click choices. + +## Onboarding implementation, 30 September 2026 + +Changes are isolated in the attached Desktop worktree and its private Pro checkout. An optional **Connect your work** step follows device setup. The same controls are available in Integrations. The generic core owns sign-in state, cancel, retry, and failed-record cleanup. Pro owns Google client settings, direct OAuth, and Gmail / Calendar / Drive / Contacts REST tools. The core-only build does not acquire a Google provider implementation. + +Google presents **Connect with Off Grid AI** and **Use your own client**. A grouped connection requests read scopes once and verifies all four APIs. Drive lists files with pagination and exports supported text documents, with a 2 MB read limit; binary files return metadata. Contacts retain pagination cursors. New quick connections stay disabled until verification succeeds and skip automatic memory imports. Existing connections retain their previous import policy. + +Shared UI selection: `wednesday-solutions/component-library-animations`, commit `ca35d2f9004b212ea034b73109fcd2819a08816b`, consumed through the `@offgrid/operator-ui` alias to avoid collision with the headless `@offgrid/ui` package. Production `Button`, `Card`, `Label`, and `NativeSelect` provide real props, keyboard support, loading state, and reduced motion. The main-branch previews and timer-based demo buttons were rejected. The existing own-client side panel remains the form owner. New controls use Off Grid design tokens and Phosphor icons. + +Credential check (30 September 2026): Google Cloud Console confirms the existing Off Grid AI registration is a public Desktop client. Its ID and enabled application credential match the user-authorized saved configuration. Both are configured in the ignored worktree `.env.local`, with `GOOGLE_OAUTH_CLIENT_TYPE=desktop`; the build includes them only in the main process. Release builds must receive the same Desktop configuration. Customers can use Connect with Off Grid AI without supplying developer credentials, or select their own client. OAuth uses the system browser, PKCE, a local callback, and protected local token storage. All service traffic goes directly to Google. The project is External and In production, but Google still requires verification for the requested scopes and shows a 100-user cap. The owner will complete verification. The configured build passed and the worktree app was restarted; real Google account sign-in remains to be reviewed. + +Validation covers rendered onboarding and provider choices, successful setup, failure cleanup, retry, cancellation, PKCE, partial grants, direct API execution, encrypted storage, and provider schema discovery. No end-to-end app run or screenshot was performed: repository instructions require an explicit user request. Both the Pro and core-only production builds passed. 63 product tests and 7 database integration tests passed. Changed implementation files passed lint with zero errors. Pro type checking passed. The full core type check reports an existing `ActiveChatStreamContract` / `ChatStreamPhase` mismatch for video phases in unchanged files. Worktree dependency copies required the shared sync package's own version of `@noble/hashes` and `xstate`; these fixes touched ignored dependencies only. + +## Current scope: knowledge-worker sources through MCP or APIs + +Direct APIs and MCP are both acceptable. Use one Off Grid connection interface across platforms, but do not require a local MCP server for a provider whose API is already simple to call. The device can call a source-operated MCP endpoint or a source API directly. Wrap either route in the same account, tool, action, and retention model. No third-party hosted connector is needed. + +The scope now covers email, calendar, messaging, task tools, notes, and files. Additional useful categories are meetings and transcripts, contacts, browser bookmarks and selected pages, developer work such as issues and pull requests, and CRM records. These are product categories, not commitments to implement every application. Prioritize the sources used by the selected customer group rather than treating all knowledge workers as one market. + +### Revised priority across the broader scope + +This order supersedes the communication-only rankings below. Effort is a relative judgment based on the inspected Desktop code and documented connection routes. Reach is expected coverage, not measured user demand. Complete sync, platform packaging, and permission work can raise the cost beyond an initial read tool. + +| Priority | Source | Direct route | Relative effort | Why it comes here | +| --- | --- | --- | --- | --- | +| 1 | Gmail and Google Calendar | Existing REST provider | Low for initial tools | Reuse existing code and cover communication plus scheduling | +| 2 | Local files and Obsidian | Selected folders and Markdown files; optional local MCP tools | Low for a Desktop reader | No provider sign-in; one route also covers local documents and downloaded files | +| 3 | Notion | Official source-operated MCP with OAuth and PKCE | Low to medium for live tools | Existing generic HTTP MCP system can supply notes and shared knowledge without a new full REST client | +| 4 | Microsoft mail and calendars | Graph and public-client OAuth | Medium | Major work suite through one account; evaluate OneDrive and SharePoint as later file capabilities | +| 5 | Jira and Confluence | Official Atlassian Rovo MCP | Low to medium for live tools | One source MCP covers issues and shared documentation; tenant approval may add setup steps | +| 6 | Google Drive, including Google document exports | Drive API | Medium | Reuses Google identity work; selected-file access and full-drive access have different scope and verification costs | +| 7 | Slack | Official source MCP | Medium overall | Important work context; publication, access, and retention remain release dependencies | +| 8 | Dropbox | Direct API with PKCE | Medium | Supported native sign-in; add after common file import and change tracking exist | +| 9 | Trello | Direct REST and delegated API-token authorization | Low to medium | Straightforward board/card model; useful but narrower than a work suite | +| 10 | Other email and calendar providers | Shared IMAP and CalDAV adapters | Medium | Broad coverage, with more credential and compatibility friction | +| Later | Meetings, contacts, CRM, browser sources, and developer work | Source-specific API, MCP, or deliberate local import | Not yet estimated | Select a role and test demand before another wide provider expansion | + +Obsidian stores its notes as Markdown files in a local vault folder. Start with user-selected read access; do not require an Obsidian plugin to read those files. A mobile sandbox can require a document picker or scoped file grant, so the same tool contract does not imply identical folder access on every device. A locally synced Dropbox or Drive folder is also useful, but it contains only the files present locally and does not prove complete cloud coverage. [Obsidian data storage](https://obsidian.md/help/data-storage) + +Notion publishes `https://mcp.notion.com/mcp` and documents custom clients using OAuth with PKCE and dynamic client registration. This is a strong early MCP target. Its legacy open-source local server is no longer actively maintained. Live tools are the first deliverable; background import still needs a separate review of coverage, pagination, deletion, and allowed retention. [Notion custom MCP client](https://developers.notion.com/guides/mcp/build-mcp-client), [Notion connection guide](https://developers.notion.com/guides/mcp/get-started-with-mcp) + +Atlassian documents `https://mcp.atlassian.com/v2/mcp` with interactive OAuth 2.1 and access to Jira, Confluence, and other Atlassian work. Use that source route first. Its separate Jira REST 3LO flow requires a client secret; the REST guide also warns against applications that collect API tokens or instruct customers to create individual 3LO apps. Do not use the current Google own-client setup as a general template for Jira. Tenant callback allowlists can still block a desktop sign-in. Do not assume this MCP includes Trello. [Atlassian MCP setup](https://developer.atlassian.com/cloud/rovo-mcp/guides/getting-started/), [MCP OAuth](https://developer.atlassian.com/cloud/rovo-mcp/guides/configuring-oauth-2-1/), [Jira REST authorization](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/) + +Google recommends the non-sensitive `drive.file` scope for files the user opens or shares with the application. It does not grant a complete Drive account view. Full-drive read access uses a restricted scope. Pick the product behavior before choosing the scope; a selected-file reader should not be called whole-drive sync. Native Google documents also need an export/read route rather than a normal binary download assumption. [Drive scopes](https://developers.google.com/workspace/drive/api/guides/api-specific-auth) + +Dropbox documents PKCE for public clients such as Desktop and mobile apps. It fits the direct-on-device rule without embedding a shared confidential secret. Resolve the file scope, app approval, change cursors, and content parser behavior as part of implementation. [Dropbox authorization](https://docs.dropboxapi.com/dropbox-api/docs/get-started/authorization) + +Trello documents a public API key and a private user token obtained through delegated authorization. The key alone does not expose user data. Off Grid should register its application and let the user authorize it; users should not create their own developer application. Test safe callback delivery on each platform and keep tokens out of URL logs. Use periodic reconciliation rather than requiring a public webhook receiver on the user's device. [Trello API introduction](https://developer.atlassian.com/cloud/trello/guides/rest-api/api-introduction/) + +### Can social accounts provide one complete view? + +Some services expose a user's feed, but there is no verified universal API for everything a user can see in every social app. Separate home feed, own posts, mentions, bookmarks, notifications, and direct messages in the capability model. Access to one does not establish access to the others. An MCP wrapper cannot supply data that its underlying provider denies. + +| Social source | Verified route or limit | Product decision | +| --- | --- | --- | +| Mastodon | Timeline APIs include authenticated user timelines | Good first social feasibility target; test the selected instance's rules | +| Bluesky | Official protocol defines the requesting account's home-timeline query with pagination | Good API candidate; authentication, chat, notifications, and retention need separate checks | +| X | Official API exposes authenticated reverse-chronological home timeline and mentions | Feasible for these features; check access cost and developer terms before release; this is not the algorithmic feed | +| LinkedIn | Member feed management requires approved Community Management access | Approval-dependent; do not promise normal sign-in grants full home feed or inbox | +| Instagram | Meta's published API collection describes professional-account access and excludes consumer accounts for Facebook Login | Do not promise a general personal-feed aggregator; distinguish professional tools from consumer access | +| Reddit | Data API use is approval-dependent, including commercial eligibility | Resolve approved product use before prioritizing a consumer aggregator | + +Mastodon's API describes user timeline access. Bluesky's official lexicon defines `app.bsky.feed.getTimeline`. X documents a home timeline from followed accounts that excludes algorithmic ranking and requires user authentication. These are concrete feed capabilities, not proof of a complete cross-service inbox. [Mastodon timelines](https://docs.joinmastodon.org/methods/timelines/), [Bluesky timeline definition](https://raw.githubusercontent.com/bluesky-social/atproto/main/lexicons/app/bsky/feed/getTimeline.json), [X timelines](https://docs.x.com/x-api/posts/timelines/introduction) + +LinkedIn's member feed program requires approval. Meta's published Instagram collection describes professional-account restrictions; direct Meta developer pages remained partly inaccessible in this research. Reddit says approved use and commercial eligibility depend on app review. Consumer Facebook feed and inbox access was not verified here. Keep these as explicit limits or questions rather than unsupported complete-account promises. [LinkedIn access](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/community-management-api-migration-guide?view=li-lms-2026-03), [Meta Instagram API collection](https://www.postman.com/meta/instagram/documentation/6yqw8pt/instagram-api?entity=request-23987686-15c537cd-a773-4b40-a87c-27829e49a439), [Reddit access rules](https://support.reddithelp.com/hc/en-us/articles/14945211791892-Developer-Platform-Accessing-Reddit-Data) + +Recommendation: deliver work context first. If social demand is strong, start a separate Mastodon/Bluesky experiment, then evaluate X. Present a combined view of the capabilities actually granted. Do not market it as every social feed and message in one place. No social accounts were connected for this research. + +## Updated decision: one MCP interface across platforms + +The product requirement is now a uniform MCP-based connection system across supported platforms and form factors. This changes the first-release order below. Mac EventKit remains an optional local adapter; it is not the common connection foundation. The checked Desktop build configuration targets macOS, Windows, and Linux. Other form factors were not inspected in this repository. The following mobile design is a proposal, not a claim of existing support. + +**Recommended design:** bundle Off Grid-owned MCP adapters with the app. Each adapter calls the provider directly. Expose the same tool definitions, account identity, permission status, pagination, and result shapes to every Off Grid client. Reuse the existing Google REST implementation behind its MCP adapter. A service does not need to offer its own MCP endpoint for this to work. + +The data path is `Off Grid client → on-device MCP adapter → source provider`. There is no hosted Off Grid connector. Source-operated MCP can also be used where supported, but it must pass the same account, action, and retention checks. A shared MCP interface does not make provider authorization or data-storage rules uniform. + +Share the tool contract and provider logic where the runtime permits it. On Desktop, a bundled adapter can use the Electron runtime and an MCP stdio transport. Do not require a separate Node installation. On a mobile platform that cannot launch this process, embed the adapter and use a documented in-process MCP transport through that platform's SDK. Do not promise that the Desktop Node process can run unchanged on mobile. MCP permits custom transports that preserve its protocol semantics. [MCP transport specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports) + +Use platform-specific browser callbacks and protected credential storage behind common interfaces. Authenticate each device directly unless a provider-approved credential transfer is explicitly designed. Share provider configuration and data schemas; do not assume a Desktop loopback OAuth callback works in every form factor. Browser-only deployment needs a separate check of provider CORS and public-client rules. It cannot fall back to a hosted connector under this requirement. + +Define a small common tool set: search messages, read a message or thread, list calendars, and list events. Add sending later through the existing action controls. Preserve provider-specific IDs and capabilities rather than pretending every service has the same features. Keep a separate resumable sync interface in the adapter: MCP tool access alone does not maintain a complete mailbox. A provider capability record must say whether local retention is allowed, unresolved, or prohibited. + +### Priority: least effort and highest useful reach + +These are relative engineering estimates, not measured development times or user-market statistics. The order weighs existing code, supported provider routes, setup friction, and the number of services covered. Registration and approval effort is shown separately because a small adapter can still have a long release dependency. + +| Priority | Connection | Adapter effort | Release dependency | Expected impact and reason | +| --- | --- | --- | --- | --- | +| 0 | Shared bundled MCP adapter host and contract | Low to medium on Desktop; unmeasured on other form factors | Verify runtime and OAuth callback support on each target | Required once; gives every later provider the same client interface | +| 1 | Gmail and Google Calendar | Low for initial MCP tools; medium for complete sync | Google verification for default consumer sign-in | Highest reuse: existing direct REST, OAuth, and tool hooks already work as the base | +| 2 | Outlook.com and Microsoft 365 mail and calendars | Medium | App registration, publisher verification, and tenant consent | One Graph adapter supplies two major services through normal sign-in | +| 3 | Shared IMAP and CalDAV adapters | Medium | Provider credential and compatibility checks | Broad provider coverage: iCloud, Yahoo, Zoho, custom mail, and compatible calendar servers; setup is less simple than OAuth | +| 4 | Slack live tools through official source MCP | Low to medium client integration; medium overall | Published or internal app eligibility; confirm public-client flow and retention | Strong work-chat value with an existing source MCP endpoint; do not connect restricted search output to automatic memory import | +| 5 | Fastmail JMAP adapter | Low to medium after the common host | Provider OAuth registration; separate calendar credentials | Cleaner mail sync for one provider; IMAP already supplies a fallback | +| Hold | Telegram | High native packaging and lifecycle work | Resolve AI-use and participant-consent rules | A usable client library exists, but the unresolved policy blocks general AI import | +| Hold | Personal WhatsApp and WeChat | High and uncertain | No verified supported general inbox route in this research | Do not make unsupported client maintenance the foundation of the uniform system | + +Slack's source server requires a registered, published or internal application; a generic MCP URL alone is not sufficient. Gmail's source MCP is still a restricted Developer Preview. These facts favor a bundled Google adapter and a conditional official Slack adapter. [Slack MCP eligibility](https://docs.slack.dev/ai/slack-mcp-server/), [Gmail MCP preview](https://developers.google.cn/workspace/gmail/api/guides/configure-mcp-server?hl=en) + +**First concrete deliverable:** wrap the current Google provider in the common MCP contract and prove the same search and event calls on macOS, Windows, and Linux. Then add Graph without changing the client-facing contract. Validate another form factor's embedded runtime before claiming full portability. EventKit can later expose the same calendar tools on Mac, but must remain an optional capability rather than a requirement for all clients. + +The sections below retain the detailed provider evidence. Their earlier Mac-first order addressed minimum setup on this Desktop app; the updated MCP-first order above controls the current recommendation. + +Off Grid can connect directly to email, calendar, and some messaging services. The best general design is a provider module in the Electron main process. That module calls the source service and supplies tools to the existing connection system. The proposed design keeps credentials, imported data, and AI processing on the device. + +The first work should reuse the existing Mac calendar reader, add Microsoft mail and calendar access through Microsoft Graph, and reduce Google setup steps with a registered desktop OAuth application. Slack is feasible, but its publication and data storage rules affect the design. Telegram is technically feasible but has a material AI use restriction. Personal WhatsApp and WeChat do not have a verified general inbox API that meets the requirements in this research. + +Recommendations in this report are engineering judgments. They are not results from live account tests. Provider facts have links to their sources. Proposed setup steps and implementation choices are identified as proposals. + +## Scope and direct connection rule + +The requirement is interpreted as follows: Off Grid may contact the user's source service, such as Google or Microsoft, from the device. It must not send data through an Off Grid server, a connector service, or a cloud AI service. Without contact with the source service, the app can read only data already available on the device. + +Three paths meet this interpretation: + +1. Off Grid calls the provider's API directly. +2. Off Grid calls a local operating system API or a local provider program. +3. Off Grid calls an MCP endpoint operated by the source provider, if its terms permit the use. + +A third-party hosted MCP server does not meet this rule. A local MCP program may meet it, but only if its actual network requests go directly to the source. A local process can still send data elsewhere. MCP does not establish the data path or remove provider permission requirements. The protocol supports both local standard input/output and HTTP transports. [MCP transport specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports) + +For the default consumer product, prefer a built-in provider module over a program that the user must install with Node, Python, Docker, or a terminal. Keep custom local MCP connections as an advanced option. + +This report covers reading data, searching it, keeping a local copy where permitted, and later communication actions. It distinguishes these tasks from letting a user send commands to Off Grid through a bot. A bot chat does not usually grant access to the user's full inbox. + +## Existing code and what to reuse + +The managed worktree uses core commit `9a5181f3a3c5a2eb9b5ce637cc0ee655a8d703a8` and Pro commit `08531ba6521de1b6b3e257ece01269cde88bc585`. It starts from the repository's remote default branch. The Google client and REST files were also compared with the original checkout. Those files had no differences. No application code was changed. + +| Existing file | Observed behavior | Effect on the proposal | +| --- | --- | --- | +| [Google client](../../pro/main/google-client.ts) | Each user supplies a Google Web application client ID and secret. One client serves Gmail and Calendar. | Reuse the direct data path. Do not make this setup the default for every new service. | +| [Google REST module](../../pro/main/google-rest.ts) | Calls Gmail v1 and Calendar v3 directly. Refreshes and retries once after HTTP 401. | This is the main pattern for new provider modules. | +| [Provider registration](../../pro/main/index.ts) | Registers `HOOKS.mcpConnectorToolSource` for Google tools. | Provider-owned tools can use REST without running an MCP server. | +| [Core connection system](../../src/main/mcp.ts) | Stores connection records. Supports HTTP and local processes. A `ConnectorToolSource` owns tool definitions, verification, and execution. | Keep this common interface. Provider implementations belong in Pro. | +| [OAuth provider](../../src/main/mcp-oauth.ts) | Uses the system browser, state, PKCE, and a local callback. Static client configuration currently requires a secret. | Generalize static public-client configuration for Microsoft, Slack, and other native clients. | +| [OAuth callback](../../src/shared/mcp-oauth-callback.ts) | Uses `http://127.0.0.1:33418/callback`. | Keep existing Google Web clients working. Add provider-specific native redirect configuration. | +| [Secret store](../../src/main/secrets.ts) | Stores encrypted blobs in SQLite. Electron `safeStorage` uses an OS-held encryption key. | Reuse it. Credentials are not each stored as a separate Keychain entry. | +| [Memory import](../../pro/main/ingest.ts) | Imports Google data and has a Slack adapter for named MCP tools. | Add explicit provider import methods. Do not infer all sync behavior from arbitrary tool names. | +| [Service timer](../../pro/main/services.ts) | Starts connection refresh after about one minute and repeats every 30 minutes. | Add per-provider cursors and schedules. Retain a visible last successful sync time. | +| [Native actions helper](../../scripts/actions-helper/main.swift) | Already reads calendar events with EventKit, creates events, and sends mail with AppleScript. | Reuse the existing native boundary for Mac calendars. Mail reading still needs separate work. | +| [Native action mapping](../../src/main/actions/semantic-rail.ts) | Maps calendar lookup to `calendar.listEvents`. | A native calendar connection does not require a second standalone helper. | +| [Action control](../../src/main/actions/chat-connector-action.ts) | Routes chat connection mutations through the durable action engine. | Keep sending and calendar changes on this route. | + +The present Google tools are narrower than a full email or calendar client. Gmail search returns up to 20 messages, with headers, snippets, and links. It does not return full message bodies or attachments. Calendar reads the primary calendar, with a default 14-day range and a result limit of 50. These functions do not follow result pagination or maintain change cursors. + +The Google memory import turns these results into observations. A richer connection needs stable source IDs, update handling, deletion handling, and a complete account identity. Repeatedly importing a short recent list is not sufficient to maintain a complete local mailbox. + +There is also an existing Slack import. It looks for `slack_get_channel_history`, resolves users, selects channels where the bot is a member, reads up to 12 channels, and stops after 50 messages. It is a bot-oriented path. It is not a complete personal Slack connection, and the official Slack MCP tool set must be mapped explicitly. + +Two current behaviors need attention before broader messaging import. The importer logs the start of generic tool results and a sample Slack history result. These logs can contain message data. The service timer also runs connection sync before it checks whether screen capture is paused. Capture pause therefore does not pause connection import. Add a clear per-account sync control, and decide how a global pause should affect it. Remove content samples from normal logs before enabling sources that prohibit retention. + +## Service comparison + +The setup ratings below are estimates for the proposed product. Low means normal sign-in or one OS permission. Medium means an app password, local program, or workspace approval. High means developer setup or an unsupported protocol. The detailed sections contain the source evidence and conditions. + +| Service | Preferred direct path | Consumer setup | Main limit | Recommendation | +| --- | --- | --- | --- | --- | +| Calendars already on the Mac | Existing EventKit helper | Low if accounts are already configured | Mac only; OS controls refresh | Start here | +| Gmail | Direct Gmail API and desktop OAuth | Low after Off Grid verification; high with current own-client setup | Restricted scopes and verification | Keep REST; improve sign-in | +| Google Calendar | Direct Calendar API; EventKit as a Mac option | Low after application setup | Calendar selection and complete sync need work | Expand existing module | +| Outlook.com and Microsoft 365 mail | Microsoft Graph and public-client OAuth | Low for personal accounts; may need work administrator approval | Organization consent policy | First new network provider | +| Microsoft calendars | Same Graph account; EventKit when configured on Mac | Low to medium | Shared calendar and tenant limits | Add with Microsoft mail | +| Fastmail | JMAP OAuth for mail; CalDAV for calendars | Low after provider registration; medium with a token | Calendar credentials need separate verification | Good second email provider | +| iCloud Mail | IMAP and SMTP with an app password | Medium | New Apple account authorization is not yet verified for Off Grid | Use standards fallback | +| Yahoo and AOL | IMAP and SMTP with an app password | Medium | Password availability and provider OAuth approval | Add provider presets | +| Zoho and other IMAP mail | IMAP; provider REST when useful | Medium | Plan, region, and administrator settings | Support through shared mail adapter | +| Proton Mail | Local Proton Mail Bridge and IMAP | Medium | Paid plan and Bridge installation | Optional local integration | +| Other calendars | CalDAV or a selected calendar feed | Medium | Feed may be read-only or delayed | Shared calendar adapter | +| Slack | Source-operated MCP or direct API with user OAuth | Low after publication and workspace approval | MCP eligibility, rate limits, and retention rules | Feasibility work before release | +| Telegram | TDLib as a user client | Low to medium technically | AI use and participant consent terms | Hold general AI import | +| Personal WhatsApp | No verified official general inbox path; local linked-device libraries exist | QR setup can be simple | Unsupported clients, account blocks, incomplete history | Optional research only | +| WhatsApp Business | Direct Meta API | High for consumers | Business setup and inbound delivery design | Separate business product | +| WeChat personal chats | No verified supported inbox route found | High or uncertain | Web login eligibility and unavailable official evidence | Defer automatic sync | +| Teams work chats | Microsoft Graph | Medium | Work accounts and broader permissions | Later Microsoft extension | +| Matrix | Direct client-server API | Medium | Encryption keys and homeserver policy | Good open-protocol extension | +| Signal | Local `signal-cli` | Medium | Community client and frequent maintenance | Experimental extension | +| Discord | Official bot API | Medium | Bot access is not personal inbox access | Limit to bot-visible spaces | + +## Google mail and calendars + +### Preserve the direct REST path + +The existing Google data path fits the requirement. Keep it as the production basis. The Gmail MCP guide still identifies the server as a Developer Preview and requires program membership. MCP also adds service setup that is not needed for the current REST functions. [Gmail MCP configuration](https://developers.google.cn/workspace/gmail/api/guides/configure-mcp-server?hl=en) + +### Reduce setup through a desktop application + +Proposed default: Off Grid registers and verifies its own Google Desktop application. The user selects Connect, signs in through the system browser, and grants the selected service access. Authorization code exchange and token refresh occur on the device. + +Google supports PKCE and a local loopback redirect for desktop clients. A distributed native client cannot keep an application secret confidential. An application registration identifies Off Grid to Google; it does not require an Off Grid data server. Google also says installed applications do not support incremental authorization. Design mail and calendar grants with that limit in mind. [Google desktop OAuth](https://developers.google.com/identity/protocols/oauth2/native-app) + +Retain the user's own client as an advanced option. Do not silently convert an existing Web application registration to Desktop type. Its callback rules and saved tokens belong to the existing client. + +Current own-client setup has a recurring friction point: an External consent screen in Testing issues refresh tokens that normally expire after seven days when mail or calendar scopes are requested. This can cause frequent reconnects. It is a provider behavior, not necessarily a token storage defect. [Google token expiry rules](https://developers.google.com/identity/protocols/oauth2#expiration) + +### Verification and access level + +`gmail.readonly` is a restricted scope. `gmail.metadata` is also restricted; changing to metadata does not remove restricted-scope verification. `gmail.send` is a separate sensitive scope. Request it only when sending is added. [Gmail scopes](https://developers.google.com/workspace/gmail/api/auth/scopes) The metadata scope also does not support Gmail's `q` search parameter. [Gmail message search parameters](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/list) + +Google's restricted-scope guide requires an assessment when the application can access restricted data from or through a third-party server. Local processing is relevant to this assessment, but it is not a promise of approval or exemption. Document the complete data path, including optional remote models, sync, crash reports, and backups, before making a verification claim. Confirm the assessment decision with Google's verification team. [Restricted-scope verification](https://developers.google.cn/identity/protocols/oauth2/production-readiness/restricted-scope-verification?hl=en) + +Google lists email productivity features, including generative AI summaries, as an approved Gmail use case. Its data policy also requires clear disclosure, limited access, secure handling, and deletion support. Do not use imported mail to train a general model. [Google Workspace data policy](https://developers.google.com/workspace/workspace-api-user-data-developer-policy) + +For Calendar, request only the event and calendar-list scopes needed by the released features. The current module asks for free/busy access as well. Review whether the current REST tool uses it before carrying that request into a new application registration. [Calendar scope reference](https://developers.google.com/workspace/calendar/api/auth) + +### Complete the sync model + +Proposed next steps are full bodies on demand, selected folders or labels, pagination, and a stable source record before summary generation. Keep Gmail's `historyId` for partial sync. If the history cursor is no longer valid, perform a bounded full sync. [Gmail synchronization](https://developers.google.com/workspace/gmail/api/guides/sync) + +For Calendar, add calendar selection and per-calendar sync tokens. Process deleted events and invalid token recovery. The current moving date window must not simply be combined with a sync token; use request parameters that the API permits and a stable sync query. [Calendar synchronization](https://developers.google.com/workspace/calendar/api/guides/sync) + +## Microsoft email and calendars + +Outlook is an application and product name. Microsoft-hosted Outlook.com, Hotmail, Live, and Microsoft 365 mail can use one Graph provider. A Gmail account shown inside Outlook still belongs to Google. An on-premises Exchange mailbox is a separate case. + +Microsoft supports authorization code with PKCE for desktop public clients and supports both personal and work or school accounts. Register an Off Grid application for the required account types. A public client does not need a shared application secret. [Microsoft authentication flows](https://learn.microsoft.com/en-us/entra/identity-platform/authentication-flows-app-scenarios) + +Proposed implementation: use `@azure/msal-node` in the main process for sign-in and token management. Microsoft supplies an Electron system-browser example. Store the MSAL token cache with the existing protected storage. Use silent token acquisition after the initial sign-in. Do not copy the example's plaintext file cache into the product. [Microsoft Electron example](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/samples/msal-node-samples/ElectronSystemBrowserTestApp/README.md), [MSAL token acquisition](https://learn.microsoft.com/en-us/entra/msal/javascript/node/acquire-token-requests) + +Use delegated permissions. For the present Gmail-equivalent experience, use `Mail.Read` because `Mail.ReadBasic` excludes the body and preview. `Calendars.ReadBasic` can cover basic event details; use `Calendars.Read` if the released feature needs event bodies. Add `Mail.Send` or `Calendars.ReadWrite` only with the relevant action. `Mail.ReadWrite` does not itself grant sending. Shared resources have separate permissions and account limits. [Graph permission reference](https://learn.microsoft.com/en-us/graph/permissions-reference) + +Proposed initial endpoints are `/v1.0/me/messages`, `/v1.0/me/mailFolders`, `/v1.0/me/calendars`, and a bounded calendar view. Use API pagination and per-resource change cursors. Calendar delta tracks a defined calendar view; retain its date range as part of the cursor state. [Calendar delta](https://learn.microsoft.com/en-us/graph/api/event-delta?view=graph-rest-1.0) + +Microsoft publisher verification can reduce consent friction. It cannot override an organization's policy. A tenant can require administrator approval even for delegated permissions that do not normally require it. Show that state clearly and retain the completed local setup. Do not ask the user to repeat sign-in without a change in policy. [Publisher verification](https://learn.microsoft.com/en-us/entra/identity-platform/publisher-verification-overview) + +Do not build a new Exchange Online connection around EWS. Microsoft's current guidance says phased disablement starts on 1 October 2026 and permanent retirement is on 1 April 2027. This does not mean on-premises Exchange has the same retirement date. [Microsoft EWS retirement guidance](https://learn.microsoft.com/en-us/skypeforbusiness/hybrid/prepare-for-ews-retirement) + +Proposed user steps: select Microsoft, sign in, choose Mail and/or Calendar, then choose the import scope. Users should not need to create an Azure application. Keep an own-client option for organizations that require it. Never borrow Microsoft Office or another application's client ID to avoid registration. + +## Mac calendars and other calendar services + +### Reuse EventKit first + +The native helper already calls `requestFullAccessToEvents` on macOS 14 and later, then reads events in a specified date range. Older macOS uses the earlier request method. The app build configuration already contains calendar permission descriptions. + +Apple's Calendar supports iCloud, Google, CalDAV, and Exchange accounts. Thus, a local EventKit connection can cover several providers when the account is already configured on the Mac. It reads the OS calendar store. Off Grid does not need the provider's password or OAuth token. [Mac calendar accounts](https://support.apple.com/guide/calendar/add-calendars-and-calendar-accounts-icl4308d6701/mac) + +Reading existing events requires full calendar access under the newer permission model. This permission also allows writing; the OS does not supply an equivalent read-only event grant. Keep Off Grid's default tools read-only even though the OS permission is broader. [Apple calendar access levels](https://developer.apple.com/documentation/technotes/tn3152-migrating-to-the-latest-calendar-access-levels) + +Proposed work: extend the existing helper to enumerate calendar IDs and sources, accept a calendar selection, and return source identifiers, time zones, locations, and attendees where available. Use the current helper invocation boundary. Verify permission attribution in the signed app when implementation begins. + +The existing helper is a one-shot process. It cannot retain `EKEventStoreChanged` subscriptions after it exits. Begin with a small periodic local read. Add a persistent native component only if update speed requires it. EventKit supplies store-change notifications for a running client. [EventKit access guide](https://developer.apple.com/documentation/eventkit/accessing-calendar-using-eventkit-and-eventkitui?changes=_7) + +Limit: Off Grid cannot promise a provider refresh time from a local store read. macOS owns account sync. If the user has not added an account, adding it through Calendar or Internet Accounts is an extra step. Direct Google or Graph access remains useful for those users. + +### CalDAV and calendar feeds + +Proposed second path: one CalDAV adapter for providers such as Fastmail and compatible self-hosted services. Use discovery, selected calendars, event UIDs, recurrence IDs, and ETags. Detect supported change-sync features rather than assuming every server has them. `tsdav` is a candidate TypeScript client; it needs a provider compatibility check before adoption. [tsdav documentation](https://tsdav.vercel.app/docs/) + +An ICS file import is a simple offline fallback. A subscribed calendar URL is a read-only network source and can update slowly. Treat a private feed URL as a credential. Do not present it as a full calendar account connection. Proton offers calendar sharing by link; that does not establish a CalDAV account API. [Proton calendar sharing](https://proton.me/support/share-calendar-via-link) + +Do not import the same calendar twice through EventKit and a direct API. Let the user select one source route. Matching an event by title and time alone is not a safe general deduplication rule. + +## Other email providers + +### Shared IMAP and SMTP support + +Build a common direct mail adapter. IMAP handles reading and folder state. SMTP handles sending. Add provider presets and an advanced server form. Use TLS and normal certificate validation. A provider domain or MX record alone is not enough to identify all server and authentication settings safely. + +`ImapFlow` is a candidate for direct IMAP access. It supports message retrieval, searching, mailbox management, and IMAP extensions. Use the library without deploying the separate EmailEngine service. [ImapFlow](https://imapflow.com/) + +For proposed sync state, keep mailbox identity, `UIDVALIDITY`, UIDs, and the server's supported modification state. Use read operations that do not mark mail as read. Use IDLE while the app runs where supported, with reconnect and periodic reconciliation. Do not keep every account connection active without a resource budget. + +`Nodemailer` is a candidate for SMTP. Keep protocol debug logging disabled for customer data. For attachments, use validated local inputs and disable arbitrary file or URL access. Sending must use the app's action controls. [Nodemailer SMTP](https://nodemailer.com/smtp) + +### Fastmail + +Fastmail documents JMAP mail access, OAuth with PKCE, loopback redirects, API tokens, and CalDAV calendar access. Register Off Grid for the consumer OAuth path. An API token is a useful advanced fallback. The OAuth documentation does not prove that the resulting JMAP token can also authenticate CalDAV; verify that separately. [Fastmail developer documentation](https://www.fastmail.com/dev/) + +Recommendation: use JMAP for Fastmail mail instead of IMAP if this provider is built as a dedicated module. Its structured API fits a local account and change-state model. Do not assume Fastmail calendars have the same production JMAP interface as its mail. Use CalDAV for the documented calendar route. + +### iCloud Mail + +An app-specific password is the verified fallback for IMAP and SMTP. It requires two-factor authentication on the Apple Account. The user creates a password for Off Grid and can revoke it. Do not request the main Apple Account password. [Apple app-specific passwords](https://support.apple.com/en-us/102654) + +Apple now also documents account authorization for supported third-party Mail, Calendar, and Contacts apps. It is therefore inaccurate to say all iCloud access always requires an app password. However, that support page does not establish a public registration process that Off Grid can use today. Treat direct Apple account authorization as a provider inquiry, not a ready implementation. Generic Sign in with Apple is not evidence of mail access. [Apple third-party account authorization](https://support.apple.com/en-us/121539) + +For iCloud calendars on Mac, prefer EventKit. It avoids a second credential setup when the account is already configured. + +### Yahoo and AOL + +Use the shared mail adapter with an app-password setup guide. Yahoo documents that third-party apps with older sign-in methods need an app password. Availability and account restrictions must be checked in the product's feasibility test. [Yahoo secure access](https://uk.help.yahoo.com/kb/SLN27791.html) + +Yahoo also documents an OAuth registration with a consumer key and secret. Do not assume this server-oriented flow can be shipped as a secret-free native flow, or that mail access is automatically approved for a new application. Resolve these questions before promising one-click Yahoo sign-in. [Yahoo OAuth flow](https://developer.yahoo.com/oauth2/guide/flows_authcode/) + +### Zoho and custom domains + +Zoho documents both REST OAuth and IMAP access. Its IMAP help describes account settings and plan limits. Use the shared adapter first, with the correct regional server and a clear explanation if IMAP is disabled. Add REST only if it materially reduces setup or supplies a required capability. [Zoho IMAP](https://www.zoho.com/mail/help/imap-access.html), [Zoho OAuth](https://www.zoho.com/mail/help/api/using-oauth-2.html) + +### Proton Mail + +Proton Mail Bridge provides local IMAP and SMTP to desktop clients. It requires a paid Proton Mail plan. Off Grid can connect to Bridge on the same device; Bridge handles the Proton connection and mail decryption. The user must install and sign in to Bridge and enter its local connection credentials. [Proton Bridge setup](https://proton.me/support/imap-smtp-and-pop3-setup) + +Keep Bridge's local credentials protected. Do not expose the local mail port on the network. Do not assume Mail Bridge also supplies a Calendar API. Use a separate calendar path if needed. + +### Apple Mail as a local source + +The native helper already sends mail through AppleScript, but it does not read mail. A selected-mail import or bounded Apple Mail reader is a reasonable feasibility experiment for users who already have accounts configured. + +This is a proposal, not verified inbox support. Check the Mail scripting dictionary, Automation permission, retrieval performance, attachments, and whether requested bodies are already downloaded. Prefer supported scripting over reading the Mail app's private database. Do not copy another app's account tokens. Do not require Full Disk Access merely to avoid a provider sign-in flow. + +## Slack + +### Direct desktop sign-in is now supported + +Slack's current PKCE documentation supports desktop public clients without a client secret. Desktop redirects may request user scopes, not bot scopes. Custom schemes require PKCE. The documented public-client flow also changes token rotation behavior, and PKCE refresh tokens expire after 30 days. The product must handle that expiry and concurrent refresh safely. [Slack PKCE](https://docs.slack.dev/authentication/using-pkce/) + +This changes the recommendation from older Slack integrations. An Off Grid-owned public application can remove developer credential setup for the user. It still requires registration, correct scopes, and workspace permission. The dedicated user token exchange is documented for MCP clients. [Slack user token exchange](https://docs.slack.dev/reference/methods/oauth.v2.user.access/) + +### Official MCP is a direct source option + +Slack supplies `https://mcp.slack.com/mcp` over Streamable HTTP. It does not support dynamic client registration. Only published directory applications and internal applications may use it; unlisted distributed applications are prohibited. Its server offers search, message reading, and actions. This is source-operated MCP, so it can fit the direct connection rule. [Slack MCP overview](https://docs.slack.dev/ai/slack-mcp-server/) + +Proposal: test the official MCP path first for live chat tools, using an internal test application. For consumer distribution, resolve Marketplace eligibility and publication. Configure a fixed public client and the correct user OAuth endpoints. Do not use the current generic dynamic-registration assumption unchanged. + +### Live search and saved memory need different designs + +Slack's Real-time Search API supports user queries from outside Slack through a user token. It restricts access to published or internal applications. It prohibits storing or copying retrieved data, training use, and scraping unrelated to user queries. It also requires an in-Slack experience under its general usage guidelines. [Slack Real-time Search](https://docs.slack.dev/apis/web-api/real-time-search-api/) + +Recommendation: keep this route for live, user-requested context. Do not send these results to `recordObservation`, embeddings, persistent model history, or paired-device sync. A local disk copy is still a copy. Confirm how these rules apply to official MCP search results and derived summaries before retention is enabled. + +For a separate Conversations API import, confirm the applicable Slack agreement and approved product use. Do not infer a right to store an entire workspace from possession of an OAuth token. The current Slack memory import must not automatically run for a new official MCP connection. + +### History limits and bot alternatives + +`conversations.history` has a limit of one request per minute and 15 objects for affected new commercially distributed non-Marketplace applications or installations. Marketplace and internal applications have different limits. User tokens and bot tokens also have different conversation visibility. [Slack history method](https://docs.slack.dev/reference/methods/conversations.history/) + +At 15 messages per minute, 10,000 messages require about 667 minutes, or 11.1 hours, before retries and extra thread requests. This is a calculated lower-bound example, not a benchmark. It makes full history import a poor default for that application class. Check thread limits independently. MCP must not be treated as a way to evade API limits. + +Socket Mode receives events through an outbound WebSocket without a public callback. However, it needs an app-level token, and Socket Mode applications cannot currently be listed in the public Marketplace. It is useful for a user's own internal app, not a shared app-level secret embedded in every consumer installation. [Slack Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode/) + +Recommended Slack order: direct live search and selected-thread reads, then approved communication actions, then a separately approved retained-memory design if Slack permits it. + +## Telegram + +TDLib is Telegram's official client library. It handles networking, encryption, and local storage. It is a better base for a user-account connection than a bot API wrapper. [Telegram TDLib](https://core.telegram.org/tdlib) + +QR authentication is available through TDLib. It can reduce setup steps when the user already has Telegram on a phone. The login state machine must still handle password and other account challenges. Off Grid needs its own application API identity. A user should not need to create a developer application for the default product. [TDLib QR authentication](https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1request_qr_code_authentication.html), [Telegram application registration](https://core.telegram.org/api/obtaining_api_id?source=post_page---------------------------) + +The material limit is policy. Telegram's content terms prohibit broad scraping, indexing, aggregation, and use for AI development or deployment. The stated exception requires explicit, informed, affirmative, continued consent from all relevant users for the specific content or chat context. One account holder's connection consent does not establish that condition. [Telegram content terms](https://telegram.org/tos/content-licensing) + +Recommendation: hold general AI inbox ingestion until Telegram clarifies the intended use and consent process. A non-AI client capability and a scoped conversation where the required consent exists are different product cases. Local inference alone does not remove the restriction. + +If this is resolved, run TDLib as a managed local component and keep sessions and keys protected. It needs a persistent lifecycle, unlike the current on-demand MCP process. Do not assume cloud chat history also grants access to secret-chat history on another device. A Telegram bot can provide a command interface to Off Grid, but cannot substitute for the user's inbox. + +## WhatsApp + +### Personal inbox and agent chats are different + +WhatsApp now documents third-party agents. They can read only what the user shares with them, cannot join or access other chats, and are available only in limited countries. This is useful for an Off Grid command interface, but not for personal inbox access. [WhatsApp third-party agent help](https://faq.whatsapp.com/1050934623978152) + +The agent terms were updated on 25 August 2026. They state that agent conversations are not end-to-end encrypted personal conversations and use Meta's agent platform. These terms do not establish a device-only transport or a general personal inbox API. [WhatsApp agent terms](https://www.whatsapp.com/legal/third-party-agents-terms) + +### Local linked-device libraries are technically possible + +Baileys is a TypeScript library that uses a direct WebSocket connection. It supports QR or pairing-code authentication as another linked client and does not need a browser. It is not an official WhatsApp integration. [Baileys project documentation](https://raw.githubusercontent.com/WhiskeySockets/Baileys/master/README.md) + +`whatsapp-web.js` uses Puppeteer and a managed browser to access WhatsApp Web internals. Its project warns that account blocking remains possible. Packaging a second browser also adds a resource and maintenance burden to this Electron app. [whatsapp-web.js project documentation](https://raw.githubusercontent.com/pedroslopez/whatsapp-web.js/main/README.md) + +Engineering judgment: if Off Grid chooses to fund an experimental personal WhatsApp connection, evaluate Baileys first. It offers the simpler local architecture. The experiment must prove reconnect behavior, session key storage, history completeness, message edits, deletion, disappearing messages, and account stability. It must not claim provider support or complete history access. + +A local MCP wrapper does not change those limits. It only exposes the same unofficial client through tools. Do not use a managed cloud bridge to reduce setup steps under this task's direct connection requirement. + +Recommendation for the normal product: offer deliberate local import of selected user-provided messages or exports. Keep continuous personal WhatsApp sync outside the first release. This is a product reliability decision; the research did not establish that a direct local implementation is impossible. + +### Business API and new business MCP + +WhatsApp Business API access serves a business account, not arbitrary access to a consumer account. A direct implementation must resolve business registration, phone number setup, permissions, and inbound message delivery. Receiving webhooks on a consumer Mac behind a router is a substantial obstacle under a strict no-relay rule. An external tunnel would add another service to the data path. + +Meta's developer overview required login or returned rate-limit errors during this research. Its official SDK provides a separate Cloud API setup route. Business account coexistence, current AI-provider restrictions, and the newly reported business-management MCP were not verified from accessible primary documentation. Do not use older blanket claims about WhatsApp AI bans or assume that business-management MCP grants personal inbox access. These remain provider questions. [Meta Cloud API SDK](https://whatsapp.github.io/WhatsApp-Nodejs-SDK/), [Meta developer overview requiring access](https://developers.facebook.com/documentation/business-messaging/whatsapp/overview) + +## WeChat + +No supported general personal WeChat inbox API was verified. Official Account and WeCom documentation could not be read through the research tool. Their product names must not be treated as proof of access to personal WeChat conversations. This is an evidence gap, not a categorical proof that no such capability can exist. + +The local Wechaty Web provider is a real candidate, but its own documentation warns that accounts registered after 2017 may be unable to log in to Web WeChat. It also reports contact and room IDs changing between sessions. Those limits make it unsuitable as a predictable default for new consumers. [Wechaty Web provider](https://wechaty.js.org/docs/puppet-providers/wechat) + +Recommendation: defer automatic personal sync. Investigate supported user export or selected-content import. Evaluate a local UI read only for an explicit user task, using the existing opt-in capture controls and visible indicator. Do not describe screen content as a complete inbox. Do not extract process keys or patch the WeChat application as the default product design. + +Treat WeCom as a separate business request. Before scoping it, verify the archive product, administrator setup, participant consent, IP restrictions, supported SDK platforms, and whether the data can travel directly to the Mac. Do not buy a hosted Wechaty provider unless its data path is acceptable; some provider designs add a remote service. + +## Other communication systems + +Teams can extend the Microsoft provider for work accounts. Graph lists chat messages with delegated `Chat.Read`; personal Microsoft accounts are not supported by that API. Channel messages use different permissions and require separate access work. Mail consent alone does not grant Teams messages. [Graph chat messages](https://learn.microsoft.com/en-us/graph/api/chat-list-messages?view=graph-rest-1.0), [Graph channel messages](https://learn.microsoft.com/en-us/graph/api/channel-list-messages?view=graph-rest-1.0) + +Matrix has a direct client-server protocol for authentication, sync, messages, and device encryption. It fits the proposed architecture well. The main work is a full local client session, including key verification and recovery for encrypted rooms. A Matrix bridge to WhatsApp or WeChat is a separate data path; do not assume that it meets the same requirement. [Matrix client-server specification](https://spec.matrix.org/latest/client-server-api/) + +Signal can use the community `signal-cli` program locally. The project supports linking an existing account and a JSON-RPC daemon. It also warns that releases older than three months may fail after server changes. This adds runtime packaging and maintenance work. Treat it as an optional experiment, not a low-maintenance official provider API. [signal-cli documentation](https://github.com/AsamK/signal-cli/blob/master/README.md) + +Discord supports bot automation. It forbids automating a normal user account as a self-bot outside the supported OAuth or bot API. A bot connection can cover permitted server channels; it does not provide a general copy of the user's private inbox. [Discord self-bot policy](https://support.discord.com/hc/en-us/articles/115002192352-Automated-User-Accounts-Self-Bots) + +## Proposed shared architecture + +Keep provider code and paid memory behavior in `pro/`. Reuse the generic core hooks and tools. Any new core boundary should remain inert when Pro is absent. No Pro implementation should be added to the public core to make a single provider easier. + +Use an explicit provider identifier. Today, a Google connection's MCP URL also selects its REST behavior. A provider ID is clearer for Graph, IMAP, CalDAV, and native sources, which may not have a meaningful MCP URL. + +A proposed provider module needs these operations: + +- Connect an account, cancel an attempt, and report granted access. +- Verify a small authenticated read. +- Enumerate selectable folders, calendars, or conversations. +- Supply tools for live reads and permitted actions. +- Import permitted records with pagination and a resumable cursor. +- Reconcile updates and deletions. +- Revoke access where supported and remove local credentials. +- Report account-specific status without exposing tokens or message bodies in logs. + +Keep account authentication separate from service selection. One Microsoft account can supply Mail and Calendar while the user enables each service separately. Persist a stable provider account ID, tenant where applicable, granted scopes, and connection generation. Do not use a visible display name as the record's identity. + +Keep live search separate from retained memory. Each tool result needs a retention rule supplied by the provider module: transient use only, permitted local retention, or retention not yet established. The memory importer must check this rule. Transient data must also stay out of persistent chat history, tool traces, embeddings, error dumps, and paired-device sync. + +For retained data, store normalized source records before summaries. A proposed identity is provider, account, collection, item ID, and version. Calendar recurrence needs an occurrence identity as well. Keep deleted-record state long enough to remove derived observations. Advance a sync cursor only after records commit successfully. + +Provider modules must report explicit tool risk and capability. The current generic MCP classifier uses the tool name to decide whether a call is a read. That can remain a fallback for advanced custom servers. Built-in modules know whether a call sends, changes, deletes, or only reads. They should use that knowledge. + +Use a per-account sync lock and bounded requests. Apply `Retry-After`, backoff, cancellation, and sleep/wake recovery. Record the last successful sync separately from the last attempt. A denied token, blocked administrator consent, and a temporary rate limit need different status values. + +For direct data retrieval, prefer polling change cursors over adding a public webhook server. Provider-specific outbound streams can reduce delay where available. The current 30-minute timer is a useful starting point, not a universal freshness promise. When the app is closed or the Mac sleeps, a device-only system cannot continue processing on an Off Grid cloud worker. + +Enforce local AI processing for these sources. The fact that a request uses the shared `llm` interface does not alone prove that every configured model route is local. Check the selected execution route before supplying communication data. The same applies to embeddings and summaries. Do not send data to an optional remote model under this research's scope. + +Allow only the provider's required network destinations, including auth, API, and documented content-download hosts. Validate redirect and pagination destinations before forwarding credentials. Do not automatically load remote images in email bodies. Keep message content as data; it must not authorize a new connection or a send operation. + +The current local MCP launcher merges the parent process environment into the child environment. For managed provider programs, pass only the environment values they need. Do not give every local integration all parent environment values. A custom MCP program also needs an explicit installation and trust decision; local execution alone does not prove that its network path meets the requirement. + +Disconnection and deletion are separate user choices. Removing a token does not remove already imported observations. Offer a clear local data removal path, including derived summaries and indexes. Retain the current opt-in and visible indicator requirements for any screen capture fallback. + +## Proposed consumer setup + +The default setup should have four stages: + +1. Select the source. +2. Sign in at the provider or grant the OS permission. +3. Select the services and data scope. +4. Confirm the first small import and show its date range and result count. + +Do not ask a normal consumer for client IDs, client secrets, MCP URLs, terminal commands, or Docker setup. Where a provider requires an app password, show the provider's own page and ask only for the minimum credentials. Keep own-client and custom MCP setup in an advanced path. + +Proposed first import: a bounded recent range and selected folders or calendars. Show older history as an explicit choice with an estimated cost in time or disk space when the provider supports it. This is a product choice, not a claim that 30 days is a provider limit. + +Show the account, selected data, data path, last successful sync, and access that needs renewal. Report a limited local calendar cache or a partial message history plainly. Avoid a green Connected status that implies complete data coverage. + +## Implementation order and decision tests + +### First release work + +1. **Mac calendar connection.** Reuse the EventKit reader. Add calendar selection and a Pro import module. This offers the fewest setup steps for users with configured Mac accounts. +2. **Microsoft mail and calendars.** Register a public application, complete publisher verification, and build a Graph module with protected MSAL caching. This is the strongest new direct network provider. +3. **Google setup improvement.** Keep REST, add a verified Desktop application path, and retain the own-client path. Add selected calendars, paging, and change tracking. +4. **Shared IMAP and CalDAV.** Add iCloud, Yahoo, Zoho, and custom-provider presets. Add Proton Bridge as a named local option. +5. **Fastmail.** Add JMAP OAuth if demand justifies a dedicated mail module. + +### Separate feasibility work + +- **Slack:** prove public-client login, refresh, MCP eligibility, account visibility, and live search without persistent copies. Resolve approved retained-memory use before connecting the autosync importer. +- **Telegram:** obtain provider clarification on AI use and consent before general import work. +- **Personal WhatsApp:** evaluate a direct local linked client only as an optional experiment with an explicit support limit. +- **WeChat:** obtain accessible provider documentation and test a supported account route before committing to sync. +- **Teams, Matrix, and Signal:** add after the common account and import model is stable. + +These priorities are judgments based on setup friction, existing code, provider support, and maintenance burden. They are not development-time estimates. Provider verification can take longer than implementation, so start registration work early. + +Before a provider is selected for release, run a small disposable-account test that proves: + +- First sign-in, cancellation, restart, reconnect, and concurrent account behavior. +- The account scope and returned identity match the user's selection. +- Data requests go directly to the source and AI processing stays local. +- Paging, token refresh, rate limits, updates, deletions, and interrupted sync are handled. +- No live-search-only data persists in memory, model history, logs, or sync. +- Sending and calendar changes use the existing action controls and correct account. + +For EventKit, also test denied OS access, signed-app permission attribution, multiple calendars, all-day events, daylight-saving changes, and recurring events. For IMAP, prove that a fetch does not change the Seen flag. For Slack, prove private scope revocation and the chosen retention rule. These are proposed future implementation checks; none was executed against a customer account for this report. + +## Evidence limits and unresolved questions + +This research inspected code and current provider documentation. It did not connect user accounts, install integration programs, run the app, or run end-to-end tests. No credentials or private profile records were used. + +The following items need direct confirmation before a release promise: + +| Item | What remains unresolved | Required next evidence | +| --- | --- | --- | +| Google verification | Exact assessment scope for the complete Off Grid configuration | Verification-team response to the documented local data path | +| Microsoft organizations | Consent behavior under customer tenant policy | A disposable personal account and a managed test tenant | +| Slack | Marketplace eligibility; retention rules for MCP, RTS, and derived output | Provider approval and a scoped internal-app test | +| Apple account authorization | Off Grid eligibility and developer registration for Mail access | Apple developer guidance; do not infer it from consumer help | +| Fastmail calendars | OAuth credential compatibility with CalDAV | Provider documentation or a registered test application | +| Telegram | AI use and all-participant consent implementation | Provider clarification for the specific product behavior | +| WhatsApp | Consumer inbox API availability; business inbound path; current agent and business MCP developer access | Accessible primary documentation and a disposable account | +| WeChat and WeCom | Supported personal path and business archive requirements | Accessible Tencent documentation and provider-approved test setup | + +Primary Meta and Tencent pages were partly inaccessible or required login. Community reports were used to find questions, not to establish release capabilities. Project documentation for Baileys, Wechaty, and signal-cli establishes what those projects claim; it does not establish provider approval. + +The saved result is a research document only. It contains private-repository source references and should remain internal. The main checkout's application files were not changed. New provider code should be implemented and committed in the Pro repository, with separate core changes only where a generic interface needs extension. + + +## Obsidian implementation choice + +User job: connect local Markdown notes from onboarding or Integrations with one folder choice. Reuse the pinned production `Card` and `Button` primitives already used by Google. The native folder dialog owns folder selection; a text path field or third-party MCP process would add setup work and is not selected. A private provider supplies read tools through the existing core MCP tool-source hook. The grant names one vault directory; hidden directories, symbolic links, non-Markdown files, and paths outside that directory are excluded. Notes are read when requested, without a background import. No new tests will be written or run until the user agrees that the product works. + + +Obsidian delivery: onboarding and Integrations now include **Choose vault** and **Change vault**. The private provider supplies `list_notes`, `search_notes`, and `read_note` through the same MCP chat extension as other connections. Folder grants are protected in the device secret store and are removed when the connection is removed. A changed or unavailable vault requires folder selection again. The reader skips hidden paths and symbolic links, accepts Markdown only, limits each note to 2 MB, and reports bounded search results. No writes or automatic memory import are available from this provider. The build and Pro type check passed. No Obsidian tests were written or run. New profiles also leave clipboard capture off until explicitly enabled; saved user choices retain their previous value. + +## Microsoft implementation, 30 September 2026 + +The private provider uses direct Microsoft Graph v1.0 requests and the common +Microsoft identity authority for personal and work/school accounts. One public +application identity requests delegated User.Read, Mail.Read, Calendars.Read, +and Files.Read permissions, plus offline_access for refresh. Authorization uses +PKCE, the existing system-browser loopback, cancel, and encrypted local tokens. +No confidential client secret is requested or bundled. + +Onboarding and Integrations offer registered Off Grid sign-in or a user-supplied +public application ID. Configure MICROSOFT_CLIENT_ID for the registered build. +No Microsoft registration was supplied for this worktree, so registered sign-in +is visibly unavailable. Register personal and organizational account support +and the app's displayed loopback URI under Mobile and desktop applications. +Microsoft requires the 127.0.0.1 HTTP URI to be configured through the app manifest +when the portal field does not allow it. Work tenants may require administrator +consent. This implementation targets the global Microsoft cloud. + +Tools read/search Outlook messages, read message bodies, list calendars and +calendar occurrences, list/search OneDrive files, and read text files up to 2 MB. +Binary Office documents return metadata. Collection tools preserve next-page +links and restrict authenticated pagination to the same Graph collection. +OneDrive download redirects never receive the OAuth token. Connections stay +live-only, without automatic memory import. Changed own-app settings remove +old tokens and require approval again. + +Sources: [Microsoft authorization code flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow), +[redirect configuration](https://learn.microsoft.com/en-us/entra/identity-platform/reply-url), +[mail reads](https://learn.microsoft.com/en-us/graph/api/user-list-messages?view=graph-rest-1.0), +[calendar occurrences](https://learn.microsoft.com/en-us/graph/api/calendar-list-calendarview?view=graph-rest-1.0), +[OneDrive download redirects](https://learn.microsoft.com/en-us/graph/api/driveitem-get-content?view=graph-rest-1.0). + +## Design correction and reload recovery + +The sibling brand guide at 15e6b2f (7 September) is newer than the Desktop guide +at 332e48397 (27 August). The Desktop guide now includes the canonical content +and a source record; DESIGN.md refers to it. The connection screen reuses the existing Desktop Item, ItemGroup, Button, and +SidePanel components after product review rejected the earlier card composition. +Shared Input, Label, and NativeSelect controls remain for the advanced setup +form. Row composition wins over tall Cards and catalogue demo controls because +it matches the existing Desktop patterns, keeps actions beside content, and +moves application configuration into a focused side panel. No Card or Sheet +copy remains in the connection screen. The pinned operator dependency is +ca35d2f9004b212ea034b73109fcd2819a08816b. + +Locally bundled monochrome app marks identify Google, Microsoft, Obsidian, +Notion, Jira, Confluence, and Linear; generic service icons were removed. +CSS layer ordering places Tailwind resets before shared components. Previously +the reset layer was registered after components and overrode spacing and text +sizes. New profiles now start in dark mode, consistent with the documented +Desktop default; explicit light and system preferences remain respected. + +Packaged navigation now keeps the renderer HTML path and stores routes in the +fragment, preventing reload from opening missing file:///settings pages. +Onboarding loads the provider slot for entitled builds before full app activation, +so Google, Microsoft, and Obsidian appear with the existing MCP providers. +No tests were added or run during this implementation. Real account authorization +is still unverified. + +Delivery checks: the final Pro type check and production bundle pass. Core type +checking still reports the existing chat-stream video-phase contract mismatch +in src/main/chat-stream-state.ts. No tests were written or run. The isolated +review app shows the connection rows, correct app marks, dark default, and the +Microsoft own-application SidePanel. Real-account sign-in remains unverified. + +### Google setup correction, 30 September 2026 + +Enabled the Google Drive API and Google People API in the Off Grid AI project through Google Cloud Console. The owner approved the People API terms. The Enabled APIs page confirms Gmail, Calendar, Drive, and People. The worktree now offers Add account after a successful Google connection. Each addition creates its own connector ID and protected token keys; Google shows an account chooser. Successful verification labels the connector with its account email, so chat can distinguish account-specific tool sets. Existing Integrations controls manage each connector separately. Setup errors render below the row and disabled-API responses use short actionable copy. Production build and Pro type check pass; no tests were written or run. Real sign-in and multiple-account behavior need owner review. + +Obsidian update: the community Local REST API plugin now includes a built-in MCP server, so a separate wrapper server is unnecessary. This requires the plugin, API key, and a running Obsidian session; the existing folder reader remains the no-plugin option. See https://github.com/coddingtonbear/obsidian-local-rest-api. + +### Microsoft registered application, 30 September 2026 + +Created Off Grid AI Desktop in Microsoft Entra with the owner's approval of Microsoft Platform Policies. Public client ID: 2bbfd68a-f7ce-4327-8cb9-45760b77c27f. Account audience: organisational and personal Microsoft accounts. Mobile/Desktop redirect: http://127.0.0.1:33418/callback. No client secret was created. The ignored worktree configuration supplies MICROSOFT_CLIENT_ID; release builds need the same public ID. The production build passed and the review app was restarted. Microsoft displays a publisher-verification requirement for customer consent to newly registered multitenant applications. No tenant-wide administrator consent was granted. Real account sign-in remains to be reviewed. + +### Per-account service selection + +Google and Microsoft quick setup now opens the shared SidePanel with service checkboxes before sign-in, in both onboarding and Integrations. At least one service is required. Each connector stores its own validated service selection in protected storage. OAuth scopes, Microsoft refresh scopes, service verification, and exposed chat tools follow that selection; tool execution also rejects unselected services. Existing connections without selection metadata keep their previous full-service behavior. Google identity fallback reads skip unselected Gmail/Calendar. The existing shared Input/Label primitives supply checkbox controls. Production build and Pro type check pass. No tests were written or run. The Google checklist is visible in the running review app; actual consent for a reduced selection remains to be reviewed. + +### Account management progress + +Added account lists and per-connection Remove actions for Google, Microsoft, Notion, Jira, Linear, plus Obsidian vault removal. Google/Microsoft Edit access loads saved selections. A new disabled connection holds sign-in and verification; only a matching provider identity can replace the existing account credentials. Failed/cancelled attempts leave the original connection intact. Duplicate Google/Microsoft sign-in targets the existing connection. Older Microsoft labels are filled from Graph /me. Existing duplicate rows are not automatically removed. + +MCP account identity checks use advertised Jira/Linear identity tools and Notion workspace metadata when available. Notion self lookup follows the advertised input schema and requires a workspace ID. Missing identity leaves connections separate; universal deduplication is not yet established. No identity is inferred from a shared server URL. Provider-managed permission changes for those MCP servers still need their consent flows. No tests were run or written. diff --git a/e2e/accounts-lifecycle.spec.ts b/e2e/accounts-lifecycle.spec.ts new file mode 100644 index 000000000..0e6b752e0 --- /dev/null +++ b/e2e/accounts-lifecycle.spec.ts @@ -0,0 +1,224 @@ +/** + * Every account kind can be added, read, changed and removed in the real app (Pro, dev build, + * fresh temp profile). Boundaries replaced: the OS folder chooser (a synthetic vault) and the + * system browser (shell.openExternal records the consent URL instead of opening it). A real + * Google or Microsoft consent needs a person and is checked by hand; this spec proves everything + * up to that page, including which services each sign-in asks for. + */ +import { test, expect, type ElectronApplication, type Page } from '@playwright/test' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { launchOffGrid } from './helpers/launch' +import { completeOnboarding } from './helpers/onboarding' + +const PRO_PRESENT = fs.existsSync(path.resolve('pro/package.json')) +const MCP_SERVER = path.resolve('e2e/fixtures/accounts-mcp-connector.mjs') +const SHOTS = process.env.OFFGRID_E2E_SHOTS + +let app: ElectronApplication +let page: Page +let profileDir: string + +const shot = async (name: string): Promise => { + if (SHOTS) await page.screenshot({ path: path.join(SHOTS, `${name}.png`) }) +} + +/** Consent URLs the app asked the system browser to open, oldest first. */ +const opened = (): Promise => + app.evaluate(() => (globalThis as unknown as { __opened: string[] }).__opened.slice()) + +const nextConsent = async (host: string): Promise => { + let found: string | undefined + await expect + .poll( + async () => { + found = (await opened()).reverse().find((u) => new URL(u).hostname === host) + return Boolean(found) + }, + { timeout: 20_000 } + ) + .toBe(true) + await app.evaluate(() => { + ;(globalThis as unknown as { __opened: string[] }).__opened.length = 0 + }) + return new URL(found!) +} + +const openIntegrations = async (): Promise => { + const nav = page.getByRole('navigation', { name: 'Primary navigation' }) + await nav.hover() + await nav.getByRole('button', { name: 'Integrations', exact: true }).click() + // The first launch of a run is cold: give the app time to reach the screen. + await expect(page.getByRole('heading', { name: 'Integrations' })).toBeVisible({ timeout: 60_000 }) +} + +const card = (name: string): ReturnType => + page.getByRole('listitem', { name, exact: true }) + +test.beforeEach(async () => { + test.skip(!PRO_PRESENT, 'pro package not present') + profileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-accounts-')) + app = await launchOffGrid({ + env: { ...process.env, OFFGRID_USER_DATA: profileDir, OFFGRID_PRO: '1', NODE_ENV: 'production' } + }) + page = await app.firstWindow() + await page.waitForLoadState('domcontentloaded') + await app.evaluate(({ shell }) => { + const g = globalThis as unknown as { __opened: string[] } + g.__opened = [] + shell.openExternal = async (url: string) => { + g.__opened.push(url) + } + }) + await completeOnboarding(page) + await openIntegrations() +}) + +test.afterEach(async () => { + await app?.close().catch(() => undefined) + fs.rmSync(profileDir, { recursive: true, force: true }) +}) + +for (const provider of [ + { + name: 'Google', + host: 'accounts.google.com', + drop: 'Drive', + keeps: 'gmail.readonly', + dropped: 'drive.readonly', + ownId: '123456789012-synthetic.apps.googleusercontent.com', + ownSecret: 'synthetic-secret' + }, + { + name: 'Microsoft', + host: 'login.microsoftonline.com', + drop: 'OneDrive', + keeps: 'Mail.Read', + dropped: 'Files.Read', + ownId: '00000000-1111-4222-8333-444444444444', + ownSecret: null + } +] as const) { + test(`${provider.name}: asks only for the chosen services, with Off Grid AI's app or your own`, async () => { + const google = provider.name === 'Google' + await card(provider.name).getByRole('button', { name: 'Connect', exact: true }).click() + const panel = page.getByRole('dialog', { name: `Connect ${provider.name}` }) + await expect(panel).toBeVisible() + await panel.getByRole('checkbox', { name: provider.drop }).uncheck() + await shot(`${provider.name.toLowerCase()}-choose-services`) + + // Connect with Off Grid AI: the registration built into this app. + await panel.getByRole('button', { name: `Connect ${provider.name}` }).click() + const builtIn = await nextConsent(provider.host) + const builtInClient = builtIn.searchParams.get('client_id')! + expect(builtInClient).toBeTruthy() + expect(builtIn.searchParams.get('scope')).toContain(provider.keeps) + expect(builtIn.searchParams.get('scope')).not.toContain(provider.dropped) + expect(builtIn.searchParams.get('code_challenge_method')).toBe('S256') + await panel.getByRole('button', { name: 'Cancel' }).click() + await expect(panel.getByRole('button', { name: `Connect ${provider.name}` })).toBeEnabled() + + // Use your own application: client ID (and Google's secret) saved on this device. + await panel.getByLabel('Connection option').selectOption('own') + await panel.getByLabel('Application client ID').fill(provider.ownId) + if (provider.ownSecret) await panel.getByLabel('Client secret').fill(provider.ownSecret) + else await expect(panel.getByLabel('Client secret')).toHaveCount(0) + await panel.getByRole('button', { name: 'Save application' }).click() + await expect(panel.getByText('Application saved on this device.')).toBeVisible() + await shot(`${provider.name.toLowerCase()}-own-app`) + await panel.getByRole('button', { name: `Connect ${provider.name}` }).click() + const own = await nextConsent(provider.host) + expect(own.searchParams.get('client_id')).toBe(provider.ownId) + expect(own.searchParams.get('client_id')).not.toBe(builtInClient) + expect(own.searchParams.get('scope')).not.toContain(provider.dropped) + await panel.getByRole('button', { name: 'Cancel' }).click() + + // A cancelled sign-in leaves no half-made account behind. + await panel.getByRole('button', { name: `Close ${provider.name} setup` }).click() + await expect( + card(provider.name).getByRole('button', { name: 'Connect', exact: true }) + ).toBeVisible() + expect(google || provider.ownSecret === null).toBe(true) + }) +} + +test('Obsidian: add two vaults, change one, remove one after confirming', async () => { + const vault = (name: string): string => { + const dir = path.join(profileDir, 'vaults', name) + fs.mkdirSync(path.join(dir, '.obsidian'), { recursive: true }) + fs.writeFileSync(path.join(dir, 'Synthetic note.md'), `# ${name}\nA synthetic note.\n`) + return dir + } + const choose = (dir: string): Promise => + app.evaluate(({ dialog }, folder) => { + dialog.showOpenDialog = (async () => ({ canceled: false, filePaths: [folder] })) as never + }, dir) + const obsidian = card('Obsidian') + const vaults = obsidian.getByRole('list', { name: 'Obsidian vaults' }) + + await choose(vault('Field notes')) + await obsidian.getByRole('button', { name: 'Choose vault' }).click() + await expect(vaults.getByRole('listitem', { name: 'Field notes' })).toBeVisible() + + await choose(vault('Recipes')) + await obsidian.getByRole('button', { name: 'Add vault' }).click() + await expect(vaults.getByRole('listitem', { name: 'Recipes' })).toBeVisible() + + // The same folder twice is refused. + await obsidian.getByRole('button', { name: 'Add vault' }).click() + // The reason is shown as written, without Electron's IPC wrapper, and there is no Retry: + // nothing failed to load. + await expect(page.getByRole('alert')).toHaveText( + 'The vault Recipes is already connected. Choose a different vault.' + ) + await expect(obsidian.getByRole('button', { name: 'Retry' })).toHaveCount(0) + + await choose(vault('Recipes 2026')) + await vaults.getByRole('button', { name: 'Change Recipes' }).click() + await expect(vaults.getByRole('listitem', { name: 'Recipes 2026' })).toBeVisible() + await expect(vaults.getByRole('listitem', { name: 'Recipes', exact: true })).toHaveCount(0) + await shot('obsidian-two-vaults') + + await vaults.getByRole('button', { name: 'Remove Field notes' }).click() + await vaults + .getByRole('alertdialog', { name: 'Remove Field notes?' }) + .getByRole('button', { name: 'Remove' }) + .click() + await expect(vaults.getByRole('listitem', { name: 'Field notes' })).toHaveCount(0) + await expect(vaults.getByRole('listitem', { name: 'Recipes 2026' })).toBeVisible() +}) + +test('MCP connector: add, test, rename, remove after confirming', async () => { + await page.getByRole('button', { name: /Custom/ }).click() + await page.getByPlaceholder('Name').fill('Synthetic notes') + await page.getByRole('button', { name: 'stdio (local)' }).click() + await page.getByPlaceholder('command (e.g. npx)').fill(process.execPath) + await page.getByPlaceholder('args').fill(MCP_SERVER) + await page.getByRole('button', { name: 'Add', exact: true }).click() + + await page + .getByRole('button', { name: /Synthetic notes/ }) + .first() + .click() + await expect(page.getByRole('heading', { name: 'Synthetic notes' })).toBeVisible() + await page.getByRole('button', { name: 'Test' }).click() + await expect(page.getByText('connected', { exact: true })).toBeVisible({ timeout: 30_000 }) + await expect(page.getByText('lookup_note')).toBeVisible() + + await page.getByRole('button', { name: 'Edit' }).click() + const form = page.getByRole('form', { name: 'Edit Synthetic notes' }) + await form.getByLabel('Name').fill('Team notebook') + await form.getByRole('button', { name: 'Save' }).click() + await expect(page.getByRole('heading', { name: 'Team notebook' })).toBeVisible() + await expect(page.getByText('connected', { exact: true })).toBeVisible() + await shot('mcp-connector-edited') + + await page.getByRole('button', { name: 'Remove Team notebook' }).click() + await page + .getByRole('alertdialog', { name: 'Remove Team notebook?' }) + .getByRole('button', { name: 'Remove' }) + .click() + await expect(page.getByRole('heading', { name: 'Team notebook' })).toHaveCount(0) + await expect(page.getByRole('button', { name: /Team notebook/ })).toHaveCount(0) +}) diff --git a/e2e/devices-sync.spec.ts b/e2e/devices-sync.spec.ts index 45aba2b43..dbc8a9498 100644 --- a/e2e/devices-sync.spec.ts +++ b/e2e/devices-sync.spec.ts @@ -517,7 +517,7 @@ test.describe('Devices surface — pro tier', () => { await page.getByRole('button', { name: 'Rescan network' }).click() await expect(page.getByRole('button', { name: 'Rescan network' })).toBeEnabled() await expect(page.getByText('Synthetic Android').first()).toBeVisible() - await expect(page.getByText('Scanning WiFi and Nearby routes.')).toHaveCount(0) + await expect(page.getByText('Scanning Wi-Fi and Nearby routes.')).toHaveCount(0) await expect(page.getByRole('alert')).toHaveCount(0) const timestamp = new Date().toISOString() diff --git a/e2e/fixtures/accounts-mcp-connector.mjs b/e2e/fixtures/accounts-mcp-connector.mjs new file mode 100644 index 000000000..c51722cd1 --- /dev/null +++ b/e2e/fixtures/accounts-mcp-connector.mjs @@ -0,0 +1,12 @@ +// A synthetic local MCP server for the accounts lifecycle spec: one read tool, synthetic data. +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { z } from 'zod' + +const server = new McpServer({ name: 'accounts-lifecycle-notes', version: '1.0.0' }) +server.registerTool( + 'lookup_note', + { description: 'Finds a note in the synthetic notebook.', inputSchema: { title: z.string() } }, + async ({ title }) => ({ content: [{ type: 'text', text: `Synthetic note: ${title}` }] }) +) +await server.connect(new StdioServerTransport()) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 16336937f..b4d91ce45 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -2,9 +2,15 @@ import { resolve } from 'node:path' import { existsSync, readFileSync } from 'node:fs' import { randomBytes } from 'node:crypto' import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +import { loadEnv } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import { createRendererContentSecurityPolicy } from './src/shared/renderer-csp' +import { + checkOauthBuildConfig, + readUserBuildEnv, + resolveOauthBuildConfig +} from './scripts/oauth-build-config.mjs' // Open-core seam: the private `pro/` git submodule is present only in paid // builds. When it's missing (free / contributor build) we alias the pro entry @@ -22,6 +28,22 @@ const proRenderer = proExists ? resolve('pro/renderer/index.tsx') : stub // Baked into every bundle so runtime code can tell a pro build from a free build // without relying on an env var default (which can't distinguish "unset" from "pro"). const proDefine = { __OFFGRID_PRO__: JSON.stringify(proExists) } +// The app's own version. An unpackaged launch that points Electron at a script rather than this +// package (the e2e capture fixtures) gets Electron's version from app.getVersion(). +const appVersion = (JSON.parse(readFileSync(resolve('package.json'), 'utf8')) as { version: string }) + .version +// App registrations for Connect with Off Grid AI (scripts/oauth-build-config.mjs). Only a Desktop +// Google registration is accepted: a Web application's confidential secret must not enter an +// installed app. A release build (OFFGRID_REQUIRE_OAUTH=1) fails rather than ship without them. +const oauth = resolveOauthBuildConfig([ + process.env, + loadEnv('production', process.cwd(), ['GOOGLE_', 'MICROSOFT_']), + readUserBuildEnv() +]) +checkOauthBuildConfig(oauth, { pro: proExists, require: process.env.OFFGRID_REQUIRE_OAUTH === '1' }) +const microsoftClientId = oauth.microsoftClientId +const googleClientId = oauth.google.clientId +const googleClientSecret = oauth.google.clientSecret // Sourcemaps, for one purpose: making the e2e run's coverage land on source. // @@ -38,7 +60,13 @@ const rendererContentSecurityPolicy = createRendererContentSecurityPolicy(render export default defineConfig({ main: { - define: proDefine, + define: { + ...proDefine, + __OFFGRID_APP_VERSION__: JSON.stringify(appVersion), + __OFFGRID_MICROSOFT_CLIENT_ID__: JSON.stringify(microsoftClientId), + __OFFGRID_GOOGLE_CLIENT_ID__: JSON.stringify(googleClientId), + __OFFGRID_GOOGLE_CLIENT_SECRET__: JSON.stringify(googleClientSecret) + }, build: { sourcemap: coverageSourcemap, // The embedding worker is a SECOND main-process entry, bundled beside index.js so diff --git a/integration-tests/memory-chat-tts.ui.integration.dbtest.ts b/integration-tests/memory-chat-tts.ui.integration.dbtest.ts index 7053935aa..338054c67 100644 --- a/integration-tests/memory-chat-tts.ui.integration.dbtest.ts +++ b/integration-tests/memory-chat-tts.ui.integration.dbtest.ts @@ -379,6 +379,8 @@ describe('assistant reply speech integration (#105)', () => { const boundary = new ChatBoundary() installProductionVoiceBridge(boundary) + // This journey is the Manual turn: tap to record, tap to stop. Auto is the default now. + saveSetting('composerVoiceTurnMode', 'tap') const user = userEvent.setup() const view = renderChat({ conversationId }) diff --git a/integration-tests/remote-settings.ui.integration.dbtest.tsx b/integration-tests/remote-settings.ui.integration.dbtest.tsx index 071daeb49..d604807c3 100644 --- a/integration-tests/remote-settings.ui.integration.dbtest.tsx +++ b/integration-tests/remote-settings.ui.integration.dbtest.tsx @@ -94,6 +94,8 @@ describe('remote media choices in Desktop Settings', () => { fireEvent.click(screen.getByRole('button', { name: 'Test connection' })) await screen.findByText(/3 models found/) const user = userEvent.setup() + // The media choices sit in the Other models section, closed until opened. + await user.click(screen.getByRole('button', { name: /^Other models/ })) await user.click(screen.getByRole('button', { name: 'image model' })) await user.click(screen.getByRole('menuitemradio', { name: 'picture' })) await user.click(screen.getByRole('button', { name: 'transcription model' })) diff --git a/integration-tests/workspace-production-bridge.ui.integration.dbtest.tsx b/integration-tests/workspace-production-bridge.ui.integration.dbtest.tsx index 852eb8b39..84bc5601f 100644 --- a/integration-tests/workspace-production-bridge.ui.integration.dbtest.tsx +++ b/integration-tests/workspace-production-bridge.ui.integration.dbtest.tsx @@ -110,7 +110,8 @@ vi.mock('electron', () => ({ } })) -vi.mock('@xenova/transformers', () => ({ +// The embedding model is the one boundary replaced: a real ONNX run cannot work inside jsdom. +vi.mock('@huggingface/transformers', () => ({ env: {}, pipeline: async () => async () => ({ data: new Float32Array(384).fill(0.01) }) })) diff --git a/package-lock.json b/package-lock.json index c54715a89..712a6a2c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "motion": "^12.27.1", "node-machine-id": "^1.1.12", "ollama": "^0.6.3", + "onnxruntime-node": "1.30.0", "pdf-parse": "^1.1.1", "qrcode.react": "^4.2.0", "radix-ui": "^1.6.0", @@ -64,6 +65,7 @@ "remark-parse": "^11.0.0", "sharp": "^0.35.2", "tailwind-merge": "^3.4.0", + "three": "^0.186.0", "tldts": "^7.4.7", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", @@ -84,6 +86,7 @@ "@types/node": "^22.19.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/three": "^0.186.0", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^5.1.1", "@vitest/coverage-v8": "^4.1.10", @@ -114,6 +117,7 @@ "node": ">=22 <23" }, "optionalDependencies": { + "@offgrid/operator-ui": "github:wednesday-solutions/component-library-animations#ca35d2f9004b212ea034b73109fcd2819a08816b", "@nut-tree-fork/nut-js": "^4.2.6" } }, @@ -251,13 +255,13 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -266,9 +270,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -276,21 +280,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -307,14 +311,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -324,14 +328,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -341,9 +345,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -351,29 +355,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -413,9 +417,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -423,27 +427,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -511,33 +515,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -545,9 +549,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -727,6 +731,13 @@ "node": ">=20.19.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -1778,9 +1789,9 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1854,9 +1865,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1960,15 +1971,15 @@ "license": "MIT" }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.4.tgz", + "integrity": "sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { - "hono": "^4" + "hono": "^4 || ^5.0.0-0" } }, "node_modules/@huggingface/tokenizers": { @@ -2005,65 +2016,12 @@ "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", "license": "Apache-2.0" }, - "node_modules/@huggingface/transformers/node_modules/global-agent": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz", - "integrity": "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==", - "license": "BSD-3-Clause", - "dependencies": { - "globalthis": "^1.0.2", - "matcher": "^4.0.0", - "semver": "^7.3.5", - "serialize-error": "^8.1.0" - }, - "engines": { - "node": ">=10.0" - } - }, "node_modules/@huggingface/transformers/node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, - "node_modules/@huggingface/transformers/node_modules/matcher": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz", - "integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@huggingface/transformers/node_modules/onnxruntime-common": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.30.0.tgz", - "integrity": "sha512-7fdVWjAID1dVhH/G8qK3APARunV4VkBFoCQAP7qp4Wkab0mrorvmc+sqiT+mKXOzDqdjN5j+/Z9nb4gzNPWcyA==", - "license": "MIT" - }, - "node_modules/@huggingface/transformers/node_modules/onnxruntime-node": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.30.0.tgz", - "integrity": "sha512-twhs1C2C/BFkz1yc5OY0KIU2GUq6DURO7hD4bx5Q2Qy3nAMJwRXW8xU3NVczE29VA9lolLOYepoD8fjTGOfIqw==", - "hasInstallScript": true, - "license": "MIT", - "os": [ - "win32", - "darwin", - "linux" - ], - "dependencies": { - "adm-zip": "^0.6.0", - "global-agent": "^4.1.3", - "onnxruntime-common": "1.30.0" - } - }, "node_modules/@huggingface/transformers/node_modules/onnxruntime-web": { "version": "1.31.0-dev.20260914-8d85527a0", "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.31.0-dev.20260914-8d85527a0.tgz", @@ -2107,69 +2065,44 @@ "node": ">=12.0.0" } }, - "node_modules/@huggingface/transformers/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@huggingface/transformers/node_modules/serialize-error": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", - "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@huggingface/transformers/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -2208,9 +2141,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -2226,13 +2159,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -2248,20 +2181,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -2271,9 +2204,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -2287,9 +2220,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -2303,9 +2236,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -2322,9 +2255,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -2341,9 +2274,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -2360,9 +2293,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -2379,9 +2312,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -2398,9 +2331,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -2417,9 +2350,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -2436,9 +2369,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -2455,9 +2388,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -2476,13 +2409,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -2501,13 +2434,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -2526,13 +2459,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -2551,13 +2484,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -2576,13 +2509,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -2601,13 +2534,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -2626,13 +2559,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -2651,17 +2584,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -2670,17 +2603,27 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -2690,9 +2633,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -2709,9 +2652,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -2728,9 +2671,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -2853,7 +2796,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "minipass": "^7.0.4" @@ -3873,12 +3816,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -3934,6 +3877,25 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -4146,6 +4108,29 @@ "resolved": "../shared/packages/models", "link": true }, + "node_modules/@offgrid/operator-ui": { + "name": "@offgrid/ui", + "version": "0.2.4", + "resolved": "git+ssh://git@github.com/wednesday-solutions/component-library-animations.git#ca35d2f9004b212ea034b73109fcd2819a08816b", + "integrity": "sha512-u/CZp7RhApUuvweQVGj+JKkz7MOE5AnJw8SBbzhqVbyoFvCcNWJxpE1h+cpebIA6u9kP3Odv/XewfG9eOY/UtQ==", + "license": "UNLICENSED", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "class-variance-authority": "^0.7.1" + }, + "engines": { + "node": ">=20.9" + }, + "peerDependencies": { + "@offgrid/design": ">=0.0.1 <1", + "react": "^18.3.0 || ^19.0.0", + "react-dom": "^18.3.0 || ^19.0.0" + }, + "optional": true + }, "node_modules/@offgrid/rag": { "resolved": "packages/rag", "link": true @@ -6588,9 +6573,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.2.tgz", + "integrity": "sha512-gjNDUwMswogkw3fNqV00u54uZlgmInuw5EU/1TeqQ6K0bEagoGvqjPdKzy112lKyhe7PkGudZ/kSQHsc9qroIg==", "cpu": [ "arm" ], @@ -6601,9 +6586,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.2.tgz", + "integrity": "sha512-YkAW8q7p5SCLSaGWaUkmsRnBEb3MGA1pB5nI/8k0lAK5fZsQKRBqkdR1h4UU9p8u3c7ZGdUF2Zcx7mK+E/pUKg==", "cpu": [ "arm64" ], @@ -6614,9 +6599,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.2.tgz", + "integrity": "sha512-hNmuIHv+LnrP/+q/44vsmIMWfoh7X2YXOp5FLtjGuvqN52VdtFpOpnshTc1k28tWv1wE/0D5gLjsozBUowEGbQ==", "cpu": [ "arm64" ], @@ -6627,9 +6612,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.2.tgz", + "integrity": "sha512-B0t5CoxgF2KL7R5FHAsyN8yVEQCSIkh4XVs8MCDjuBLNjEa8l3zd5tVKaFMv53LfUtHs9p2rnqeavPTOunV+Ww==", "cpu": [ "x64" ], @@ -6640,9 +6625,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.2.tgz", + "integrity": "sha512-QQdYnfwrPmc1DoPlddjFvQPzUHFbStKXsHrG6EBYsWHhBVUCjsCRdcfGGXGAvhDpDtTzKK4vy1UrGFJ45rQaiw==", "cpu": [ "arm64" ], @@ -6653,9 +6638,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.2.tgz", + "integrity": "sha512-1xRxtGWif6BB58VSfMavi0Yfs2M4x6CtRp4o7ScY4W7AGlgvf6GSPKdDNdw7TX3tMiQwZY5W/mvyrOJTq/7AKA==", "cpu": [ "x64" ], @@ -6666,12 +6651,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.2.tgz", + "integrity": "sha512-CXFLEt384BUwwOLR9DURWNHl1bQOVLd1ibOee+yKBJm/fZtvrSAOZ0JbxjAxszVMIovYMwFMS+oP+Q1aPcx8Eg==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6679,12 +6667,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.2.tgz", + "integrity": "sha512-T1dorY0WHm5mMNpMHFl5rYRZ02gLSZcr6J45r66tYb3S3R3DyIRGiGWVdLtF+zOQjratNJaObl9jYumueRiL1A==", "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6692,12 +6683,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.2.tgz", + "integrity": "sha512-ia6bfaCIc0ldp6bvs9oEkFrJVJKNUMoiDF0+LBZsQAZg+vE0znXnzGqH+ew/FYswE30cruAWPTO3dPw7keaVkQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6705,12 +6699,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.2.tgz", + "integrity": "sha512-mB/gwfBfXtT0EBpbSssqKCqMFuX8b21Zy2TGoIhehSJEssZ5ksybxymwp1BkEM5Nczh/AAxpmh5q+MkhtundGg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6718,12 +6715,15 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.2.tgz", + "integrity": "sha512-VPd0a+XlHoNRI9jIPRHww2UKhDkD6XjbtcKYXZijAnhJS1jLLbiNtVl6a5LyEfNN+eyiyWcJOZXPzBo69oTsew==", "cpu": [ "loong64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6731,12 +6731,15 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.2.tgz", + "integrity": "sha512-10N1I6oBpERqzGtHKcCLrEpHlbXSBbMK9TURHvqgWbIFZuU3oJROxJ0H2nHfq96TVI0XJHQ9qzolJMFd2tz0LA==", "cpu": [ "loong64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6744,12 +6747,15 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.2.tgz", + "integrity": "sha512-dhJrLVZ3wNNhwCmy9/VTg6Pq6J8a8eZ0n15dVRlbSWcDb+8V/1mDfbnvis+vt8W6Pbxzv6gUbTEl4piOT3bbUQ==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6757,12 +6763,15 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.2.tgz", + "integrity": "sha512-tPd2QuwxkTxgju5eARyb3RdgBWWi/O9dn/NFH0VkvGbLzoQeXrH6qMABtW8IODb5lUeBzB3sy2U7qmI4MtQ72g==", "cpu": [ "ppc64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6770,12 +6779,15 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.2.tgz", + "integrity": "sha512-njtDdPvSQWI9CDhIPPxsiEMeHayCfu7wwcbSQ7c2kYPhGVviL+WGVhq6Jfj/b+VuhSTkFuQ0z4ZFbFjkWwBFmg==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6783,12 +6795,15 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.2.tgz", + "integrity": "sha512-EbOBo3dqCj9ecEV9Z7zvulB6AieMP2ztnKQYGVOSN0pzIyOu+3JH2YJZnLmbPeVvfn6yP6Qi7NbBM27EzGbj2w==", "cpu": [ "riscv64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6796,12 +6811,15 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.2.tgz", + "integrity": "sha512-8wLt2Q5n4qmfiE2Gk8V6KNLSf+oJuBX7Kdw5joFDtnZIZvirS0hLzBc79OiJjV71oYDmBUZLNy6pDgvylo4qiQ==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6809,12 +6827,15 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.2.tgz", + "integrity": "sha512-aIChbkhC4d33bxkVP0LSi2/2lUSGRXE6k4Qa0G7tgpgTOK7kxHTixmx5qC3SKUJ6yUMznHBtQoWaG4QMqLUnhw==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6822,12 +6843,15 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.2.tgz", + "integrity": "sha512-gQ+TkVZRfjZVcl/UOAxxfWw+sHjzIXoieNwxEyJlD3tRBmBhR8ZDq11w+QEgQDcpslEB+fhN/OEpaTsDoIIHhQ==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6835,9 +6859,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.2.tgz", + "integrity": "sha512-sUaJpMKS2JBCvUk8fYimG7aMq2jHVT4kw/uVHxPolKLrMmkKy0KXirmZyZehdNpL7PffqAlPAWUELQcTiD8ChA==", "cpu": [ "x64" ], @@ -6848,9 +6872,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.2.tgz", + "integrity": "sha512-j1RdJWb0CrlSuwt12OPjLucPlwfLtA/KMOKVvh5rXSJEtl/WDoIGR3CSykK/jLGTo9RqNfW5Ub6kY5Z5ccYl9w==", "cpu": [ "arm64" ], @@ -6861,9 +6885,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.2.tgz", + "integrity": "sha512-i3r04aHCIY3eggTBLUp/4IxiBjDpAgQaK89Ue2nMJ2MKuIXX5JfoeZjHw+kPjGGNVDlhi8FT8xzuGMJZe3aehg==", "cpu": [ "arm64" ], @@ -6874,9 +6898,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.2.tgz", + "integrity": "sha512-kCQ15h/uwNMGdN8gjXBZGf+aGE+ODzHfbi90/cAm4+Q89WoMiLODNoj8HoGZmYx87HPxfu5CBNPsZ+ZG4uIAoA==", "cpu": [ "ia32" ], @@ -6887,9 +6911,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.2.tgz", + "integrity": "sha512-yYAuySSs+0X2gSMJPw/Q4jHsHZHyMq1dpAE+XnN790Jg1WuReUAD/+j+e4vuDvS4PXJ4PA4NCzTR4OYtDf18/g==", "cpu": [ "x64" ], @@ -6900,9 +6924,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.2.tgz", + "integrity": "sha512-JY1rQdWOJrKutORqKO1uzrHXV86jFiQzfLFKx2s8S1CjLXL9fC5LOIvk0q7Z7PXJvsByt+sdoE1Q2a9gBr4gLQ==", "cpu": [ "x64" ], @@ -7353,6 +7377,13 @@ "license": "MIT", "optional": true }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -7478,9 +7509,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -7591,12 +7622,41 @@ "@types/node": "*" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.186.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz", + "integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.3", + "meshoptimizer": "~1.1.1" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -8045,9 +8105,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -8204,9 +8264,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -8460,16 +8520,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/app-builder-lib/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/app-builder-lib/node_modules/ci-info": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", @@ -8544,33 +8594,6 @@ "node": ">=10" } }, - "node_modules/app-builder-lib/node_modules/tar": { - "version": "7.5.22", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", - "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/app-builder-lib/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/aproba": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", @@ -8970,13 +8993,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.15", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", - "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/better-sqlite3": { @@ -9037,12 +9063,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/bluebird": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", - "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", - "license": "MIT" - }, "node_modules/bmp-js": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", @@ -9122,9 +9142,9 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -9133,9 +9153,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -9153,11 +9173,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -9421,9 +9441,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001764", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", - "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", "dev": true, "funding": [ { @@ -9543,13 +9563,13 @@ } }, "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "license": "ISC", - "optional": true, + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "devOptional": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/chromium-pickle-js": { @@ -10553,9 +10573,9 @@ } }, "node_modules/electron": { - "version": "39.2.7", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.2.7.tgz", - "integrity": "sha512-KU0uFS6LSTh4aOIC3miolcbizOFP7N1M46VTYVfqIgFiuA2ilfNaOHLDS9tCMvwwHRowAsvqBrh9NgMXcTOHCQ==", + "version": "39.8.10", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.10.tgz", + "integrity": "sha512-zbYtGPYUI7PzqLAzkk21Rk6j67WN0hxn0Mq/njErZo1d0HSf33is4f8ICI5fMLy5vYe0JtCtM5sYunNOaochSQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -10704,9 +10724,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "version": "1.5.451", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.451.tgz", + "integrity": "sha512-vUNKPrUzw8ByEMOxloq0a71iD/mPuPxVkbDm3G5YhnxTzOw/I6qAg2MUS0cYj/c267lWmw26yW4Vv7foxTxPLg==", "dev": true, "license": "ISC" }, @@ -11347,9 +11367,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -11441,9 +11461,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -11877,9 +11897,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -11928,6 +11948,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -12063,9 +12090,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -12518,9 +12545,9 @@ } }, "node_modules/get-windows/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "license": "MIT", "optional": true, "dependencies": { @@ -12880,9 +12907,9 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "devOptional": true, "license": "ISC", "dependencies": { @@ -13199,9 +13226,9 @@ } }, "node_modules/hono": { - "version": "4.12.26", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", - "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -13271,9 +13298,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "license": "BSD-2-Clause" }, "node_modules/http-errors": { @@ -13496,9 +13523,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", "license": "MIT", "engines": { "node": ">= 12" @@ -14291,9 +14318,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -14576,9 +14603,9 @@ } }, "node_modules/kdbxweb/node_modules/fflate": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", - "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.5.tgz", + "integrity": "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g==", "license": "MIT" }, "node_modules/keyv": { @@ -15154,19 +15181,17 @@ } }, "node_modules/mammoth": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", - "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.13.0.tgz", + "integrity": "sha512-2cawcYFcP7ISfXbBcL9lmVgTNWo88v/52ND1gnywHtVT/pX0//puPnf0iMPV9ZgmuvNOE+zkIhDgZ9M2c2dDDg==", "license": "BSD-2-Clause", "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", - "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", - "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, @@ -15557,6 +15582,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -16176,13 +16208,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -16202,16 +16234,16 @@ } }, "node_modules/minimatch/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/minimist": { @@ -16349,7 +16381,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "minipass": "^7.1.2" @@ -16358,19 +16390,6 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "optional": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -16447,9 +16466,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -16608,16 +16627,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/node-gyp/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/node-gyp/node_modules/isexe": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", @@ -16641,27 +16650,10 @@ "node": ">=10" } }, - "node_modules/node-gyp/node_modules/tar": { - "version": "7.5.22", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", - "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/node-gyp/node_modules/undici": { - "version": "6.28.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", - "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", "dev": true, "license": "MIT", "engines": { @@ -16684,16 +16676,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/node-gyp/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -16708,11 +16690,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nopt": { "version": "9.0.0", @@ -16918,6 +16903,98 @@ "wrappy": "1" } }, + "node_modules/onnxruntime-common": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.30.0.tgz", + "integrity": "sha512-7fdVWjAID1dVhH/G8qK3APARunV4VkBFoCQAP7qp4Wkab0mrorvmc+sqiT+mKXOzDqdjN5j+/Z9nb4gzNPWcyA==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.30.0.tgz", + "integrity": "sha512-twhs1C2C/BFkz1yc5OY0KIU2GUq6DURO7hD4bx5Q2Qy3nAMJwRXW8xU3NVczE29VA9lolLOYepoD8fjTGOfIqw==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "adm-zip": "^0.6.0", + "global-agent": "^4.1.3", + "onnxruntime-common": "1.30.0" + } + }, + "node_modules/onnxruntime-node/node_modules/global-agent": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz", + "integrity": "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==", + "license": "BSD-3-Clause", + "dependencies": { + "globalthis": "^1.0.2", + "matcher": "^4.0.0", + "semver": "^7.3.5", + "serialize-error": "^8.1.0" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/onnxruntime-node/node_modules/matcher": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz", + "integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onnxruntime-node/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/onnxruntime-node/node_modules/serialize-error": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onnxruntime-node/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/option": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", @@ -17241,6 +17318,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -17541,9 +17619,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "funding": [ { "type": "opencollective", @@ -17560,9 +17638,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.19", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -17830,9 +17908,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -17840,6 +17918,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/pump": { @@ -17892,12 +17974,13 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -18636,12 +18719,12 @@ } }, "node_modules/rollup": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", - "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "version": "4.64.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.2.tgz", + "integrity": "sha512-RxXm0XVVV1hFI3dYe12ruaOgsYQ725lUcUWeMSQqkF9tTxVErY+fy2Ets6vWhItL9MZMSNZGbvZFc7rH/VHp2Q==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -18651,31 +18734,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.1", - "@rollup/rollup-android-arm64": "4.55.1", - "@rollup/rollup-darwin-arm64": "4.55.1", - "@rollup/rollup-darwin-x64": "4.55.1", - "@rollup/rollup-freebsd-arm64": "4.55.1", - "@rollup/rollup-freebsd-x64": "4.55.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", - "@rollup/rollup-linux-arm-musleabihf": "4.55.1", - "@rollup/rollup-linux-arm64-gnu": "4.55.1", - "@rollup/rollup-linux-arm64-musl": "4.55.1", - "@rollup/rollup-linux-loong64-gnu": "4.55.1", - "@rollup/rollup-linux-loong64-musl": "4.55.1", - "@rollup/rollup-linux-ppc64-gnu": "4.55.1", - "@rollup/rollup-linux-ppc64-musl": "4.55.1", - "@rollup/rollup-linux-riscv64-gnu": "4.55.1", - "@rollup/rollup-linux-riscv64-musl": "4.55.1", - "@rollup/rollup-linux-s390x-gnu": "4.55.1", - "@rollup/rollup-linux-x64-gnu": "4.55.1", - "@rollup/rollup-linux-x64-musl": "4.55.1", - "@rollup/rollup-openbsd-x64": "4.55.1", - "@rollup/rollup-openharmony-arm64": "4.55.1", - "@rollup/rollup-win32-arm64-msvc": "4.55.1", - "@rollup/rollup-win32-ia32-msvc": "4.55.1", - "@rollup/rollup-win32-x64-gnu": "4.55.1", - "@rollup/rollup-win32-x64-msvc": "4.55.1", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.2", + "@rollup/rollup-android-arm64": "4.64.2", + "@rollup/rollup-darwin-arm64": "4.64.2", + "@rollup/rollup-darwin-x64": "4.64.2", + "@rollup/rollup-freebsd-arm64": "4.64.2", + "@rollup/rollup-freebsd-x64": "4.64.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.2", + "@rollup/rollup-linux-arm-musleabihf": "4.64.2", + "@rollup/rollup-linux-arm64-gnu": "4.64.2", + "@rollup/rollup-linux-arm64-musl": "4.64.2", + "@rollup/rollup-linux-loong64-gnu": "4.64.2", + "@rollup/rollup-linux-loong64-musl": "4.64.2", + "@rollup/rollup-linux-ppc64-gnu": "4.64.2", + "@rollup/rollup-linux-ppc64-musl": "4.64.2", + "@rollup/rollup-linux-riscv64-gnu": "4.64.2", + "@rollup/rollup-linux-riscv64-musl": "4.64.2", + "@rollup/rollup-linux-s390x-gnu": "4.64.2", + "@rollup/rollup-linux-x64-gnu": "4.64.2", + "@rollup/rollup-linux-x64-musl": "4.64.2", + "@rollup/rollup-openbsd-x64": "4.64.2", + "@rollup/rollup-openharmony-arm64": "4.64.2", + "@rollup/rollup-win32-arm64-msvc": "4.64.2", + "@rollup/rollup-win32-ia32-msvc": "4.64.2", + "@rollup/rollup-win32-x64-gnu": "4.64.2", + "@rollup/rollup-win32-x64-msvc": "4.64.2", "fsevents": "~2.3.2" } }, @@ -19010,9 +19094,9 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", @@ -19026,31 +19110,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -19092,14 +19176,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -19111,13 +19195,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -19267,9 +19351,9 @@ } }, "node_modules/smol-toml": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", - "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", + "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -19320,9 +19404,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -19763,21 +19847,20 @@ } }, "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "license": "ISC", - "optional": true, + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "devOptional": true, + "license": "BlueOak-1.0.0", "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/tar-fs": { @@ -19814,76 +19897,16 @@ "node": ">=6" } }, - "node_modules/tar/node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/minipass": { + "node_modules/tar/node_modules/yallist": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "license": "MIT", - "optional": true, - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/tar/node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "devOptional": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true - }, "node_modules/temp": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", @@ -19962,6 +19985,12 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/three": { + "version": "0.186.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.186.0.tgz", + "integrity": "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ==", + "license": "MIT" + }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", @@ -20483,9 +20512,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, "license": "MIT", "engines": { @@ -20663,9 +20692,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { @@ -20807,12 +20836,12 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -21706,9 +21735,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 57019709a..de706f920 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "desktopName": "off-grid-ai.desktop", "version": "0.0.54", "offgrid": { - "llamaRef": "b11056", - "prismLlamaRef": "prism-b10709-9a9394a" + "llamaRef": "b11532", + "prismLlamaRef": "prism-b10754-2459f68" }, "description": "Off Grid AI — a private, local-first AI that runs open models (text, vision, image, voice) entirely on your device. No cloud, no accounts.", "license": "AGPL-3.0-only", @@ -61,13 +61,15 @@ "kokoro-js": { "@huggingface/transformers": "$@huggingface/transformers" }, - "sharp": "^0.35.2" + "sharp": "^0.35.2", + "tar": "^7.5.22" }, "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", + "@huggingface/transformers": "4.3.0", "@lancedb/lancedb": "^0.30.0", "@langchain/core": "^1.2.9", "@langchain/langgraph": "^1.4.12", @@ -87,7 +89,6 @@ "@tabler/icons-react": "^3.36.1", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-virtual": "3.14.13", - "@huggingface/transformers": "4.3.0", "apache-arrow": "^18.1.0", "async-mutex": "^0.5.0", "better-sqlite3": "^12.6.2", @@ -108,6 +109,7 @@ "motion": "^12.27.1", "node-machine-id": "^1.1.12", "ollama": "^0.6.3", + "onnxruntime-node": "1.30.0", "pdf-parse": "^1.1.1", "qrcode.react": "^4.2.0", "radix-ui": "^1.6.0", @@ -118,6 +120,7 @@ "remark-parse": "^11.0.0", "sharp": "^0.35.2", "tailwind-merge": "^3.4.0", + "three": "^0.186.0", "tldts": "^7.4.7", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", @@ -125,6 +128,7 @@ "ws": "^8.18.3" }, "optionalDependencies": { + "@offgrid/operator-ui": "github:wednesday-solutions/component-library-animations#ca35d2f9004b212ea034b73109fcd2819a08816b", "@nut-tree-fork/nut-js": "^4.2.6" }, "devDependencies": { @@ -141,6 +145,7 @@ "@types/node": "^22.19.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/three": "^0.186.0", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^5.1.1", "@vitest/coverage-v8": "^4.1.10", diff --git a/pro b/pro index eb75a3709..d11e41ece 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit eb75a370980b06c8b30c95d5527debde542af8df +Subproject commit d11e41eceb8557dd591ae8650059c9d65f5c8bb5 diff --git a/resources/bin/llama-prism/libggml-base.0.21.0.dylib b/resources/bin/llama-prism/libggml-base.0.21.0.dylib index 15b7b70e4..d7bf47912 100755 --- a/resources/bin/llama-prism/libggml-base.0.21.0.dylib +++ b/resources/bin/llama-prism/libggml-base.0.21.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4d12bde3a58cfd157eaeb8519256e31913495f4d1f62a959eaa3b4c91b512d2f +oid sha256:ae705867f4ff607c7b8a9c8d5360ed899f1e5997acd5cdd0dac45b8d038b0ea7 size 749192 diff --git a/resources/bin/llama-prism/libggml-base.0.dylib b/resources/bin/llama-prism/libggml-base.0.dylib index 15b7b70e4..973538bd6 100755 --- a/resources/bin/llama-prism/libggml-base.0.dylib +++ b/resources/bin/llama-prism/libggml-base.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4d12bde3a58cfd157eaeb8519256e31913495f4d1f62a959eaa3b4c91b512d2f +oid sha256:9e1d12da8d8870b9900c834b9b4a2979249d4853aa6587a82503cd91a93042ec size 749192 diff --git a/resources/bin/llama-prism/libggml-base.dylib b/resources/bin/llama-prism/libggml-base.dylib index 15b7b70e4..e9d51643f 100755 --- a/resources/bin/llama-prism/libggml-base.dylib +++ b/resources/bin/llama-prism/libggml-base.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4d12bde3a58cfd157eaeb8519256e31913495f4d1f62a959eaa3b4c91b512d2f -size 749192 +oid sha256:b5af0cdf616be5d14572e0308c79b404484880f9861615f08dd89787b39774ba +size 749184 diff --git a/resources/bin/llama-prism/libggml-blas.0.21.0.dylib b/resources/bin/llama-prism/libggml-blas.0.21.0.dylib index 22a2a3cad..82a69198e 100755 --- a/resources/bin/llama-prism/libggml-blas.0.21.0.dylib +++ b/resources/bin/llama-prism/libggml-blas.0.21.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:de5b5c77fe5e95b3c4482dae03b604166e0bbe8048bd87de553ddd306f98bc8e +oid sha256:c7ea27ddfcbfb4b0f47dc9d477f569fefd97113a9bd71e39078f7b2ff1cd2505 size 58776 diff --git a/resources/bin/llama-prism/libggml-blas.0.dylib b/resources/bin/llama-prism/libggml-blas.0.dylib index 22a2a3cad..1a926e4ee 100755 --- a/resources/bin/llama-prism/libggml-blas.0.dylib +++ b/resources/bin/llama-prism/libggml-blas.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:de5b5c77fe5e95b3c4482dae03b604166e0bbe8048bd87de553ddd306f98bc8e +oid sha256:b30c1997e8e7f680bec69061b6553553dabbb4e707fc22a2443b386ffd05f623 size 58776 diff --git a/resources/bin/llama-prism/libggml-blas.dylib b/resources/bin/llama-prism/libggml-blas.dylib index 22a2a3cad..e74d7308f 100755 --- a/resources/bin/llama-prism/libggml-blas.dylib +++ b/resources/bin/llama-prism/libggml-blas.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:de5b5c77fe5e95b3c4482dae03b604166e0bbe8048bd87de553ddd306f98bc8e -size 58776 +oid sha256:058ccc9814f828514e4c9e31e2a47ee99b1cd5014da613fbe72f527c0295e960 +size 58768 diff --git a/resources/bin/llama-prism/libggml-cpu.0.21.0.dylib b/resources/bin/llama-prism/libggml-cpu.0.21.0.dylib index 6c85afd8e..e919b8c5d 100755 --- a/resources/bin/llama-prism/libggml-cpu.0.21.0.dylib +++ b/resources/bin/llama-prism/libggml-cpu.0.21.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:37f2327ca6692aae506c6d4f261b54f46b5ff4484d3e5ea35940ab003c50bf1d -size 974240 +oid sha256:6ca8cedf6ea0ca5a41d3213e86c4e5a229fd10f35f708dae41687ccb6f834653 +size 990984 diff --git a/resources/bin/llama-prism/libggml-cpu.0.dylib b/resources/bin/llama-prism/libggml-cpu.0.dylib index 6c85afd8e..d051f1da9 100755 --- a/resources/bin/llama-prism/libggml-cpu.0.dylib +++ b/resources/bin/llama-prism/libggml-cpu.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:37f2327ca6692aae506c6d4f261b54f46b5ff4484d3e5ea35940ab003c50bf1d -size 974240 +oid sha256:2a520cf2cd02a356759cccb85ad32b7a2ad42024266cbca48439f74fbb06413a +size 990976 diff --git a/resources/bin/llama-prism/libggml-cpu.dylib b/resources/bin/llama-prism/libggml-cpu.dylib index 6c85afd8e..5dd4045eb 100755 --- a/resources/bin/llama-prism/libggml-cpu.dylib +++ b/resources/bin/llama-prism/libggml-cpu.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:37f2327ca6692aae506c6d4f261b54f46b5ff4484d3e5ea35940ab003c50bf1d -size 974240 +oid sha256:b71c47dcabb954496c63975c2a74da1d3a286450262c3e911e86db437d7682ed +size 990976 diff --git a/resources/bin/llama-prism/libggml-metal.0.21.0.dylib b/resources/bin/llama-prism/libggml-metal.0.21.0.dylib index 074cdaac9..d937d359d 100755 --- a/resources/bin/llama-prism/libggml-metal.0.21.0.dylib +++ b/resources/bin/llama-prism/libggml-metal.0.21.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c5052b432e8892b22bf1b15608c4da099464b47cf93f2f2982760167d3532d4 -size 2162904 +oid sha256:0955c0cc05f30d81ed8d7bd30e599a485b51ab96a1df55bc996abad19882ce76 +size 2180392 diff --git a/resources/bin/llama-prism/libggml-metal.0.dylib b/resources/bin/llama-prism/libggml-metal.0.dylib index 074cdaac9..d1fca497d 100755 --- a/resources/bin/llama-prism/libggml-metal.0.dylib +++ b/resources/bin/llama-prism/libggml-metal.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c5052b432e8892b22bf1b15608c4da099464b47cf93f2f2982760167d3532d4 -size 2162904 +oid sha256:86c15d2b5f4f231669be1eba38812be7181841ad323bfc93152f200e7c9b12cb +size 2180392 diff --git a/resources/bin/llama-prism/libggml-metal.dylib b/resources/bin/llama-prism/libggml-metal.dylib index 074cdaac9..f628b6f98 100755 --- a/resources/bin/llama-prism/libggml-metal.dylib +++ b/resources/bin/llama-prism/libggml-metal.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c5052b432e8892b22bf1b15608c4da099464b47cf93f2f2982760167d3532d4 -size 2162904 +oid sha256:55605545962a20fc964fdd616da1c8b7ecc06ebd1d6666c8f6da45061308d940 +size 2180384 diff --git a/resources/bin/llama-prism/libggml.0.21.0.dylib b/resources/bin/llama-prism/libggml.0.21.0.dylib index 37864a66a..53c0d6aad 100755 --- a/resources/bin/llama-prism/libggml.0.21.0.dylib +++ b/resources/bin/llama-prism/libggml.0.21.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cf93c8bcfe8bc25f0372535cda6883cf664f933c57dd7fa582442fa5b586a8ee -size 59808 +oid sha256:90d9211b3c24e605c9ad3315c381ebfcddf5f4dcd2b07cdf793f0ca89d8e00dd +size 59816 diff --git a/resources/bin/llama-prism/libggml.0.dylib b/resources/bin/llama-prism/libggml.0.dylib index 37864a66a..c20763a3e 100755 --- a/resources/bin/llama-prism/libggml.0.dylib +++ b/resources/bin/llama-prism/libggml.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cf93c8bcfe8bc25f0372535cda6883cf664f933c57dd7fa582442fa5b586a8ee +oid sha256:58727ece3ce41a5c4ca48e483ceaac5d8fd12d20e0dc6c0ea92bdd05eead2d2c size 59808 diff --git a/resources/bin/llama-prism/libggml.dylib b/resources/bin/llama-prism/libggml.dylib index 37864a66a..8df03635b 100755 --- a/resources/bin/llama-prism/libggml.dylib +++ b/resources/bin/llama-prism/libggml.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cf93c8bcfe8bc25f0372535cda6883cf664f933c57dd7fa582442fa5b586a8ee +oid sha256:eddedf7f0e171379b154f1f8936df25ff52ee6c0fe1a194228d660cab88ae805 size 59808 diff --git a/resources/bin/llama-prism/libllama-common.0.2.0.dylib b/resources/bin/llama-prism/libllama-common.0.2.0.dylib index 1ca8c1f83..998dd9ef3 100755 --- a/resources/bin/llama-prism/libllama-common.0.2.0.dylib +++ b/resources/bin/llama-prism/libllama-common.0.2.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a9309528bd37638f8e542b60ab80e6532d4a081f78320c059c5ce5513aaad199 -size 4968392 +oid sha256:c428f113a26fd332e7464ecdfbc5e8540f0cc5df46b538205300dd384ab534a1 +size 4989688 diff --git a/resources/bin/llama-prism/libllama-common.0.dylib b/resources/bin/llama-prism/libllama-common.0.dylib index 1ca8c1f83..ea444e3d7 100755 --- a/resources/bin/llama-prism/libllama-common.0.dylib +++ b/resources/bin/llama-prism/libllama-common.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a9309528bd37638f8e542b60ab80e6532d4a081f78320c059c5ce5513aaad199 -size 4968392 +oid sha256:f7415b58bf3fb23a7a35953d1e2a24a47f6874f16e83bbebdd2363d2d21149a1 +size 4989688 diff --git a/resources/bin/llama-prism/libllama-common.dylib b/resources/bin/llama-prism/libllama-common.dylib index 1ca8c1f83..f96b276db 100755 --- a/resources/bin/llama-prism/libllama-common.dylib +++ b/resources/bin/llama-prism/libllama-common.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a9309528bd37638f8e542b60ab80e6532d4a081f78320c059c5ce5513aaad199 -size 4968392 +oid sha256:33985db4882ddc990f22239abd8cc215e0bdc347c3e3ecd591f8924a44c765ea +size 4989688 diff --git a/resources/bin/llama-prism/libllama-server-impl.dylib b/resources/bin/llama-prism/libllama-server-impl.dylib index 333ba73c8..f189d489e 100755 --- a/resources/bin/llama-prism/libllama-server-impl.dylib +++ b/resources/bin/llama-prism/libllama-server-impl.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:86b44c99e2159e166b176ec1570ef39638958923f2d7fde76c33001efa3d2124 -size 3120760 +oid sha256:e45b014487d033fb0184ab0810b3e69826229cbdde451e4744373d2523b04771 +size 3121208 diff --git a/resources/bin/llama-prism/libllama.0.2.0.dylib b/resources/bin/llama-prism/libllama.0.2.0.dylib index ddda6df5b..30894b570 100755 --- a/resources/bin/llama-prism/libllama.0.2.0.dylib +++ b/resources/bin/llama-prism/libllama.0.2.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f1767f213141496f6883d113b17e88e4473712b4c1aaf19958c69654f4285780 -size 3078544 +oid sha256:358304f08b613f35ac4fd29d7bd9d586c1f75df7f4763616efca01744eeb97f2 +size 3099032 diff --git a/resources/bin/llama-prism/libllama.0.dylib b/resources/bin/llama-prism/libllama.0.dylib index ddda6df5b..05a051910 100755 --- a/resources/bin/llama-prism/libllama.0.dylib +++ b/resources/bin/llama-prism/libllama.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f1767f213141496f6883d113b17e88e4473712b4c1aaf19958c69654f4285780 -size 3078544 +oid sha256:832f914c0f3c6cc04edc97c3d5eeba6b6ab3dd28c2e1894580a4b92dec466edb +size 3099024 diff --git a/resources/bin/llama-prism/libllama.dylib b/resources/bin/llama-prism/libllama.dylib index ddda6df5b..a5d05c47e 100755 --- a/resources/bin/llama-prism/libllama.dylib +++ b/resources/bin/llama-prism/libllama.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f1767f213141496f6883d113b17e88e4473712b4c1aaf19958c69654f4285780 -size 3078544 +oid sha256:59c3e479aa68372d037cf92601d2621ba3eb40f08f77359bb736388ee6f19efc +size 3099024 diff --git a/resources/bin/llama-prism/libmtmd.0.2.0.dylib b/resources/bin/llama-prism/libmtmd.0.2.0.dylib index e4d22b33c..6a8084cea 100755 --- a/resources/bin/llama-prism/libmtmd.0.2.0.dylib +++ b/resources/bin/llama-prism/libmtmd.0.2.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd17061b26e1d5534b70fa49df09742e3f62c18eb1c11949b930b22c52b5116f +oid sha256:a4208b7d6c292cfd5e2103347fd112f93bc92bbf8606399278e415878abbe889 size 1357712 diff --git a/resources/bin/llama-prism/libmtmd.0.dylib b/resources/bin/llama-prism/libmtmd.0.dylib index e4d22b33c..0a6a2065f 100755 --- a/resources/bin/llama-prism/libmtmd.0.dylib +++ b/resources/bin/llama-prism/libmtmd.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd17061b26e1d5534b70fa49df09742e3f62c18eb1c11949b930b22c52b5116f +oid sha256:d34073393bdc4739fa752089c6ff26161881b32c1a7f3aef81b96a351d9b5957 size 1357712 diff --git a/resources/bin/llama-prism/libmtmd.dylib b/resources/bin/llama-prism/libmtmd.dylib index e4d22b33c..bd53da4a5 100755 --- a/resources/bin/llama-prism/libmtmd.dylib +++ b/resources/bin/llama-prism/libmtmd.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd17061b26e1d5534b70fa49df09742e3f62c18eb1c11949b930b22c52b5116f +oid sha256:75c127578c81f98ce0c7b4c0e6916425e564755f3bf8b9b494d778fad03828e6 size 1357712 diff --git a/resources/bin/llama-prism/llama-server b/resources/bin/llama-prism/llama-server index abd29ff95..a2e5f2458 100755 --- a/resources/bin/llama-prism/llama-server +++ b/resources/bin/llama-prism/llama-server @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:04ceb025e45bd01f2783a5fe6dbe085bdb40a6d04e1cafa9091815f609ba40e3 +oid sha256:06e907178f7f56bcfaea1731b9588ac1d2185f6766fac7008aac8dd6628702e4 size 33472 diff --git a/resources/bin/llama/libggml-base.0.24.0.dylib b/resources/bin/llama/libggml-base.0.24.0.dylib deleted file mode 100755 index 402761f73..000000000 --- a/resources/bin/llama/libggml-base.0.24.0.dylib +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:74439497cd096fe6dbed3d464c51bb28faaf1d2a7011fe6a136d8ccd19447a7a -size 733208 diff --git a/resources/bin/llama/libggml-base.0.26.0.dylib b/resources/bin/llama/libggml-base.0.26.0.dylib new file mode 100755 index 000000000..331a4220d --- /dev/null +++ b/resources/bin/llama/libggml-base.0.26.0.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b0166609a620e2728b6f3990d56a530e88e743a8f48b2c0669a94ece719386fc +size 751368 diff --git a/resources/bin/llama/libggml-base.0.dylib b/resources/bin/llama/libggml-base.0.dylib index 402761f73..5a8c3ee9b 100755 --- a/resources/bin/llama/libggml-base.0.dylib +++ b/resources/bin/llama/libggml-base.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:74439497cd096fe6dbed3d464c51bb28faaf1d2a7011fe6a136d8ccd19447a7a -size 733208 +oid sha256:da18afb5d8dbf60956624cfc0c7051ed0858be4a71fe7428d55685f7c6403d30 +size 751368 diff --git a/resources/bin/llama/libggml-base.dylib b/resources/bin/llama/libggml-base.dylib index 402761f73..402815a4c 100755 --- a/resources/bin/llama/libggml-base.dylib +++ b/resources/bin/llama/libggml-base.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:74439497cd096fe6dbed3d464c51bb28faaf1d2a7011fe6a136d8ccd19447a7a -size 733208 +oid sha256:7fb6799e26a154fc8d6a6be02857fec94fdaa857453616c0abfb8e5e1f483e26 +size 751360 diff --git a/resources/bin/llama/libggml-blas.0.24.0.dylib b/resources/bin/llama/libggml-blas.0.24.0.dylib deleted file mode 100755 index f4df75455..000000000 --- a/resources/bin/llama/libggml-blas.0.24.0.dylib +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:25410d528fdea2fe76f68864b715650fb3e12f27c12a77a39eb63f8ee7382687 -size 58776 diff --git a/resources/bin/llama/libggml-blas.0.26.0.dylib b/resources/bin/llama/libggml-blas.0.26.0.dylib new file mode 100755 index 000000000..dcebc0ad0 --- /dev/null +++ b/resources/bin/llama/libggml-blas.0.26.0.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:299e23c6302e773e0634947b6cacc009d6e8adb4dae98c232504f09318a886d8 +size 58776 diff --git a/resources/bin/llama/libggml-blas.0.dylib b/resources/bin/llama/libggml-blas.0.dylib index f4df75455..277cdd4a8 100755 --- a/resources/bin/llama/libggml-blas.0.dylib +++ b/resources/bin/llama/libggml-blas.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:25410d528fdea2fe76f68864b715650fb3e12f27c12a77a39eb63f8ee7382687 +oid sha256:38c3c342d75d9f4911bb5d2c9c046f345f02f000dc45264f707ef318b663cdd0 size 58776 diff --git a/resources/bin/llama/libggml-blas.dylib b/resources/bin/llama/libggml-blas.dylib index f4df75455..b5e540e3b 100755 --- a/resources/bin/llama/libggml-blas.dylib +++ b/resources/bin/llama/libggml-blas.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:25410d528fdea2fe76f68864b715650fb3e12f27c12a77a39eb63f8ee7382687 -size 58776 +oid sha256:3300654860acaa52d17b260f8da756a26a6c759251bda95417684de38429e010 +size 58768 diff --git a/resources/bin/llama/libggml-cpu.0.24.0.dylib b/resources/bin/llama/libggml-cpu.0.24.0.dylib deleted file mode 100755 index ba7fba704..000000000 --- a/resources/bin/llama/libggml-cpu.0.24.0.dylib +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8d13665cd0d51f4a2406edcaa1ff595bec7bfe3fc96fddbf62c365d8335e4253 -size 1001776 diff --git a/resources/bin/llama/libggml-cpu.0.26.0.dylib b/resources/bin/llama/libggml-cpu.0.26.0.dylib new file mode 100755 index 000000000..43c50c259 --- /dev/null +++ b/resources/bin/llama/libggml-cpu.0.26.0.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c0e74cadfe728b84c90cb7a46cf05f6b99f191b69e502cd683a1cd636c6641c9 +size 1006952 diff --git a/resources/bin/llama/libggml-cpu.0.dylib b/resources/bin/llama/libggml-cpu.0.dylib index ba7fba704..35147483c 100755 --- a/resources/bin/llama/libggml-cpu.0.dylib +++ b/resources/bin/llama/libggml-cpu.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8d13665cd0d51f4a2406edcaa1ff595bec7bfe3fc96fddbf62c365d8335e4253 -size 1001776 +oid sha256:fd214595b28437f9a4fb64ede638cf11c7a56944d23198fae86e01435013c1a4 +size 1006944 diff --git a/resources/bin/llama/libggml-cpu.dylib b/resources/bin/llama/libggml-cpu.dylib index ba7fba704..3f91508b8 100755 --- a/resources/bin/llama/libggml-cpu.dylib +++ b/resources/bin/llama/libggml-cpu.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8d13665cd0d51f4a2406edcaa1ff595bec7bfe3fc96fddbf62c365d8335e4253 -size 1001776 +oid sha256:e337356203d17b281f840b181b40bb8f64dcd3a621e45b90cf99f73024fde4fa +size 1006944 diff --git a/resources/bin/llama/libggml-metal.0.24.0.dylib b/resources/bin/llama/libggml-metal.0.24.0.dylib deleted file mode 100755 index aed1b284b..000000000 --- a/resources/bin/llama/libggml-metal.0.24.0.dylib +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5e044dc2d8046ae9996c09ee61738ae6e1afd55e6bce7e2d0e69cab01d9173fa -size 2215992 diff --git a/resources/bin/llama/libggml-metal.0.26.0.dylib b/resources/bin/llama/libggml-metal.0.26.0.dylib new file mode 100755 index 000000000..94d2dbfa6 --- /dev/null +++ b/resources/bin/llama/libggml-metal.0.26.0.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:854e09e8ee5a88b7d0180c53befbfbbb4cde710e34e1f6598677e0e8cb557ad3 +size 5554552 diff --git a/resources/bin/llama/libggml-metal.0.dylib b/resources/bin/llama/libggml-metal.0.dylib index aed1b284b..b2f5fa65d 100755 --- a/resources/bin/llama/libggml-metal.0.dylib +++ b/resources/bin/llama/libggml-metal.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e044dc2d8046ae9996c09ee61738ae6e1afd55e6bce7e2d0e69cab01d9173fa -size 2215992 +oid sha256:06b16efb78b2de9ce7838228fc413fbda6620cd5351d83979c650b136e661975 +size 5554552 diff --git a/resources/bin/llama/libggml-metal.dylib b/resources/bin/llama/libggml-metal.dylib index aed1b284b..134b628ab 100755 --- a/resources/bin/llama/libggml-metal.dylib +++ b/resources/bin/llama/libggml-metal.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e044dc2d8046ae9996c09ee61738ae6e1afd55e6bce7e2d0e69cab01d9173fa -size 2215992 +oid sha256:fd49bf94f808ea08d96f09a4782759c8d16dc235015067b8f8a988679a97e77f +size 5554544 diff --git a/resources/bin/llama/libggml.0.24.0.dylib b/resources/bin/llama/libggml.0.24.0.dylib deleted file mode 100755 index 903254715..000000000 --- a/resources/bin/llama/libggml.0.24.0.dylib +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c49b55a2a4da05f441731d78f58995b0a5dcdaa6f5c2939196bea681030e9197 -size 60064 diff --git a/resources/bin/llama/libggml.0.26.0.dylib b/resources/bin/llama/libggml.0.26.0.dylib new file mode 100755 index 000000000..f5141d01c --- /dev/null +++ b/resources/bin/llama/libggml.0.26.0.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2d1c97a4b277df5052be3b0f01ce761ff8db3eb4e839aceddeebe770a347441 +size 60072 diff --git a/resources/bin/llama/libggml.0.dylib b/resources/bin/llama/libggml.0.dylib index 903254715..da75afb6b 100755 --- a/resources/bin/llama/libggml.0.dylib +++ b/resources/bin/llama/libggml.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c49b55a2a4da05f441731d78f58995b0a5dcdaa6f5c2939196bea681030e9197 +oid sha256:fdd64031854e0db075664b6d8639fb1e724d38cdc18814a9495273544d0fa19b size 60064 diff --git a/resources/bin/llama/libggml.dylib b/resources/bin/llama/libggml.dylib index 903254715..bdbab3ec8 100755 --- a/resources/bin/llama/libggml.dylib +++ b/resources/bin/llama/libggml.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c49b55a2a4da05f441731d78f58995b0a5dcdaa6f5c2939196bea681030e9197 +oid sha256:c75af991bc83bd6b4e9cf2ddf5077884ddf0326654843e45303addf264dcea1d size 60064 diff --git a/resources/bin/llama/libllama-common.0.4.1.dylib b/resources/bin/llama/libllama-common.0.4.1.dylib deleted file mode 100755 index 56229ee2f..000000000 --- a/resources/bin/llama/libllama-common.0.4.1.dylib +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f56f4a7ce265af477cd1e4aaad74418e145e0e7923a2001e1ea6f62dba921c57 -size 4948792 diff --git a/resources/bin/llama/libllama-common.0.6.0.dylib b/resources/bin/llama/libllama-common.0.6.0.dylib new file mode 100755 index 000000000..c48c5eed0 --- /dev/null +++ b/resources/bin/llama/libllama-common.0.6.0.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a24e91548fa63a9f554f627e77f979abbf0f37bb49217dadd073645bb4442141 +size 5087144 diff --git a/resources/bin/llama/libllama-common.0.dylib b/resources/bin/llama/libllama-common.0.dylib index 56229ee2f..315da4f87 100755 --- a/resources/bin/llama/libllama-common.0.dylib +++ b/resources/bin/llama/libllama-common.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f56f4a7ce265af477cd1e4aaad74418e145e0e7923a2001e1ea6f62dba921c57 -size 4948792 +oid sha256:10da4edcc4ffdf979a813152ddbd6c0ec9d488e2b54ef023c2e7f2206ccdf280 +size 5087144 diff --git a/resources/bin/llama/libllama-common.dylib b/resources/bin/llama/libllama-common.dylib index 56229ee2f..a12a4674a 100755 --- a/resources/bin/llama/libllama-common.dylib +++ b/resources/bin/llama/libllama-common.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f56f4a7ce265af477cd1e4aaad74418e145e0e7923a2001e1ea6f62dba921c57 -size 4948792 +oid sha256:5e6014c58614a8529fd66b1ede6f66432b97d350aecdcc7a6d8ac0e4c26e47f2 +size 5087144 diff --git a/resources/bin/llama/libllama-server-impl.dylib b/resources/bin/llama/libllama-server-impl.dylib index 62563d082..c0a307cca 100755 --- a/resources/bin/llama/libllama-server-impl.dylib +++ b/resources/bin/llama/libllama-server-impl.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb27318e1438d366ff34c2ccc66a93500e9a75e889b1d899a59d0a4400032a7b -size 3158312 +oid sha256:bb8d3c42d5ebe83d0c218deeefcf00118cd8675fc4c7d94bcfcfe8c27c95c335 +size 3342632 diff --git a/resources/bin/llama/libllama.0.4.1.dylib b/resources/bin/llama/libllama.0.4.1.dylib deleted file mode 100755 index 8112b73ea..000000000 --- a/resources/bin/llama/libllama.0.4.1.dylib +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:73e0213ddf739686a32dd4f2a431ea6c37a999efa54583bc9d13d1343eb409d4 -size 3132880 diff --git a/resources/bin/llama/libllama.0.6.0.dylib b/resources/bin/llama/libllama.0.6.0.dylib new file mode 100755 index 000000000..b275f7ac1 --- /dev/null +++ b/resources/bin/llama/libllama.0.6.0.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d1e9b59cf5dee19a4b3dbf084903b72bb801195a14f8ed3aa0d0e4cda283390 +size 3339880 diff --git a/resources/bin/llama/libllama.0.dylib b/resources/bin/llama/libllama.0.dylib index 8112b73ea..c124d22f1 100755 --- a/resources/bin/llama/libllama.0.dylib +++ b/resources/bin/llama/libllama.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:73e0213ddf739686a32dd4f2a431ea6c37a999efa54583bc9d13d1343eb409d4 -size 3132880 +oid sha256:63442da46d3566e6dfffc9bda94ec8d98b73308949db909c45ed8dd3dbf40887 +size 3339872 diff --git a/resources/bin/llama/libllama.dylib b/resources/bin/llama/libllama.dylib index 8112b73ea..0212cf2d9 100755 --- a/resources/bin/llama/libllama.dylib +++ b/resources/bin/llama/libllama.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:73e0213ddf739686a32dd4f2a431ea6c37a999efa54583bc9d13d1343eb409d4 -size 3132880 +oid sha256:9366a93d5250bbeb8db0acfe90f28bce915affc3cd39bc40dbfe95b587d6b81a +size 3339872 diff --git a/resources/bin/llama/libmtmd.0.4.1.dylib b/resources/bin/llama/libmtmd.0.4.1.dylib deleted file mode 100755 index cd6948ad1..000000000 --- a/resources/bin/llama/libmtmd.0.4.1.dylib +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ef96cecdc405ed1cbbd6bbf3b24309b7f236ff5a3e5284febcdcb0bd1fa48b2c -size 1378704 diff --git a/resources/bin/llama/libmtmd.0.6.0.dylib b/resources/bin/llama/libmtmd.0.6.0.dylib new file mode 100755 index 000000000..0b3add7de --- /dev/null +++ b/resources/bin/llama/libmtmd.0.6.0.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb08277ac37db7abbdbdb54b2ce4dbb97ccbe4580924982a63efdc22b9440ae9 +size 1400272 diff --git a/resources/bin/llama/libmtmd.0.dylib b/resources/bin/llama/libmtmd.0.dylib index cd6948ad1..15e56e3db 100755 --- a/resources/bin/llama/libmtmd.0.dylib +++ b/resources/bin/llama/libmtmd.0.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ef96cecdc405ed1cbbd6bbf3b24309b7f236ff5a3e5284febcdcb0bd1fa48b2c -size 1378704 +oid sha256:dd9955f37319798221d28067503d184f2cb084fd268fc5af725194d509a33f08 +size 1400272 diff --git a/resources/bin/llama/libmtmd.dylib b/resources/bin/llama/libmtmd.dylib index cd6948ad1..2eed4ffc4 100755 --- a/resources/bin/llama/libmtmd.dylib +++ b/resources/bin/llama/libmtmd.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ef96cecdc405ed1cbbd6bbf3b24309b7f236ff5a3e5284febcdcb0bd1fa48b2c -size 1378704 +oid sha256:f067ca30d7da60e184809d6e44eec3bcd3b538b973a1b1694e3120b2020f5df7 +size 1400272 diff --git a/resources/bin/llama/llama-server b/resources/bin/llama/llama-server index 75db5908f..3eb15dc2c 100755 --- a/resources/bin/llama/llama-server +++ b/resources/bin/llama/llama-server @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:567cee841072a58fbd44e055dc11923b6769d6740483d7fd88d0dbf15e22e44b +oid sha256:e2001902f36f481338c33d3dbdd3433a2d21e4011b50a5b721978a96e753bc7a size 33472 diff --git a/resources/demo/Acme_rollout_v3.pdf b/resources/demo/Acme_rollout_v3.pdf new file mode 100644 index 000000000..5e1b86ebf Binary files /dev/null and b/resources/demo/Acme_rollout_v3.pdf differ diff --git a/resources/demo/acme-reminder.png b/resources/demo/acme-reminder.png new file mode 100644 index 000000000..80a5b02a5 Binary files /dev/null and b/resources/demo/acme-reminder.png differ diff --git a/scripts/build-llama-linux.sh b/scripts/build-llama-linux.sh index 671b8b766..722605ed0 100644 --- a/scripts/build-llama-linux.sh +++ b/scripts/build-llama-linux.sh @@ -20,14 +20,14 @@ case "$VARIANT" in REF="${LLAMA_REF:-$(node -p "require('$ROOT/package.json').offgrid.llamaRef")}" REPO=ggml-org/llama.cpp DIR=llama - if [ "$REF" != b11056 ]; then + if [ "$REF" != b11532 ]; then echo '[build-llama-linux] update the pinned standard archive hashes for the new ref' >&2 exit 1 fi case "$ACCELERATOR" in - cuda) SHA256=bc44772a8186efa778d8ed8ccf74964d42afd04d5b032f6f3a9076820cd2de70 ;; - vulkan) SHA256=9edceb8555da0bb24fe4f7fa531954461a17c3ac32a674d96ecaf0a6321305c8 ;; - cpu) SHA256=74936888e975064d07e352745237508d4bef08d4b51843b01d011d58a4c4601a ;; + cuda) SHA256=7fb7cc87eba8ffedebc0fbe8acf0bacc628a9f6596260f7fa2aa28b387222e3b ;; + vulkan) SHA256=dfd641b1c69e0e9d4c232928f62dcfc2d681d62cf553ad7891468369383a1d81 ;; + cpu) SHA256=a3190cc22e1a0bab319c5eef1cedc8e346cfa690e173725ba97f92e4a150c4e2 ;; *) echo "[build-llama-linux] unknown accelerator: $ACCELERATOR" >&2; exit 1 ;; esac ;; @@ -35,14 +35,14 @@ case "$VARIANT" in REF="${PRISM_LLAMA_REF:-$(node -p "require('$ROOT/package.json').offgrid.prismLlamaRef")}" REPO=PrismML-Eng/llama.cpp DIR=llama-prism - if [ "$REF" != prism-b10709-9a9394a ]; then + if [ "$REF" != prism-b10754-2459f68 ]; then echo '[build-llama-linux] update the pinned Prism archive hashes for the new ref' >&2 exit 1 fi case "$ACCELERATOR" in - cuda) SHA256=8aec67eb023b251712c7e6490f367b5671bf587eced1436a9b85f4a90c3b7d3d ;; - vulkan) SHA256=4d7f858539d0207cf64e90beb83fcb7e076580d52856580f223cbecdd3ef6d03 ;; - cpu) SHA256=48b487f00fd2b27bc3ef77c701b43c1c23a4af484d2a203ae87d0efc41506728 ;; + cuda) SHA256=f2c74401fc2425659588b4b52868c6d0dad5654b5ba2549ca34ef852312db9af ;; + vulkan) SHA256=32f871a8a2aae73c813255f0b72b64cc5e5aa6f7f6b764f173ce7332bd28c44d ;; + cpu) SHA256=348aeed3ef5ad801d460c9d41c2a18125438f8e5f058dfda5d0602f919ad098d ;; *) echo "[build-llama-linux] unknown accelerator: $ACCELERATOR" >&2; exit 1 ;; esac ;; diff --git a/scripts/config/vitest-asset-plugin.ts b/scripts/config/vitest-asset-plugin.ts new file mode 100644 index 000000000..f58a3f4cc --- /dev/null +++ b/scripts/config/vitest-asset-plugin.ts @@ -0,0 +1,21 @@ +// `import file from './x.onnx?asset'` gives the file's path in the app (electron-vite). Tests get +// the same: the absolute path of the file on disk, so code that reads it works unchanged. + +import { dirname, resolve } from 'node:path' +import type { Plugin } from 'vite' + +const PREFIX = '\0asset:' + +export function assetPathPlugin(): Plugin { + return { + name: 'offgrid-test-asset-path', + enforce: 'pre', + resolveId(id, importer) { + if (!id.endsWith('?asset') || !importer) return null + return PREFIX + resolve(dirname(importer), id.slice(0, -'?asset'.length)) + }, + load(id) { + return id.startsWith(PREFIX) ? `export default ${JSON.stringify(id.slice(PREFIX.length))}` : null + } + } +} diff --git a/scripts/fetch-win-binaries.ps1 b/scripts/fetch-win-binaries.ps1 index bb667f124..19e8730f1 100644 --- a/scripts/fetch-win-binaries.ps1 +++ b/scripts/fetch-win-binaries.ps1 @@ -102,10 +102,10 @@ $PackageJson = Join-Path (Split-Path $PSScriptRoot -Parent) 'package.json' $DefaultLlamaRef = (Get-Content $PackageJson -Raw | ConvertFrom-Json).offgrid.llamaRef $LlamaRef = if ($env:LLAMA_REF) { $env:LLAMA_REF } else { $DefaultLlamaRef } $LlamaHashes = @{ - cuda = 'cb6e838cad17e9920b99ab8496ca9aa7cdc3d3c218128957179bb1fbe0772c4c' + cuda = '520261241eacec1a3cc3b0ba2d9d731f969b7ab37891c80db8acf79559af8655' cudart = '8c79a9b226de4b3cacfd1f83d24f962d0773be79f1e7b75c6af4ded7e32ae1d6' - vulkan = 'b7b5ef4a1f47542635a3a5e3e471cbfcbaee057aa0c962f9573329ddd9168c5a' - cpu = 'a2668a200ca7271e66af0a54fd4376aaf8ae0b2a562cf7a63c41d8fd2a8245fa' + vulkan = '33c3aed08c51abe576f741383796c012fb9cf06af40899ef2f5894d24463f18c' + cpu = 'db18cfa250eb692158eaf8fedb1dc5be0a9d280e87144b67465c774b664c5438' } if ($LlamaRef -ne $DefaultLlamaRef) { $overrideHashes = @{ @@ -139,12 +139,12 @@ Copy-Runtime $x 'llama-cpu' | Out-Null $PrismLlamaRef = (Get-Content $PackageJson -Raw | ConvertFrom-Json).offgrid.prismLlamaRef Write-Host "== Prism llama.cpp (pinned $PrismLlamaRef): CUDA + Vulkan + CPU ==" if ($IncludeCuda) { - $x = Expand-Asset 'PrismML-Eng/llama.cpp' '^llama-.+-bin-win-cuda-12\.4-x64\.zip$' $PrismLlamaRef 'f565c8428c1f108311f65ed97f02425188b3aa3c745c2bc597521bbd24bcbbc9' + $x = Expand-Asset 'PrismML-Eng/llama.cpp' '^llama-.+-bin-win-cuda-12\.4-x64\.zip$' $PrismLlamaRef '200842b4c689fe092a8a1684ef1e185d8d025a0bfae6908737607a0f29560b96' Copy-Runtime $x 'llama-prism-cuda' | Out-Null } -$x = Expand-Asset 'PrismML-Eng/llama.cpp' 'bin-win-vulkan-x64\.zip$' $PrismLlamaRef 'fabef609b588cbbed85b5f10b45809c46088f0a63caca7976054034e24b40836' +$x = Expand-Asset 'PrismML-Eng/llama.cpp' 'bin-win-vulkan-x64\.zip$' $PrismLlamaRef '790c979ca73f8b57c5a89ae782e952d8641e64280df4a11664198245f81e9df7' Copy-Runtime $x 'llama-prism' | Out-Null -$x = Expand-Asset 'PrismML-Eng/llama.cpp' 'bin-win-cpu-x64\.zip$' $PrismLlamaRef '92cd4d1cee11107593ff87d77eb57b02d804c86dd4b13224e18ba963a4271ad8' +$x = Expand-Asset 'PrismML-Eng/llama.cpp' 'bin-win-cpu-x64\.zip$' $PrismLlamaRef '509f3829371570dfd4a651a3583532515629fc835c4d050f14740df4ac4c7df2' Copy-Runtime $x 'llama-prism-cpu' | Out-Null # --- whisper.cpp (whisper-cli.exe + DLLs): CUDA GPU + CPU fallback ----------- diff --git a/scripts/hooks/post-checkout b/scripts/hooks/post-checkout new file mode 100755 index 000000000..5abf8ed93 --- /dev/null +++ b/scripts/hooks/post-checkout @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-checkout "$@" diff --git a/scripts/hooks/post-commit b/scripts/hooks/post-commit new file mode 100755 index 000000000..b8b76c2c4 --- /dev/null +++ b/scripts/hooks/post-commit @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-commit "$@" diff --git a/scripts/hooks/post-merge b/scripts/hooks/post-merge new file mode 100755 index 000000000..726f90989 --- /dev/null +++ b/scripts/hooks/post-merge @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-merge "$@" diff --git a/scripts/oauth-build-config.mjs b/scripts/oauth-build-config.mjs new file mode 100644 index 000000000..b527679ca --- /dev/null +++ b/scripts/oauth-build-config.mjs @@ -0,0 +1,70 @@ +// Which app registrations a build carries for "Connect with Off Grid AI" (Google, Microsoft). +// +// They are application identities, not user data: Google's Desktop client and Microsoft's public +// client are meant to ship inside installed apps. They are still kept out of Git (Google's +// credential pair) and read at build time from, in order: +// 1. the environment (CI passes repository secrets), +// 2. the repo's ignored env files (.env, .env.local, .env.production, .env.production.local), +// 3. a per-user file outside any checkout, ~/.offgrid/build.env, which survives worktrees. +// Microsoft's client ID is public and has no secret, so it defaults to the registered app. +// A release build sets OFFGRID_REQUIRE_OAUTH=1 and fails instead of shipping without them. + +import { existsSync, readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +/** Off Grid AI Desktop in Microsoft Entra: public client, no secret (docs/research). */ +export const OFFGRID_MICROSOFT_CLIENT_ID = '2bbfd68a-f7ce-4327-8cb9-45760b77c27f' + +export const USER_BUILD_ENV = join(homedir(), '.offgrid', 'build.env') + +/** KEY=value lines; # comments, blank lines and optional quotes. */ +export function parseEnvFile(text) { + const out = {} + for (const line of String(text).split(/\r?\n/)) { + const m = /^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line) + if (!m || line.trim().startsWith('#')) continue + out[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2') + } + return out +} + +/** + * The registrations to build in. `sources` are tried in order and the first value wins. + * Returns what is missing so the caller can warn or fail. + */ +export function resolveOauthBuildConfig(sources) { + const pick = (key) => { + for (const s of sources) { + const v = typeof s?.[key] === 'string' ? s[key].trim() : '' + if (v) return v + } + return '' + } + const desktopClient = pick('GOOGLE_OAUTH_CLIENT_TYPE') === 'desktop' + const google = { + clientId: desktopClient ? pick('GOOGLE_CLIENT_ID') : '', + clientSecret: desktopClient ? pick('GOOGLE_CLIENT_SECRET') : '' + } + const microsoftClientId = pick('MICROSOFT_CLIENT_ID') || OFFGRID_MICROSOFT_CLIENT_ID + const missing = [] + if (!desktopClient) missing.push('GOOGLE_OAUTH_CLIENT_TYPE=desktop') + if (!google.clientId) missing.push('GOOGLE_CLIENT_ID') + if (!google.clientSecret) missing.push('GOOGLE_CLIENT_SECRET') + return { google, microsoftClientId, missing } +} + +/** Read the per-user file when it exists; nothing otherwise. */ +export function readUserBuildEnv(file = USER_BUILD_ENV) { + return existsSync(file) ? parseEnvFile(readFileSync(file, 'utf8')) : {} +} + +/** Warn on a local build, fail a release build, when Google sign-in would be missing. */ +export function checkOauthBuildConfig(config, { pro, require }) { + if (!pro || config.missing.length === 0) return + const message = + `Connect with Off Grid AI for Google is off in this build: missing ${config.missing.join(', ')}. ` + + `Set them in the environment, in an ignored .env.local, or in ${USER_BUILD_ENV}.` + if (require) throw new Error(message) + console.warn(`\n[oauth] ${message}\n`) +} diff --git a/src/bootstrap/globals.d.ts b/src/bootstrap/globals.d.ts index 7c0e60af0..bf76a7ccb 100644 --- a/src/bootstrap/globals.d.ts +++ b/src/bootstrap/globals.d.ts @@ -2,3 +2,5 @@ // the private pro/ submodule is present at build time (pro build); false in the // free/open build. Read by preload (isPro) and the main pro loader. declare const __OFFGRID_PRO__: boolean +/** package.json's version, stamped into the main bundle at build time. */ +declare const __OFFGRID_APP_VERSION__: string | undefined diff --git a/src/main/__tests__/ai-request-log.test.ts b/src/main/__tests__/ai-request-log.test.ts index 3f50204ea..66af84137 100644 --- a/src/main/__tests__/ai-request-log.test.ts +++ b/src/main/__tests__/ai-request-log.test.ts @@ -5,7 +5,8 @@ import { invalidateAIRequests, recordAIRequest, setAIRequestSink, - snapshotAIValue + snapshotAIValue, + withoutAIRequestLog } from '../ai-request-log' import type { AIRequestRecord } from '../../shared/ai-request-log' import { AI_LOG_POLICY } from '../../shared/ai-request-log' @@ -20,6 +21,27 @@ function capture(): AIRequestRecord[] { return rows } describe('AI request recording does not own inference', () => { + it('keeps nothing of work run without the log, nested requests and audio included', async () => { + const rows = capture() + const dir = await mkdtemp(path.join(os.tmpdir(), 'offgrid-unrecorded-')) + const clip = path.join(dir, 'clip.wav') + await writeFile(clip, Buffer.from('synthetic audio')) + try { + const text = await withoutAIRequestLog(() => + recordAIRequest({ modality: 'stt', source: 'Whisper transcription' }, async (log) => { + await log.inputFile(clip) + return recordAIRequest({ modality: 'stt', source: 'Whisper attempt' }, async () => 'Ares') + }) + ) + expect(text).toBe('Ares') + expect(rows).toEqual([]) + // Work after it is recorded as usual. + await recordAIRequest({ modality: 'stt', source: 'Dictation' }, async () => 'hello') + expect(rows.map((row) => row.source)).toEqual(['Dictation', 'Dictation']) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) it('preserves results, effective inputs, model identity and timing', async () => { const rows = capture() const result = { content: 'answer', metrics: { completionTokens: 7 } } diff --git a/src/main/__tests__/computer-use-settings.integration.dbtest.ts b/src/main/__tests__/computer-use-settings.integration.dbtest.ts index dbda2b3d9..aa8b65580 100644 --- a/src/main/__tests__/computer-use-settings.integration.dbtest.ts +++ b/src/main/__tests__/computer-use-settings.integration.dbtest.ts @@ -11,7 +11,9 @@ vi.mock('electron', () => ({ isEncryptionAvailable: () => false, encryptString: (value: string) => Buffer.from(value), decryptString: (value: Buffer) => value.toString() - } + }, + // Settings changes are announced to open windows: none in this test. + BrowserWindow: { getAllWindows: () => [] } })) import { deleteSetting, getDB } from '../database' diff --git a/src/main/__tests__/conversation-surface.dbtest.ts b/src/main/__tests__/conversation-surface.dbtest.ts new file mode 100644 index 000000000..d3618a356 --- /dev/null +++ b/src/main/__tests__/conversation-surface.dbtest.ts @@ -0,0 +1,41 @@ +/** + * Conversations know their screen: Chat or God. Real database and settings store; Electron's + * userData directory and its encryption are the only boundaries replaced. Synthetic data. + */ +import { afterAll, describe, expect, it, vi } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' + +const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-surface-')) + +vi.mock('electron', () => ({ + app: { getPath: () => TMP_DIR, isPackaged: false, getAppPath: () => process.cwd() }, + ipcMain: { handle: () => undefined }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + } +})) + +import { createRagConversation, getRagConversations } from '../database' + +afterAll(() => { + fs.rmSync(TMP_DIR, { recursive: true, force: true }) +}) + +describe('conversation surface', () => { + it("lists God's conversations on God and leaves them out of Chat", () => { + createRagConversation('chat-1', 'Trip ideas') + createRagConversation('god-1', 'What is on today?', null, 'god') + + const ids = (surface?: 'chat' | 'god'): string[] => + getRagConversations(undefined, undefined, surface).map((c) => c.id) + expect(ids('god')).toEqual(['god-1']) + expect(ids('chat')).toContain('chat-1') + expect(ids('chat')).not.toContain('god-1') + // Asking for no surface keeps every conversation, as before. + expect(ids()).toEqual(expect.arrayContaining(['chat-1', 'god-1'])) + }) +}) diff --git a/src/main/__tests__/mcp-connector-update.dbtest.ts b/src/main/__tests__/mcp-connector-update.dbtest.ts new file mode 100644 index 000000000..0fb92ed78 --- /dev/null +++ b/src/main/__tests__/mcp-connector-update.dbtest.ts @@ -0,0 +1,146 @@ +/** + * Changing a connector over the real connector database and secret store. Electron's userData + * directory and its encryption are the only boundaries replaced. + */ +import { afterAll, describe, expect, it, vi } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' + +const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-mcp-update-')) + +vi.mock('electron', () => ({ + app: { getPath: () => TMP_DIR, isPackaged: false, getAppPath: () => process.cwd() }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + } +})) + +import { getDB } from '../database' +import { HOOKS, registerHook, unregisterHook } from '../bootstrap/hookRegistry' +import { + addConnector, + listConnectors, + removeConnector, + setConnectorSecrets, + testConnector, + updateConnector +} from '../mcp' +import { getSecret, setSecret } from '../secrets' + +afterAll(() => { + fs.rmSync(TMP_DIR, { recursive: true, force: true }) +}) + +function connected(id: number): void { + getDB() + .prepare("UPDATE connectors SET status='ok', tools=? WHERE id=?") + .run(JSON.stringify([{ name: 'search_notes' }]), id) + setSecret(`connector:${id}:oauth:tokens`, '{"access_token":"synthetic"}') + setSecret(`connector:${id}:API_TOKEN`, 'synthetic-token') +} + +describe('updateConnector', () => { + it('renames without signing out or forgetting tools', () => { + const id = addConnector({ name: 'Notes', transport: 'http', url: 'https://a.example.test/mcp' }) + connected(id) + + updateConnector(id, { name: ' Team notes ', url: 'https://a.example.test/mcp' }) + + const row = listConnectors().find((c) => c.id === id)! + expect(row).toMatchObject({ name: 'Team notes', status: 'ok' }) + expect(row.tools).toContain('search_notes') + expect(getSecret(`connector:${id}:oauth:tokens`)).not.toBeNull() + }) + + it('a new address signs out of the old server and waits for a new test, keeping the token', () => { + const id = addConnector({ name: 'Notes', transport: 'http', url: 'https://a.example.test/mcp' }) + connected(id) + + updateConnector(id, { url: 'https://b.example.test/mcp' }) + + const row = listConnectors().find((c) => c.id === id)! + expect(row).toMatchObject({ + url: 'https://b.example.test/mcp', + status: 'unknown', + status_detail: 'Settings changed. Test the connection.', + tools: null + }) + expect(getSecret(`connector:${id}:oauth:tokens`)).toBeNull() + expect(getSecret(`connector:${id}:API_TOKEN`)).toBe('synthetic-token') + }) + + it('changes the command and arguments of a local server', () => { + const id = addConnector({ name: 'Local', transport: 'stdio', command: 'node', args: ['a.js'] }) + + updateConnector(id, { command: 'npx', args: ['server', '--port', '0'] }) + expect(listConnectors().find((c) => c.id === id)).toMatchObject({ + command: 'npx', + args: JSON.stringify(['server', '--port', '0']), + url: null + }) + }) + + it("a new command does not inherit the old one's secrets", () => { + // Review finding: the new command started with the old command's environment secrets. + const id = addConnector({ + name: 'Local', + transport: 'stdio', + command: 'node', + args: ['a.js'], + envKeys: ['API_TOKEN'] + }) + connected(id) + updateConnector(id, { name: 'Local notes' }) + expect(getSecret(`connector:${id}:API_TOKEN`)).toBe('synthetic-token') + + updateConnector(id, { command: 'npx', args: ['other-server'] }) + expect(getSecret(`connector:${id}:API_TOKEN`)).toBeNull() + expect(listConnectors().find((c) => c.id === id)).toMatchObject({ env_keys: null }) + + // Review finding: "Change token" saved the new token, but nothing passed it to the command. + setConnectorSecrets(id, { API_TOKEN: 'new-synthetic-token', 'bad key': 'x' }) + expect(getSecret(`connector:${id}:API_TOKEN`)).toBe('new-synthetic-token') + expect(listConnectors().find((c) => c.id === id)).toMatchObject({ + env_keys: JSON.stringify(['API_TOKEN']) + }) + }) + + it('refuses an empty name, an empty address, and a removed connector', () => { + const id = addConnector({ name: 'Notes', transport: 'http', url: 'https://a.example.test/mcp' }) + expect(() => updateConnector(id, { name: ' ' })).toThrow('Enter a name.') + expect(() => updateConnector(id, { url: '' })).toThrow('Enter the server address.') + removeConnector(id) + expect(() => updateConnector(id, { name: 'Back' })).toThrow('This connection no longer exists.') + }) +}) + +describe('testConnector', () => { + it('saves the tools for the services granted at sign-in, not the ones it started with', async () => { + // Review finding: the first test saved tools for services the user declined at consent. + let granted = ['mail', 'files'] + registerHook(HOOKS.mcpConnectorToolSource, () => ({ + tools: granted.map((service) => ({ name: `${service}_search` })), + authorize: async () => { + granted = ['mail'] + }, + verify: async () => undefined, + callTool: async () => ({ ok: true, text: '' }) + })) + try { + const id = addConnector({ + name: 'Work', + transport: 'http', + url: 'https://a.example.test/mcp' + }) + expect(await testConnector(id)).toMatchObject({ ok: true, tools: [{ name: 'mail_search' }] }) + expect(JSON.parse(listConnectors().find((c) => c.id === id)!.tools!)).toEqual([ + { name: 'mail_search' } + ]) + } finally { + unregisterHook(HOOKS.mcpConnectorToolSource) + } + }) +}) diff --git a/src/main/__tests__/mcp-oauth-loopback.integration.test.ts b/src/main/__tests__/mcp-oauth-loopback.integration.test.ts index 32a1e2786..f4e8d84e3 100644 --- a/src/main/__tests__/mcp-oauth-loopback.integration.test.ts +++ b/src/main/__tests__/mcp-oauth-loopback.integration.test.ts @@ -157,7 +157,10 @@ describe('OAuth loopback callback lifecycle', () => { const pending = blocked.awaitCode('blocked-state') const callbackFailure = expect(pending).rejects.toThrow('OAuth callback server unavailable') - await expect(blocked.start()).rejects.toMatchObject({ code: 'EADDRINUSE' }) + await expect(blocked.start()).rejects.toMatchObject({ + code: 'EADDRINUSE', + message: expect.stringContaining('If another Off Grid AI window is open, close it') + }) await callbackFailure expect(observedErrors).toHaveLength(1) diff --git a/src/main/__tests__/model-server-chat.integration.test.ts b/src/main/__tests__/model-server-chat.integration.test.ts index be2652290..cd3d46586 100644 --- a/src/main/__tests__/model-server-chat.integration.test.ts +++ b/src/main/__tests__/model-server-chat.integration.test.ts @@ -336,6 +336,74 @@ describe('model gateway chat streaming', () => { } }) + it('never switches off reasoning an OpenRouter model requires, as Chat does', async () => { + // Gemini 3.7 Flash on OpenRouter refuses "reasoning off" with HTTP 400 ("Reasoning is mandatory + // for this endpoint and cannot be disabled"). The browser's Agent asks with thinking off. + const remote = await import('../vision/remote-vision-server') + const codec = await import('../../shared/remote-vision-server') + const bodies: Record[] = [] + const provider = http.createServer((request, response) => { + if (request.method === 'GET' && request.url === '/v1/models') { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end( + JSON.stringify({ + data: [ + { id: 'google/gemini-3.7-flash', reasoning: { mandatory: true } }, + { id: 'openai/gpt-5.6', reasoning: { mandatory: false } } + ] + }) + ) + return + } + let raw = '' + request.setEncoding('utf8') + request.on('data', (chunk) => { + raw += chunk + }) + request.on('end', () => { + bodies.push(JSON.parse(raw) as Record) + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify({ choices: [{ message: { content: 'ok' } }] })) + }) + }) + await new Promise((resolve) => provider.listen(0, '127.0.0.1', resolve)) + const port = (provider.address() as AddressInfo).port + const ask = async (serverId: string, model: string): Promise> => { + remote.setRemoteVisionServerSettings({ + provider: 'openrouter', + endpoint: `http://127.0.0.1:${port}/v1`, + model, + serverId, + name: 'Synthetic OpenRouter', + apiKey: 'synthetic-key' + }) + const response = await fetch(`http://127.0.0.1:${gatewayPort}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: codec.remoteVisionModelId(serverId, model), + stream: false, + chat_template_kwargs: { enable_thinking: false }, + messages: [{ role: 'user', content: 'Reply with three words' }] + }) + }) + expect(response.status).toBe(200) + return bodies.at(-1)! + } + + try { + const mandatory = await ask('or-mandatory', 'google/gemini-3.7-flash') + expect(mandatory).not.toHaveProperty('reasoning') + expect(mandatory).not.toHaveProperty('chat_template_kwargs') + const optional = await ask('or-optional', 'openai/gpt-5.6') + expect(optional).toMatchObject({ reasoning: { effort: 'none' } }) + } finally { + remote.removeRemoteVisionServer('or-mandatory') + remote.removeRemoteVisionServer('or-optional') + await new Promise((resolve) => provider.close(() => resolve())) + } + }) + it('rejects malformed input with a stable JSON envelope and remains healthy', async () => { const response = await fetch(`http://127.0.0.1:${gatewayPort}/v1/chat/completions`, { method: 'POST', diff --git a/src/main/__tests__/model-server-image.integration.dbtest.ts b/src/main/__tests__/model-server-image.integration.dbtest.ts index 09290876b..333189e9e 100644 --- a/src/main/__tests__/model-server-image.integration.dbtest.ts +++ b/src/main/__tests__/model-server-image.integration.dbtest.ts @@ -52,15 +52,18 @@ function installNativeRuntimeBoundary(): void { const fs = require('node:fs') const args = process.argv.slice(2) const value = (flag) => args[args.indexOf(flag) + 1] +const slow = value('-p') === 'A slow green cabin under stars' const valid = - value('-p') === 'A green cabin under stars' && + (value('-p') === 'A green cabin under stars' || slow) && value('-W') === '640' && value('-H') === '384' && value('--steps') === '7' && value('-s') === '314' if (!valid) process.exit(17) -fs.writeFileSync(value('-o'), Buffer.from('${PNG_BASE64}', 'base64')) process.stderr.write('1/7 - 0.01s/it\\n') +const finish = () => fs.writeFileSync(value('-o'), Buffer.from('${PNG_BASE64}', 'base64')) +if (slow) setTimeout(finish, 10000) +else finish() ` ) fs.chmodSync(CLI_PATH, 0o755) @@ -175,4 +178,125 @@ describe('model gateway image generation', () => { } }) }) + + it('stops synchronous image work when the client disconnects', async () => { + const base = `http://127.0.0.1:${String(gatewayPort)}` + const before = await (await fetch(`${base}/v1/requests`)).json() + const existing = new Set(before.data.map((entry: { request_id: string }) => entry.request_id)) + const controller = new AbortController() + const response = fetch(`${base}/v1/images/generations`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: controller.signal, + body: JSON.stringify({ + prompt: 'A slow green cabin under stars', + width: 640, + height: 384, + steps: 7, + seed: 314, + model: MODEL_NAME + }) + }).catch((error: unknown) => error) + const deadline = Date.now() + 8000 + let running: { request_id: string; progress?: { step: number } } | undefined + while (Date.now() < deadline) { + const recent = await (await fetch(`${base}/v1/requests`)).json() + running = recent.data.find((entry: { request_id: string }) => !existing.has(entry.request_id)) + if (running) { + running = await (await fetch(`${base}/v1/requests/${running.request_id}`)).json() + } + if (running?.progress?.step === 1) break + await new Promise((resolve) => setTimeout(resolve, 25)) + } + expect(running?.progress?.step).toBe(1) + controller.abort() + expect(await response).toMatchObject({ name: 'AbortError' }) + let cancelled: unknown + const cancelDeadline = Date.now() + 2000 + while (Date.now() < cancelDeadline) { + cancelled = await (await fetch(`${base}/v1/requests/${running!.request_id}`)).json() + if ((cancelled as { status: string }).status === 'failed') break + await new Promise((resolve) => setTimeout(resolve, 25)) + } + expect(cancelled).toMatchObject({ status: 'failed', error: { type: 'cancelled' } }) + }) + + it('cancels queued and running image requests without stopping another request', async () => { + const base = `http://127.0.0.1:${String(gatewayPort)}` + const submit = async (prompt: string): Promise => { + const response = await fetch(`${base}/v1/images/generations`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + async: true, + prompt, + width: 640, + height: 384, + steps: 7, + seed: 314, + model: MODEL_NAME + }) + }) + expect(response.status).toBe(202) + return (await response.json()).request_id + } + const runningId = await submit('A slow green cabin under stars') + const deadline = Date.now() + 8000 + let progress: { status: string; progress?: { step: number } } | undefined + while (Date.now() < deadline) { + progress = await (await fetch(`${base}/v1/requests/${runningId}`)).json() + if (progress?.progress?.step === 1) break + await new Promise((resolve) => setTimeout(resolve, 25)) + } + expect(progress).toMatchObject({ status: 'running', progress: { step: 1 } }) + + const queuedId = await submit('A green cabin under stars') + const cancelQueued = await fetch(`${base}/v1/requests/${queuedId}`, { method: 'DELETE' }) + expect(cancelQueued.status).toBe(200) + expect(await cancelQueued.json()).toMatchObject({ + request_id: queuedId, + status: 'failed', + cancelled: true + }) + expect(await (await fetch(`${base}/v1/requests/${runningId}`)).json()).toMatchObject({ + status: 'running' + }) + + for (let repeat = 0; repeat < 2; repeat++) { + const cancelled = await fetch(`${base}/v1/requests/${runningId}`, { method: 'DELETE' }) + expect(cancelled.status).toBe(200) + expect(await cancelled.json()).toMatchObject({ + request_id: runningId, + status: 'failed', + cancelled: true + }) + } + for (const id of [runningId, queuedId]) { + expect(await (await fetch(`${base}/v1/requests/${id}`)).json()).toMatchObject({ + status: 'failed', + error: { type: 'cancelled', message: 'Image generation cancelled.' } + }) + } + + const completed = await fetch(`${base}/v1/images/generations`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: 'A green cabin under stars', + width: 640, + height: 384, + steps: 7, + seed: 314, + model: MODEL_NAME + }) + }) + expect(completed.status).toBe(200) + const result = await completed.json() + expect(result.data[0].b64_json).toBe(PNG_BASE64) + const cancelCompleted = await fetch(`${base}/v1/requests/${result.request_id}`, { + method: 'DELETE' + }) + expect(await cancelCompleted.json()).toMatchObject({ status: 'completed', cancelled: false }) + expect((await fetch(`${base}/v1/requests/missing`, { method: 'DELETE' })).status).toBe(404) + }) }) diff --git a/src/main/__tests__/multimodal-rag-lifecycle.integration.dbtest.ts b/src/main/__tests__/multimodal-rag-lifecycle.integration.dbtest.ts index e67cf3ddc..3505c18ca 100644 --- a/src/main/__tests__/multimodal-rag-lifecycle.integration.dbtest.ts +++ b/src/main/__tests__/multimodal-rag-lifecycle.integration.dbtest.ts @@ -103,11 +103,15 @@ async function invoke(channel: string, ...args: unknown[]): Promise { async function bootApplicationModules(): Promise { handlers.clear() - const [{ setupIPC }, { setupRagIPC }, { llm }] = await Promise.all([ + const [{ setupIPC }, { setupRagIPC }, { llm }, { configureRuntime }] = await Promise.all([ import('../ipc'), import('../rag-ipc'), - import('../llm') + import('../llm'), + import('../runtime-env') ]) + // As the app does at every launch: a reopen after resetModules has a fresh runtime-env, which + // would otherwise fall back to the suite's default profile and find none of this one's data. + configureRuntime({ dataDir: PROFILE_DIR, binRoots: [BIN_DIR] }) const service = llm as unknown as { port: number; initialized: boolean; paused: boolean } service.port = fake.port service.initialized = true @@ -187,8 +191,6 @@ beforeAll(async () => { JSON.stringify({ primary: 'vision-model.gguf', mmproj: 'mmproj.gguf' }) ) - const { configureRuntime } = await import('../runtime-env') - configureRuntime({ dataDir: PROFILE_DIR, binRoots: [BIN_DIR] }) fake = await startFakeLlamaServer() await bootApplicationModules() }) diff --git a/src/main/__tests__/task-history-ipc.dbtest.ts b/src/main/__tests__/task-history-ipc.dbtest.ts index d30f80260..13d48a20b 100644 --- a/src/main/__tests__/task-history-ipc.dbtest.ts +++ b/src/main/__tests__/task-history-ipc.dbtest.ts @@ -12,10 +12,16 @@ const mocks = vi.hoisted(() => ({ retryIpc: vi.fn(), viable: vi.fn(), visionRun: vi.fn(), - webRun: vi.fn() + webRun: vi.fn(), + inAppRun: vi.fn(), + forgetStop: vi.fn() })) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) +// Retries now run inside the screen-task gate, whose run telemetry writes under userData. +vi.mock('electron', () => ({ + ipcMain: { handle: mocks.handle }, + app: { getPath: () => `/tmp/offgrid-task-history-ipc-${process.pid}` } +})) vi.mock('../tasks/task-history', () => ({ initializeTaskHistory: mocks.initialize, listTaskRuns: mocks.list, @@ -24,9 +30,17 @@ vi.mock('../tasks/task-history', () => ({ vi.mock('../tasks/task-retry-ipc', () => ({ registerTaskRetryIpc: mocks.retryIpc })) vi.mock('../tasks/task-guide-ipc', () => ({ registerTaskGuideIpc: mocks.guideIpc })) vi.mock('../tasks/task-retry', () => ({ configureTaskRetryRunner: mocks.configure })) -vi.mock('../vision/vision-controller', () => ({ hasActiveVisionSession: vi.fn(() => false) })) +vi.mock('../vision/vision-controller', () => ({ + hasActiveVisionSession: vi.fn(() => false), + forgetVisionStopBeforeStart: mocks.forgetStop +})) +// The browser chooser (Tasks > Web Use), and Off Grid AI's own browser, which a retry must not +// reach around it. +vi.mock('../browser/web-use-host', () => ({ + getWebUseRailHost: () => ({ runTask: mocks.webRun }) +})) vi.mock('../browser/browser-host', () => ({ - getBrowserRailHost: () => ({ runTask: mocks.webRun }) + getBrowserRailHost: () => ({ runTask: mocks.inAppRun }) })) vi.mock('../vision/vision-host', () => ({ getVisionRailHost: () => ({ runTask: mocks.visionRun }) @@ -70,9 +84,13 @@ describe('task history IPC composition', () => { const checkpoint = { taskId: 'task-1', steps: [], summary: 'retry', currentStep: 1 } await runner.web(task, 'task-1', checkpoint) + // A retry picks its browser as a new task does: review finding, it always used Off Grid AI's. expect(mocks.webRun).toHaveBeenCalledWith( expect.objectContaining({ taskId: 'task-1', checkpoint }) ) + expect(mocks.inAppRun).not.toHaveBeenCalled() + // And a Stop sent before an earlier run started does not carry over to it. + expect(mocks.forgetStop).toHaveBeenCalledWith('task-1') mocks.axRouting.mockResolvedValueOnce({ app: 'Notes', diff --git a/src/main/__tests__/task-history.integration.dbtest.ts b/src/main/__tests__/task-history.integration.dbtest.ts index d47f08864..37dff715f 100644 --- a/src/main/__tests__/task-history.integration.dbtest.ts +++ b/src/main/__tests__/task-history.integration.dbtest.ts @@ -666,4 +666,46 @@ describe('task history persistence', () => { expect(durable).toContain('[redacted]') database.close() }) + + it('shows a plain title while a retry keeps the full model-facing goal, across a restart', () => { + const { db, store } = openStore(5_000) + const goal = [ + 'Current user request (authoritative):', + 'Use Web Use to open https://getoffgridai.co/pricing and calculate Team pricing for 40 people.', + '', + 'Structured task summary:', + 'Select Team, enter 40 seats, read the total.' + ].join('\n') + store.upsert({ taskId: 'clean-title', kind: 'web_use', title: goal, status: 'running' }) + // A later progress write from the browser repeats the goal; the first goal stays the goal. + store.upsert({ taskId: 'clean-title', kind: 'web_use', title: goal, status: 'done' }) + + const reopened = new TaskHistoryStore(db, () => 6_000) + reopened.migrate() + const task = reopened.get('clean-title') + expect(task?.title).toBe('Open getoffgridai.co/pricing and calculate Team pricing for 40 people') + expect(task?.goal).toBe(goal) + db.close() + }) + + it('reads a plain title from a task row written before titles were cleaned', () => { + const { db, store } = openStore(5_000) + db.prepare( + `INSERT INTO task_run_history (task_id, kind, title, status, steps_json, started_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run( + 'legacy-title', + 'computer_use', + 'Structured task summary:\nRename the Q3 deck in Finder to Q3-final.', + 'done', + '[]', + 1_000, + 1_000 + ) + + const task = store.get('legacy-title') + expect(task?.title).toBe('Rename the Q3 deck in Finder to Q3-final') + expect(task?.goal).toBe('Structured task summary:\nRename the Q3 deck in Finder to Q3-final.') + db.close() + }) }) diff --git a/src/main/__tests__/task-role-models.test.ts b/src/main/__tests__/task-role-models.test.ts new file mode 100644 index 000000000..e51f85fa1 --- /dev/null +++ b/src/main/__tests__/task-role-models.test.ts @@ -0,0 +1,75 @@ +// The one place task model roles change. The settings stores are the boundary replaced. +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const store = vi.hoisted(() => ({ + computer: { groundingModelId: null as string | null, decisionModelId: null as string | null }, + web: { decisionModelId: null as string | null } +})) +vi.mock('../computer-use-settings', () => ({ + getComputerUseSettings: () => ({ ...store.computer }), + setComputerUseSettings: (next: typeof store.computer) => void (store.computer = next) +})) +// The model inventory: one installed decision model and one installed grounding specialist. +vi.mock('../models-manager', () => ({ + getCatalog: async () => ({ + kinds: [], + models: [ + { id: 'jev', kind: 'computer_use', tags: ['Decision'] }, + { id: 'ui-tars', kind: 'computer_use', tags: [] }, + { id: 'not-installed', kind: 'computer_use', tags: [] } + ] + }), + listInstalled: async () => ['jev', 'ui-tars'] +})) +vi.mock('../web-use-settings', () => ({ + getWebUseSettings: () => ({ ...store.web }), + setWebUseSettings: (next: typeof store.web) => void (store.web = next) +})) + +import { clearTaskRolesFor, setTaskRoleModel } from '../task-role-models' +import { setTaskRoleFromRequest } from '../task-role-requests' + +beforeEach(() => { + store.computer = { groundingModelId: null, decisionModelId: null } + store.web = { decisionModelId: null } +}) + +describe('task model roles', () => { + it('shares one grounding specialist, and keeps a decision model per task', () => { + setTaskRoleModel('web_use', 'grounding', 'ui-tars') + expect(store.computer.groundingModelId).toBe('ui-tars') + setTaskRoleModel('computer_use', 'decision', 'kev-4b') + setTaskRoleModel('web_use', 'decision', 'jev') + expect(store.computer.decisionModelId).toBe('kev-4b') + expect(store.web.decisionModelId).toBe('jev') + }) + + it('clears every role a removed model held, and no other', () => { + store.computer = { groundingModelId: 'ui-tars', decisionModelId: 'jev' } + store.web = { decisionModelId: 'jev' } + clearTaskRolesFor('jev') + expect(store.computer).toEqual({ groundingModelId: 'ui-tars', decisionModelId: null }) + expect(store.web.decisionModelId).toBeNull() + }) + + it('sets a role asked for only when the request is complete and the model can fill it', async () => { + expect( + await setTaskRoleFromRequest({ task: 'web_use', role: 'decision', modelId: 'jev' }) + ).toEqual({ success: true }) + expect(store.web.decisionModelId).toBe('jev') + // Review finding: any id was accepted. Now an unknown, uninstalled or wrong-role model is not. + for (const bad of [ + { task: 'chat', role: 'decision', modelId: 'jev' }, + { task: 'web_use', role: 'reasoner', modelId: 'jev' }, + { task: 'web_use', role: 'decision', modelId: '' }, + { task: 'web_use', role: 'decision', modelId: 'ui-tars' }, + { task: 'web_use', role: 'grounding', modelId: 'jev' }, + { task: 'web_use', role: 'grounding', modelId: 'not-installed' }, + { task: 'web_use', role: 'grounding', modelId: 'made-up' } + ]) { + expect((await setTaskRoleFromRequest(bad)).success).toBe(false) + } + expect(store.web.decisionModelId).toBe('jev') + expect(store.computer.groundingModelId).toBeNull() + }) +}) diff --git a/src/main/__tests__/tools-loop.dbtest.ts b/src/main/__tests__/tools-loop.dbtest.ts index 76206bb1c..adb5a72e3 100644 --- a/src/main/__tests__/tools-loop.dbtest.ts +++ b/src/main/__tests__/tools-loop.dbtest.ts @@ -14,6 +14,9 @@ import type { TickOutcome } from '@offgrid/use' import { startFakeLlamaServer, type FakeLlamaServer } from './harness/fake-llama-server' const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-tools-it-')) +// These cases test the tool loop, not embeddings: route by words at once instead of downloading +// the embedding model into this fresh folder on every run. +process.env.OFFGRID_EMBEDDINGS_OFFLINE = '1' vi.mock('electron', () => ({ app: { getPath: () => TMP_DIR, isPackaged: false, getAppPath: () => process.cwd() }, safeStorage: { @@ -107,6 +110,24 @@ describe('agentic tool loop — real toolChat + real LLMService over a fake llam expect(steps).toEqual([]) // no tool call -> no step }) + it('asks once more when a turn ends with no answer, and says why if there is still none', async () => { + // Reported: God showed "No response returned." after its searches, with no reason given. + enqueueReactiveAfterEmptyPlan( + { content: '', finishReason: 'length' }, + { content: 'Here is what I found.' } + ) + expect((await toolChat('what did Morgan send me?', [])).answer).toBe('Here is what I found.') + expect(fake.requests).toHaveLength(2) + + fake.reset() + enqueueReactiveAfterEmptyPlan( + { content: '', finishReason: 'length' }, + { content: '', finishReason: 'length' } + ) + const { answer } = await toolChat('and yesterday?', []) + expect(answer).toMatch(/whole output allowance \(\d+ tokens\)/) + }) + it('sends the user Max-output setting to the model, not the old hardcoded 1024 cap (essay cut-off)', async () => { // The agentic path used to force maxTokens: 1024, so a long "All memory" answer was truncated // mid-sentence at ~1024 tokens regardless of the setting/window. Now it inherits the setting. @@ -420,6 +441,41 @@ describe('agentic tool loop — real toolChat + real LLMService over a fake llam } }) + it('in assistant mode, acting is always offered and the model is told when to use it', async () => { + // God's tools do not depend on the words of the message: the model decides from the prompt. + const extension = new NativeActionToolExtension( + { run: async () => ({ ok: true, result: undefined }), taskUseEnabled: () => true }, + 'darwin' + ) + registerToolExtension(extension) + const first = (): { + tools?: { function?: { name?: string } }[] + messages?: { content?: unknown }[] + } => fake.requests[0] as never + const offered = (): string[] => (first().tools ?? []).map((t) => t.function?.name ?? '') + try { + fake.enqueue({ content: 'Pick one with good reviews.' }) + const question = await toolChat('what is a good cooking video on youtube?', [], { + assistantOnly: true + }) + expect(question.toolCalls).toEqual([]) + expect(offered()).toContain('web_use') + expect(String(first().messages?.[0]?.content)).toContain( + 'Answer questions directly. Call web_use only when the user asks you to do something on a website.' + ) + + // A request that shares no words with the tool still has it. + fake.reset() + fake.enqueue({ content: 'ok' }) + await toolChat('can you do a sanity check of this entire website?', [], { + assistantOnly: true + }) + expect(offered()).toContain('web_use') + } finally { + unregisterToolExtension(extension.id, extension) + } + }) + it('passes the tool schemas + tool_choice to the model on the first round', async () => { enqueueReactiveAfterEmptyPlan({ content: 'ok' }) await toolChat('get_datetime: what time is it', []) @@ -564,6 +620,7 @@ describe('agentic tool loop — real toolChat + real LLMService over a fake llam it('does not save successful tool output as the answer when the final model turn is empty', async () => { enqueueReactiveAfterEmptyPlan( { toolCalls: [{ name: 'calculator', args: { expression: '2+2' } }] }, + { content: '' }, { content: '' } ) const deltas: string[] = [] @@ -574,7 +631,9 @@ describe('agentic tool loop — real toolChat + real LLMService over a fake llam } }) - expect(result.answer).toBe('') + // Never the tool's raw output: an empty turn is asked once more, then explained. + expect(result.answer).not.toContain('4') + expect(result.answer).toMatch(/without an answer/) expect(deltas.join('')).toBe('') }) diff --git a/src/main/actions/__tests__/gate-host.test.ts b/src/main/actions/__tests__/gate-host.test.ts index 5fe845792..c369a19ac 100644 --- a/src/main/actions/__tests__/gate-host.test.ts +++ b/src/main/actions/__tests__/gate-host.test.ts @@ -73,11 +73,24 @@ describe('gateHost', () => { it('a handler that declines to queue also lets the action run', async () => { registerHook(HOOKS.actionsProposeApproval, () => false) const decision = await gateHost({ - action: record({ source: 'routine', sourceRef: undefined }) + action: record({ source: 'reasoning', sourceRef: undefined }) }) expect(decision).toEqual({ kind: 'approve' }) }) + it('a routine never runs a change that nothing could ask approval for', async () => { + // Review finding: with no approval queued, a routine's write ran unattended. + registerHook(HOOKS.actionsProposeApproval, () => false) + const routine = record({ source: 'routine', sourceRef: undefined }) + expect(await gateHost({ action: routine })).toMatchObject({ kind: 'reject' }) + unregisterHook(HOOKS.actionsProposeApproval) + expect(await gateHost({ action: routine })).toMatchObject({ kind: 'reject' }) + // Reading is fine on its own. + expect( + await gateHost({ action: record({ source: 'routine', risk: 'read', rail: 'semantic' }) }) + ).toEqual({ kind: 'approve' }) + }) + it('a non-chat queued action parks until Action Approval resolves it', async () => { const seen = vi.fn(() => true) registerHook(HOOKS.actionsProposeApproval, seen) @@ -90,6 +103,36 @@ describe('gateHost', () => { expect(pendingActionGateCount()).toBe(0) }) + it('a routine waits for approval even for a native action Chat runs straight away', async () => { + const queued = vi.fn(() => true) + registerHook(HOOKS.actionsProposeApproval, queued) + const sendMail: Partial = { + type: 'mail_send' as ActionRecord['type'], + rail: 'semantic', + risk: 'mutate' + } + + // Asked in Chat: runs. + await expect(gateHost({ action: record(sendMail) })).resolves.toEqual({ kind: 'approve' }) + expect(queued).not.toHaveBeenCalled() + + // From a routine, with nobody watching: waits for the approval card. + const parked = gateHost({ + action: record({ ...sendMail, source: 'routine', sourceRef: 'god-1' }) + }) + expect(queued).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'native', source: 'routine' }) + ) + expect(pendingActionGateCount()).toBe(1) + resolveActionGate('act_1', { kind: 'approve' }) + await expect(parked).resolves.toEqual({ kind: 'approve' }) + + // Reading needs no approval, from anywhere. + await expect( + gateHost({ action: record({ ...sendMail, risk: 'read', source: 'routine' }) }) + ).resolves.toEqual({ kind: 'approve' }) + }) + it('the request carries what the card needs: id, type, hash, mapped kind, args', async () => { let request: Record = {} registerHook(HOOKS.actionsProposeApproval, (req: Record) => { diff --git a/src/main/actions/chat-connector-action.ts b/src/main/actions/chat-connector-action.ts index be3ca02d3..f79fa0f5e 100644 --- a/src/main/actions/chat-connector-action.ts +++ b/src/main/actions/chat-connector-action.ts @@ -2,7 +2,10 @@ import type { ProposeOutcome, TickOutcome } from '@offgrid/use' export interface ChatConnectorActionsPort { - propose(input: unknown, meta: { source: 'chat'; sourceRef?: string }): Promise + propose( + input: unknown, + meta: { source: 'chat' | 'routine'; sourceRef?: string } + ): Promise waitForOutcome(actionId: string, timeoutMs: number): Promise onParked(actionId: string, listener: () => void): () => void kick(): void @@ -26,6 +29,8 @@ export async function runChatConnectorAction( connector: string args: Record sourceRef?: string + /** A routine's actions always wait for approval. */ + source?: 'chat' | 'routine' } ): Promise { if (!actions) return { kind: 'unavailable' } @@ -36,7 +41,10 @@ export async function runChatConnectorAction( args: { connectorId: request.connectorId, tool: request.tool, args: request.args }, risk: 'mutate' }, - { source: 'chat', ...(request.sourceRef ? { sourceRef: request.sourceRef } : {}) } + { + source: request.source ?? 'chat', + ...(request.sourceRef ? { sourceRef: request.sourceRef } : {}) + } ) if (!proposed.accepted) return { kind: 'refused', reason: proposed.reason } if (proposed.deduped) return { kind: 'deduped', actionId: proposed.id } diff --git a/src/main/actions/gate-host.ts b/src/main/actions/gate-host.ts index 395cc7fd1..7caa6e864 100644 --- a/src/main/actions/gate-host.ts +++ b/src/main/actions/gate-host.ts @@ -166,6 +166,12 @@ export function needsApproval(action: Pick): boolean { ) } +/** A routine runs with nobody watching: anything it does beyond reading waits for approval, + * including the native actions a person in Chat runs straight away. */ +export function unattended(action: Pick): boolean { + return action.source === 'routine' && action.risk !== 'read' +} + /** The existing Chat that owns an action, or null for Action Approval. * This is the routing SSOT for direct Chat execution. */ export function approvalConversation( @@ -179,7 +185,7 @@ export function approvalConversation( export async function gateHost({ action }: { action: ActionRecord }): Promise { // Native semantic actions run straight through this gate. The env flag bypasses // source approval for headless testing. - if (approvalBypassed() || !needsApproval(action)) { + if (approvalBypassed() || (!needsApproval(action) && !unattended(action))) { return { kind: 'approve' } } const conversationId = approvalConversation(action) @@ -205,6 +211,10 @@ export async function gateHost({ action }: { action: ActionRecord }): Promise Promise, dependencies: RemoteScreenGateDependencies = productionDependencies ): (action: ActionRecord) => Promise { - return async (action) => { - const modelStrategy = dependencies.modelStrategy(taskKind) - const activeServer = dependencies.activeServer() - const specialistServer = dependencies.specialistServer?.() ?? null - const decisionServer = dependencies.decisionServer?.(taskKind) ?? null - const activeServers = [ - ...(modelStrategy === 'same_as_chat' || - modelStrategy === 'text_plus_specialist' || - modelStrategy === 'decision_plus_reasoning' - ? [activeServer] - : []), - ...(modelStrategy === 'separate_specialist' || - modelStrategy === 'text_plus_specialist' || - modelStrategy === 'decision_plus_specialist' - ? [specialistServer] - : []) - ].filter((server): server is NonNullable => server !== null) - const decision = remoteScreenDecision({ + return (action) => { + const goal = (action.args as Record).goal + return runInRemoteScreenGate( taskKind, - modelStrategy, - activeServer, - activeServers - }) - if (!decision.allowed) return { ok: false, detail: decision.message } - const telemetry = - taskKind === 'computer_use' - ? createComputerUseRunTelemetry({ - actionId: action.id, - task: - typeof (action.args as Record).goal === 'string' - ? String((action.args as Record).goal) - : action.intent, - strategy: modelStrategy, - reasoningServer: activeServer, - groundingServer: specialistServer, - deciderServer: decisionServer - }) - : undefined - try { - const result = await runWithRemoteScreenTaskSession( - { taskKind, modelStrategy, activeServer, telemetry }, - () => execute(action) - ) - if (telemetry) await finishComputerUseRunTelemetry(telemetry, result) - return result - } catch (error) { - if (telemetry) { - await finishComputerUseRunTelemetry(telemetry, { - ok: false, - detail: error instanceof Error ? error.message : String(error) + { id: action.id, goal: typeof goal === 'string' ? goal : action.intent }, + () => execute(action), + { outcome: (result) => result, refused: (detail) => ({ ok: false, detail }) }, + dependencies + ) + } +} + +/** Run one screen task inside the gate: the privacy decision, the task kind's own model + * settings for every rail, and run telemetry. New tasks and retries both enter here, so a + * Continue uses the same Web Use or Computer Use selection as the run it continues. */ +export async function runInRemoteScreenGate( + taskKind: ScreenTaskKind, + task: ScreenTaskIdentity, + run: () => Promise, + result: { outcome(value: T): ExecuteResult; refused(detail: string | undefined): T }, + dependencies: RemoteScreenGateDependencies = productionDependencies +): Promise { + const modelStrategy = dependencies.modelStrategy(taskKind) + const activeServer = dependencies.activeServer() + const specialistServer = dependencies.specialistServer?.() ?? null + const decisionServer = dependencies.decisionServer?.(taskKind) ?? null + const activeServers = [ + ...(modelStrategy === 'same_as_chat' || + modelStrategy === 'text_plus_specialist' || + modelStrategy === 'decision_plus_reasoning' + ? [activeServer] + : []), + ...(modelStrategy === 'separate_specialist' || + modelStrategy === 'text_plus_specialist' || + modelStrategy === 'decision_plus_specialist' + ? [specialistServer] + : []) + ].filter((server): server is NonNullable => server !== null) + const decision = remoteScreenDecision({ + taskKind, + modelStrategy, + activeServer, + activeServers + }) + if (!decision.allowed) return result.refused(decision.message) + const telemetry = + taskKind === 'computer_use' + ? createComputerUseRunTelemetry({ + actionId: task.id, + task: task.goal, + strategy: modelStrategy, + reasoningServer: activeServer, + groundingServer: specialistServer, + deciderServer: decisionServer }) - } - throw error + : undefined + try { + const value = await runWithRemoteScreenTaskSession( + { taskKind, modelStrategy, activeServer, telemetry }, + run + ) + if (telemetry) await finishComputerUseRunTelemetry(telemetry, result.outcome(value)) + return value + } catch (error) { + if (telemetry) { + await finishComputerUseRunTelemetry(telemetry, { + ok: false, + detail: error instanceof Error ? error.message : String(error) + }) } + throw error } } diff --git a/src/main/ai-request-log.ts b/src/main/ai-request-log.ts index 6d8dc380d..2aa315507 100644 --- a/src/main/ai-request-log.ts +++ b/src/main/ai-request-log.ts @@ -21,6 +21,7 @@ type Sink = (record: AIRequestRecord) => void let sink: Sink | undefined let epoch = 0 const context = new AsyncLocalStorage() +const unrecorded = new AsyncLocalStorage() /** No database, Electron, network, or synchronous disk access in inference paths. */ export function setAIRequestSink(next: Sink | undefined): void { @@ -30,6 +31,14 @@ export function setAIRequestSink(next: Sink | undefined): void { export function invalidateAIRequests(): void { epoch++ } +/** + * Run work the request log must never keep: not its input, not its output, not that it happened. + * Every request started inside, however deeply nested, is left out. For audio the user never + * meant to send anywhere, such as the always-on wake word check. + */ +export function withoutAIRequestLog(fn: () => T): T { + return unrecorded.run(true, fn) +} export function currentAIRequest(): AIRequestHandle | undefined { return context.getStore() } @@ -82,7 +91,7 @@ export function snapshotAIValue(value: unknown): unknown { export class AIRequestHandle { readonly id = randomUUID() private readonly generation = epoch - private readonly enabled = !!sink + private readonly enabled = !!sink && unrecorded.getStore() !== true private done = false private content = '' private reasoning = '' diff --git a/src/main/api-docs.ts b/src/main/api-docs.ts index 0ec6e5143..78bea7c69 100644 --- a/src/main/api-docs.ts +++ b/src/main/api-docs.ts @@ -192,6 +192,8 @@ On-device models behind the API (all local; none are cloud-hosted): Every response carries an \`X-Request-Id\`. Any POST can run async — \`?async=true\`, body \`"async": true\`, or header \`X-Async: true\` → \`202\` with a \`request_id\` and \`poll_url\`. Poll RESTfully with \`GET /v1/requests/{request_id}\` (canonical) or the per-collection resource (e.g. \`GET /v1/images/{id}\`). There is no \`/poll\` verb — you read the resource. +Cancel an image request with \`DELETE /v1/requests/{request_id}\`. The response carries \`request_id\`, \`status\`, and \`cancelled\`. A stopped request polls as \`failed\` with \`error.type: cancelled\`. Repeating cancellation is safe. Completed work stays completed; unknown IDs return 404; other modalities return 409. Closing an async response does not cancel its work. + ## Performance & memory Models swap in/out (Apple Silicon unified memory): image generation pauses the LLM, TTS runs in a killable subprocess, STT is one-shot. HTTP timeouts are disabled so long diffusion runs and first-run downloads complete — use a client timeout ≥120s for images/TTS (or just use async + polling). While the LLM reloads after image gen, chat may briefly return \`502\` — retry after a moment.` @@ -429,7 +431,11 @@ Models swap in/out (Apple Silicon unified memory): image generation pauses the L cfg_scale: { type: 'number' }, negative_prompt: { type: 'string' }, model: { type: 'string', ...imgEnum }, - allow_unsafe_memory_override: { type: 'boolean', description: 'Run a remote image request after confirming its memory-limit warning.' }, + allow_unsafe_memory_override: { + type: 'boolean', + description: + 'Run a remote image request after confirming its memory-limit warning.' + }, response_format: { type: 'string', enum: ['b64_json', 'url'], @@ -533,6 +539,20 @@ Models swap in/out (Apple Silicon unified memory): image generation pauses the L } }, '/v1/requests/{request_id}': { + delete: { + tags: ['Requests'], + summary: 'Cancel an image request', + description: + 'Stops only the named queued or running image request. Repeated cancellation is safe. Polling reports failed with error.type cancelled. Completed requests stay completed.', + parameters: [ + { name: 'request_id', in: 'path', required: true, schema: { type: 'string' } } + ], + responses: { + '200': { description: 'Cancellation result with request_id, status and cancelled.' }, + '404': errorResponse, + '409': errorResponse + } + }, get: { tags: ['Requests'], summary: 'Poll a request (RESTful)', diff --git a/src/main/artifact-preview.test.ts b/src/main/artifact-preview.test.ts index fe7c0ecbd..8914d4cc1 100644 --- a/src/main/artifact-preview.test.ts +++ b/src/main/artifact-preview.test.ts @@ -9,7 +9,7 @@ import { serveArtifactPreview } from './artifact-preview' import { createRendererContentSecurityPolicy } from '../shared/renderer-csp' -import { MEDIA_PORT } from '../shared/ports' +import { MEDIA_PORT, MEDIA_PORT_RANGE } from '../shared/ports' describe('renderer content security policy', () => { it('keeps executable artifact permissions out of the trusted renderer', () => { @@ -24,6 +24,16 @@ describe('renderer content security policy', () => { expect(policy).toContain(`img-src 'self' data: blob: ogcapture: http://127.0.0.1:${MEDIA_PORT}`) }) + it('admits the media port a second instance falls back to', () => { + const policy = createRendererContentSecurityPolicy('test-nonce') + for (const directive of ['img-src', 'media-src', 'frame-src', 'connect-src']) { + const sources = policy.split('; ').find((part) => part.startsWith(directive)) ?? '' + expect(sources).toContain(`http://127.0.0.1:${MEDIA_PORT + 1}`) + expect(sources).toContain(`http://127.0.0.1:${MEDIA_PORT + MEDIA_PORT_RANGE - 1}`) + expect(sources).not.toContain(`http://127.0.0.1:${MEDIA_PORT + MEDIA_PORT_RANGE} `) + } + }) + it('limits artifact network access to the exact package runtime host', () => { expect(ARTIFACT_CONTENT_SECURITY_POLICY).toContain("default-src 'none'") expect(ARTIFACT_CONTENT_SECURITY_POLICY).toContain('https://esm.sh') diff --git a/src/main/bootstrap/hookRegistry.ts b/src/main/bootstrap/hookRegistry.ts index d415bd713..ac4aa7f78 100644 --- a/src/main/bootstrap/hookRegistry.ts +++ b/src/main/bootstrap/hookRegistry.ts @@ -47,6 +47,8 @@ export async function callHookAsync( /** Known hook names, centralised so core and pro stay in sync. */ export const HOOKS = { + /** The main window was shown at startup (not a hidden launch). */ + mainWindowShown: 'app:mainWindowShown', /** (basePrompt: string, query: string) => Promise — augment the chat * system/context with captured memory + entity/observation context (pro). */ chatAugmentContext: 'chat.augmentContext', @@ -57,6 +59,16 @@ export const HOOKS = { * providers own verification, read-tool discovery, and execution through their supported protocol. */ mcpConnectorToolSource: 'mcp:connectorToolSource', + /** + * (connectorId: number, url: string | null) => void - a connector is about to be removed, while + * its stored sign-in still exists: Pro revokes provider tokens that support it (Google). + */ + mcpBeforeRemove: 'mcp:beforeRemove', + /** + * (connectorId: number) => { services: string[]; missing: string[] } | undefined - what an + * account can read and what was not granted, named for people (Gmail, Calendar). Pro. + */ + accountAccess: 'accounts:access', /** (mutation: SyncMutation) => void - record a committed core data change in Pro sync. */ syncRecordLocalMutation: 'sync.recordLocalMutation', /** diff --git a/src/main/browser/__tests__/browser-start-tab.test.ts b/src/main/browser/__tests__/browser-start-tab.test.ts new file mode 100644 index 000000000..eeebee256 --- /dev/null +++ b/src/main/browser/__tests__/browser-start-tab.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { actionArgsWithStartTab, startTabFromActionArgs } from '../browser-start-tab' + +describe('a web task carries the tab its chat offered', () => { + it('travels inside that task: two chats in one browser keep their own tabs', () => { + // Review finding: one offer per browser let a second chat's task take the first's tab. + const first = actionArgsWithStartTab({ goal: 'a' }, { browserId: 'chrome-1', tabId: 11 }) + const second = actionArgsWithStartTab({ goal: 'b' }, { browserId: 'chrome-1', tabId: 22 }) + expect(startTabFromActionArgs(first)).toEqual({ browserId: 'chrome-1', tabId: 11 }) + expect(startTabFromActionArgs(second)).toEqual({ browserId: 'chrome-1', tabId: 22 }) + }) + + it('never keeps a start tab written by the model, and reads nothing malformed', () => { + const forged = { + goal: 'x', + __offgridStartBrowserId: 'someone-else', + __offgridStartTabId: 99 + } + expect(startTabFromActionArgs(actionArgsWithStartTab(forged))).toBeNull() + expect( + startTabFromActionArgs({ __offgridStartBrowserId: 'b', __offgridStartTabId: -1 }) + ).toBeNull() + expect(startTabFromActionArgs(null)).toBeNull() + }) +}) diff --git a/src/main/browser/__tests__/extension-tab-pages.test.ts b/src/main/browser/__tests__/extension-tab-pages.test.ts index 586dbfc03..5dccceeca 100644 --- a/src/main/browser/__tests__/extension-tab-pages.test.ts +++ b/src/main/browser/__tests__/extension-tab-pages.test.ts @@ -27,6 +27,11 @@ function fakeLink(): FakeLink { if (op === 'tab.create') { return { tabId: ++nextTab, url: String(args.url), title: '' } } + if (op === 'tab.adopt') { + // The extension hands over only the tab it offered (its start-tab.ts fence). + if (args.tabId !== 7) throw new Error('That tab was not offered for this task.') + return { tabId: 7, url: String(args.url ?? 'https://chat.example.test/'), title: 'Chat' } + } if (op === 'cdp.send' && args.method === 'Target.getTargetInfo') { return { targetInfo: { targetId: `T${String(args.tabId)}` } } } @@ -74,6 +79,26 @@ describe('extension tab pages under the Playwright relay', () => { }) }) + it('starts in the tab the browser offered, without opening a new one', async () => { + const link = fakeLink() + const provider = createExtensionPageProvider(link) + const tab = await provider.adopt(7, 'task-1') + + // The task id names whose offer this is, so a task queued for minutes still gets its tab. + expect(link.calls).toEqual([{ op: 'tab.adopt', args: { tabId: 7, taskId: 'task-1' } }]) + expect([tab.tabId, tab.getURL()]).toEqual([7, 'https://chat.example.test/']) + expect(provider.active()).toBe(tab) + + // A task that names a page takes the tab there; a tab never offered is refused. + await provider.adopt(7, 'task-1', 'https://shop.example.test/') + expect(link.calls.at(-1)).toEqual({ + op: 'tab.adopt', + args: { tabId: 7, taskId: 'task-1', url: 'https://shop.example.test/' } + }) + await expect(provider.adopt(8, 'task-1')).rejects.toThrow(/not offered/) + expect(link.calls.some((c) => c.op === 'tab.create')).toBe(false) + }) + it("forwards only this tab's CDP events, on Playwright's page session", async () => { const link = fakeLink() const provider = createExtensionPageProvider(link) diff --git a/src/main/browser/__tests__/web-use-host.test.ts b/src/main/browser/__tests__/web-use-host.test.ts new file mode 100644 index 000000000..c33075dae --- /dev/null +++ b/src/main/browser/__tests__/web-use-host.test.ts @@ -0,0 +1,70 @@ +// Which browser a web task runs in. The browser hosts, the live links and the settings are the +// boundaries replaced; the choice itself (web-use-target.ts) runs for real. +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const world = vi.hoisted(() => ({ + target: 'default_browser' as 'in_app' | 'default_browser', + links: [] as Array<{ browser: { id: string; name: string } }>, + inApp: vi.fn(), + extension: vi.fn(), + tabs: [] as Array +})) + +vi.mock('../../web-use-settings', () => ({ + getWebUseSettings: () => ({ browserTarget: world.target }) +})) +vi.mock('../../extension-bridge/bridge-electron', () => ({ getBrowserLinks: () => world.links })) +vi.mock('../../accessibility/ax-host', () => ({ defaultBrowserTarget: async () => null })) +vi.mock('../browser-host', () => ({ getBrowserRailHost: () => ({ runTask: world.inApp }) })) +vi.mock('../extension-browser-host', () => ({ + createExtensionBrowserHost: (_link: unknown, tabId?: number) => { + world.tabs.push(tabId) + return { runTask: world.extension } + } +})) + +import { getWebUseRailHost } from '../web-use-host' +import { DEFAULT_BROWSER_FALLBACK_NOTE } from '../../../shared/web-use-settings' + +const request = { goal: 'Find the release notes', taskId: 'task-1', journeyId: 'journey-1' } + +beforeEach(() => { + world.target = 'default_browser' + world.links = [] + world.tabs = [] + world.inApp.mockReset().mockResolvedValue({ ok: true }) + world.extension.mockReset().mockResolvedValue({ ok: true }) +}) + +describe('web task browser choice', () => { + it('runs here when the default browser is chosen but none is connected, and says so', async () => { + await getWebUseRailHost().runTask(request) + expect(world.extension).not.toHaveBeenCalled() + expect(world.inApp).toHaveBeenCalledWith({ ...request, notice: DEFAULT_BROWSER_FALLBACK_NOTE }) + }) + + it('runs in the connected browser when the default browser is chosen', async () => { + world.links = [{ browser: { id: 'chrome-1', name: 'Chrome extension' } }] + await getWebUseRailHost().runTask(request) + expect(world.extension).toHaveBeenCalledWith(request) + expect(world.inApp).not.toHaveBeenCalled() + }) + + it('adds no note when the Off Grid AI browser is the choice', async () => { + world.target = 'in_app' + world.links = [{ browser: { id: 'chrome-1', name: 'Chrome extension' } }] + await getWebUseRailHost().runTask(request) + expect(world.inApp).toHaveBeenCalledWith(request) + }) + + it('runs a task in the tab its own chat offered, whatever the setting', async () => { + world.target = 'in_app' + world.links = [{ browser: { id: 'chrome-1', name: 'Chrome extension' } }] + await getWebUseRailHost().runTask({ + ...request, + startTab: { browserId: 'chrome-1', tabId: 42 } + }) + expect(world.tabs).toEqual([42]) + expect(world.inApp).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/browser/browser-host.ts b/src/main/browser/browser-host.ts index e100f19f2..b58964e21 100644 --- a/src/main/browser/browser-host.ts +++ b/src/main/browser/browser-host.ts @@ -827,6 +827,7 @@ class BrowserHost implements BrowserRailHost { } satisfies BrowserTaskState) broadcast('browser:step', { sessionId: record.sessionId, taskId, journeyId, note }) } + if (request.notice) recordStep(request.notice) releaseSession = registerVisionSession( taskId, guard, diff --git a/src/main/browser/browser-playwright-task.ts b/src/main/browser/browser-playwright-task.ts index e9c73a3e2..4cf7ad798 100644 --- a/src/main/browser/browser-playwright-task.ts +++ b/src/main/browser/browser-playwright-task.ts @@ -129,7 +129,7 @@ async function runSemanticStep( input.signal?.throwIfAborted() await input.guard.waitUntilRunnable(input.signal) if (input.guard.isHalted) input.signal?.throwIfAborted() - input.onProgress(step, 'thinking', 'Choosing the next semantic action') + input.onProgress(step, 'thinking', 'Deciding the next step') const modelLease = input.guard.currentActionLease() let decision: SemanticDecision try { @@ -295,7 +295,9 @@ async function performSemanticAction( state.observation = await observeWithCurrentLease(input) return undefined } - input.onStep(`${actionLabel(decision)}: ${result.isError ? 'not completed' : 'completed'}`) + // The model writes its summary as a sentence; drop its full stop before the status joins it. + const label = actionLabel(decision).replace(/[.!]+$/, '') + input.onStep(`${label}: ${result.isError ? 'not completed' : 'completed'}`) if (result.isError) return recoverActionError(input, state, result) const before = fingerprint(state.observation.text) diff --git a/src/main/browser/browser-rail.ts b/src/main/browser/browser-rail.ts index 4ac0c1937..6408ea2be 100644 --- a/src/main/browser/browser-rail.ts +++ b/src/main/browser/browser-rail.ts @@ -13,6 +13,7 @@ * is the status) - the same double-fire protection sends already rely on. */ import { WEB_USE_ACTION_TYPE, type ActionRecord, type HandlerRegistry } from '@offgrid/use' +import { startTabFromActionArgs, type StartTab } from './browser-start-tab' import type { ExecuteResult } from '@offgrid/use' import type { TaskRetryCheckpoint } from '../tasks/task-retry' @@ -30,6 +31,10 @@ export interface BrowserTaskRequest { taskId: string journeyId: string checkpoint?: TaskRetryCheckpoint + /** The paired browser's tab this task should run in (the chat that asked); absent otherwise. */ + startTab?: StartTab + /** Shown as the task's first step: why it runs where it does, when that is not what was chosen. */ + notice?: string } export interface BrowserRailHost { @@ -62,11 +67,14 @@ export function makeBrowserRailExecutor( const goal = typeof args.goal === 'string' && args.goal.trim() ? args.goal : action.intent const url = typeof args.url === 'string' && /^https?:\/\//i.test(args.url) ? args.url : undefined + // The tab its chat offered travels with this task alone (browser-start-tab.ts). + const startTab = startTabFromActionArgs(args) const result = await host.runTask({ goal, url, taskId: action.id, - journeyId: action.sourceRef ?? action.id + journeyId: action.sourceRef ?? action.id, + ...(startTab ? { startTab } : {}) }) if (!result.ok) { return { ok: false, detail: result.summary } diff --git a/src/main/browser/browser-start-tab.ts b/src/main/browser/browser-start-tab.ts new file mode 100644 index 000000000..9c3f9b894 --- /dev/null +++ b/src/main/browser/browser-start-tab.ts @@ -0,0 +1,36 @@ +// The browser tab a paired browser offered for a web task: the chat's tab, so the task works where +// the user is. It travels inside that one task's action arguments, set by the extension bridge +// and read when the task starts, so two chats in one browser never trade tabs, however their +// tasks queue. Mirrors the extension's start-tab.ts, whose fence lets the desktop take that tab +// (socket op tab.adopt). Pure. + +export interface StartTab { + /** The paired browser whose tab this is: the task must run over that browser's link. */ + readonly browserId: string + readonly tabId: number +} + +const BROWSER_ARG = '__offgridStartBrowserId' +const TAB_ARG = '__offgridStartTabId' + +/** Task arguments carrying `tab`, and never a start tab the model wrote itself. */ +export function actionArgsWithStartTab( + args: Record, + tab?: StartTab +): Record { + const clean = { ...args } + delete clean[BROWSER_ARG] + delete clean[TAB_ARG] + return tab ? { ...clean, [BROWSER_ARG]: tab.browserId, [TAB_ARG]: tab.tabId } : clean +} + +/** The tab a task's arguments carry, or null. */ +export function startTabFromActionArgs(args: unknown): StartTab | null { + if (!args || typeof args !== 'object' || Array.isArray(args)) return null + const fields = args as Record + const browserId = typeof fields[BROWSER_ARG] === 'string' ? fields[BROWSER_ARG] : '' + const tabId = fields[TAB_ARG] + return browserId && typeof tabId === 'number' && Number.isSafeInteger(tabId) && tabId >= 0 + ? { browserId, tabId } + : null +} diff --git a/src/main/browser/extension-browser-host.ts b/src/main/browser/extension-browser-host.ts index 741a3f9c1..f975a5c14 100644 --- a/src/main/browser/extension-browser-host.ts +++ b/src/main/browser/extension-browser-host.ts @@ -79,7 +79,14 @@ export async function openTaskSession( } } -export function createExtensionBrowserHost(link: () => BrowserLink | null): BrowserRailHost { +/** + * `startTabId`: the tab the browser offered for this task (browser-start-tab.ts). The task + * starts there instead of in a new tab; without it, a new tab opens as before. + */ +export function createExtensionBrowserHost( + link: () => BrowserLink | null, + startTabId?: number +): BrowserRailHost { return { async runTask(request: BrowserTaskRequest): Promise { const browser = link() @@ -92,14 +99,15 @@ export function createExtensionBrowserHost(link: () => BrowserLink | null): Brow finalUrl: '' } } - return runInBrowser(browser, request) + return runInBrowser(browser, request, startTabId) } } } async function runInBrowser( link: BrowserLink, - request: BrowserTaskRequest + request: BrowserTaskRequest, + startTabId: number | undefined ): Promise { const { goal, url, taskId, journeyId, checkpoint } = request const guard = new VisionGuard({ taskId, kind: 'web_use' }) @@ -141,15 +149,22 @@ async function runInBrowser( try { setState('running', `Working in ${link.browser.name}`) - const start = url ?? START_URL const plan = checkpoint?.plan ?? (await prepareTaskExecutionPlan( { goal: retryPlanningGoal(goal, checkpoint), surface: 'web', signal: controller.signal }, recordStep )) - const tab = await pages.open(start) - recordStep(`opened ${start} in ${link.browser.name}`) + let tab: ExtensionTabContents + if (startTabId === undefined) { + const start = url ?? START_URL + tab = await pages.open(start) + recordStep(`opened ${start} in ${link.browser.name}`) + } else { + // The user's own tab, left where it is unless the task names a page. + tab = await pages.adopt(startTabId, taskId, url) + recordStep(`working in your tab in ${link.browser.name}`) + } const task = await openTaskSession(link, pages, tab) const semantic = await runBrowserPlaywrightTask({ goal, diff --git a/src/main/browser/extension-tab-pages.ts b/src/main/browser/extension-tab-pages.ts index b2a9a2674..855b78a5c 100644 --- a/src/main/browser/extension-tab-pages.ts +++ b/src/main/browser/extension-tab-pages.ts @@ -179,36 +179,54 @@ export class ExtensionTabContents implements RelayContents { } /** - * The tabs one task owns in the user's browser. It starts with the tab it opens and grows - * with any tab the page opens through Playwright. It never sees the user's other tabs. + * The tabs one task owns in the user's browser. It starts with the tab it opens, or the tab the + * browser offered for it (browser-start-tab.ts), and grows with any tab the page opens through + * Playwright. It never sees the user's other tabs. */ export function createExtensionPageProvider(link: BrowserLink): ElectronPlaywrightPageProvider & { open(url: string): Promise + /** Start in the offered tab instead of a new one, at `url` when the task names one. */ + /** `taskId`: the task the browser offered this tab to; only that task's offer lets it in. */ + adopt(tabId: number, taskId: string, url?: string): Promise active(): ExtensionTabContents | undefined closeAll(): Promise } { const pages = new Map() - const open = async (url: string): Promise => { - const info = await link.request('tab.create', { url }) - if (!isTabInfo(info)) throw new Error('The browser did not open a tab.') + const track = (info: unknown, refusal: string): ExtensionTabContents => { + if (!isTabInfo(info)) throw new Error(refusal) const contents = new ExtensionTabContents(link, info) pages.set(info.tabId, contents) contents.once('destroyed', () => pages.delete(info.tabId)) return contents } + const open = async (url: string): Promise => + track(await link.request('tab.create', { url }), 'The browser did not open a tab.') + const adopt = async ( + tabId: number, + taskId: string, + url?: string + ): Promise => + track( + await link.request('tab.adopt', { tabId, taskId, ...(url === undefined ? {} : { url }) }), + 'The browser did not hand over its tab.' + ) const asPage = (contents: ExtensionTabContents): RelayPage => ({ id: contents.tabId, contents }) return { pages: () => [...pages.values()].filter((p) => !p.isDestroyed()).map(asPage), create: async (url) => asPage(await open(url)), close: async (id) => { - pages.delete(id) + const page = pages.get(id) + if (!page) return + await link.request('cdp.detach', { tabId: id }) await link.request('tab.close', { tabId: id }) + page.destroy() }, open, + adopt, active: () => [...pages.values()].filter((p) => !p.isDestroyed()).at(-1), async closeAll() { for (const p of [...pages.values()]) { - p.debugger.detach() + await link.request('cdp.detach', { tabId: p.tabId }).catch(() => undefined) p.destroy() } pages.clear() diff --git a/src/main/browser/web-use-host.ts b/src/main/browser/web-use-host.ts index c99107528..244d01473 100644 --- a/src/main/browser/web-use-host.ts +++ b/src/main/browser/web-use-host.ts @@ -1,10 +1,12 @@ // The one place web_use picks its browser for a task: the user's default browser through the // paired extension when Tasks > Web Use says so and it is connected, otherwise Off Grid AI's -// own browser. Wiring only; the choice is web-use-target.ts (tested). +// own browser. A task a paired browser started in one of its tabs runs in that browser and that +// tab, whatever the setting: the tab travels with its task (browser-start-tab.ts). Wiring only; the choice is web-use-target.ts (tested). import { defaultBrowserTarget } from '../accessibility/ax-host' import { getBrowserLinks } from '../extension-bridge/bridge-electron' import { getWebUseSettings } from '../web-use-settings' +import { DEFAULT_BROWSER_FALLBACK_NOTE } from '../../shared/web-use-settings' import { getBrowserRailHost } from './browser-host' import type { BrowserRailHost } from './browser-rail' import { createExtensionBrowserHost } from './extension-browser-host' @@ -15,14 +17,27 @@ export function getWebUseRailHost(): BrowserRailHost { async runTask(request) { const { browserTarget } = getWebUseSettings() const links = getBrowserLinks() + // A chat that offered its own tab is asking about that page, with its sign-ins: the offer + // wins over the general setting, which decides only for tasks nobody pointed anywhere. + const offered = request.startTab + const offeredLink = offered + ? links.find((l) => l.browser.id === offered.browserId) + : undefined + if (offered && offeredLink) { + return createExtensionBrowserHost(() => offeredLink, offered.tabId).runTask(request) + } const defaultName = browserTarget === 'default_browser' && links.length > 1 ? ((await defaultBrowserTarget().catch(() => null))?.name ?? null) : null const link = pickBrowserLink(browserTarget, links, defaultName) - return link - ? createExtensionBrowserHost(() => link).runTask(request) - : getBrowserRailHost().runTask(request) + if (link) return createExtensionBrowserHost(() => link).runTask(request) + // The default browser was chosen but none is connected: run here, and say so on the task. + return getBrowserRailHost().runTask( + browserTarget === 'default_browser' + ? { ...request, notice: DEFAULT_BROWSER_FALLBACK_NOTE } + : request + ) } } } diff --git a/src/main/computer-use-settings.ts b/src/main/computer-use-settings.ts index 56bbad977..7f16aa4eb 100644 --- a/src/main/computer-use-settings.ts +++ b/src/main/computer-use-settings.ts @@ -1,4 +1,5 @@ import { getSetting, saveSetting } from './database' +import { announceTaskSettings } from './task-settings-events' import { COMPUTER_USE_SETTINGS_KEY, DEFAULT_COMPUTER_USE_SETTINGS, @@ -19,6 +20,7 @@ export function setComputerUseSettings( ): ComputerUseSettings { const normalized = normalizeComputerUseSettings(value) saveSetting(COMPUTER_USE_SETTINGS_KEY, normalized) + announceTaskSettings(COMPUTER_USE_SETTINGS_KEY, normalized) if (options.emitSync !== false) { emitSyncMutation({ entity: CORE_SYNC_ENTITIES.modelSetting, diff --git a/src/main/database.ts b/src/main/database.ts index 86f1d4a23..f64c2fed7 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -9,6 +9,7 @@ import crypto from 'crypto' import { createSettingsStore, initializeSettingsStore } from './settings-store' import { CORE_SYNC_ENTITIES, emitSyncMutation } from './sync-mutation' import type { + ConversationSurface, RagConversationContract, RagMessageContract, UserProfileContract @@ -442,6 +443,12 @@ export function getDB(): Database.Database { } catch { // Column already exists, ignore } + // Migration: which screen a conversation belongs to. NULL is Chat; 'god' is a God conversation. + try { + db.exec(`ALTER TABLE rag_conversations ADD COLUMN surface TEXT`) + } catch { + // Column already exists, ignore + } for (const column of ['origin_device_id', 'origin_device_name']) { try { db.exec(`ALTER TABLE rag_conversations ADD COLUMN ${column} TEXT`) @@ -1127,20 +1134,38 @@ export function saveUserProfile(profile: UserProfile): void { // === RAG CONVERSATIONS === export type RagConversation = RagConversationContract +export type { ConversationSurface } + +/** Move existing conversations to a screen, e.g. God conversations kept before the column. */ +export function setRagConversationsSurface( + ids: readonly string[], + surface: ConversationSurface +): void { + if (!ids.length) return + const statement = getDB().prepare('UPDATE rag_conversations SET surface = ? WHERE id = ?') + getDB().transaction(() => { + for (const id of ids) statement.run(surface === 'god' ? 'god' : null, id) + })() + // Paired devices learn the move too, or the conversation stays on the other screen there. + for (const id of ids) { + emitSyncMutation({ entity: CORE_SYNC_ENTITIES.conversation, entityId: id, kind: 'put' }) + } +} export type RagMessage = RagMessageContract export function createRagConversation( id: string, title?: string, - projectId?: string | null + projectId?: string | null, + surface?: ConversationSurface ): string { const db = getDB() db.prepare( ` - INSERT INTO rag_conversations (id, title, project_id, created_at, updated_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + INSERT INTO rag_conversations (id, title, project_id, surface, created_at, updated_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ` - ).run(id, title || null, projectId || null) + ).run(id, title || null, projectId || null, surface === 'god' ? 'god' : null) emitSyncMutation({ entity: CORE_SYNC_ENTITIES.conversation, entityId: id, kind: 'put' }) return id } @@ -1175,11 +1200,15 @@ function conversationPageLimit(page?: RagConversationPage): number { */ export function getRagConversations( projectId?: string | null, - page?: RagConversationPage + page?: RagConversationPage, + surface?: ConversationSurface ): RagConversation[] { const db = getDB() const conditions: string[] = [] const parameters: unknown[] = [] + // Omitted: every conversation. Chat lists its own and leaves God's to God. + if (surface === 'god') conditions.push("rc.surface = 'god'") + else if (surface === 'chat') conditions.push("(rc.surface IS NULL OR rc.surface <> 'god')") if (projectId === null) conditions.push('rc.project_id IS NULL') else if (projectId !== undefined) { conditions.push('rc.project_id = ?') @@ -1196,8 +1225,8 @@ export function getRagConversations( // another; both joins below are index lookups over the page's conversations only. const stmt = db.prepare(` WITH page AS ( - SELECT rc.id, rc.title, rc.project_id, rc.origin_device_id, rc.origin_device_name, - rc.created_at, rc.updated_at + SELECT rc.id, rc.title, rc.project_id, rc.surface, rc.origin_device_id, + rc.origin_device_name, rc.created_at, rc.updated_at FROM rag_conversations rc ${where} ORDER BY rc.updated_at DESC @@ -1207,6 +1236,7 @@ export function getRagConversations( page.id, page.title, page.project_id, + page.surface, page.origin_device_id, page.origin_device_name, page.created_at, @@ -1244,7 +1274,8 @@ export function getRagConversation(id: string): RagConversation | null { return db .prepare( ` - SELECT id, title, project_id, origin_device_id, origin_device_name, created_at, updated_at + SELECT id, title, project_id, surface, origin_device_id, origin_device_name, created_at, + updated_at FROM rag_conversations WHERE id = ? ` @@ -1346,14 +1377,32 @@ export function searchProjectConversations( limit = 6 ): { role: string; content: string; title: string | null }[] { const stopWords = new Set([ - 'about', 'and', 'are', 'did', 'for', 'from', 'how', 'our', 'project', - 'the', 'this', 'was', 'what', 'when', 'where', 'who', 'with', 'you' + 'about', + 'and', + 'are', + 'did', + 'for', + 'from', + 'how', + 'our', + 'project', + 'the', + 'this', + 'was', + 'what', + 'when', + 'where', + 'who', + 'with', + 'you' ]) const terms = [...new Set(query.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [])] .filter((term) => term.length > 2 && !stopWords.has(term)) .slice(0, 6) if (!terms.length) return [] - const score = terms.map(() => '(CASE WHEN lower(rm.content) LIKE ? THEN 1 ELSE 0 END)').join(' + ') + const score = terms + .map(() => '(CASE WHEN lower(rm.content) LIKE ? THEN 1 ELSE 0 END)') + .join(' + ') const patterns = terms.map((term) => `%${term}%`) return getDB() .prepare( @@ -1365,7 +1414,13 @@ export function searchProjectConversations( ORDER BY (${score}) DESC, rm.created_at DESC, rm.id DESC LIMIT ?` ) - .all(projectId, excludeConversationId, ...patterns, ...patterns, Math.min(12, Math.max(1, limit))) as { + .all( + projectId, + excludeConversationId, + ...patterns, + ...patterns, + Math.min(12, Math.max(1, limit)) + ) as { role: string content: string title: string | null diff --git a/src/main/dev-seed.ts b/src/main/dev-seed.ts index 3f1dc83c8..5b648c0a0 100644 --- a/src/main/dev-seed.ts +++ b/src/main/dev-seed.ts @@ -15,13 +15,22 @@ import { getSetting, saveSetting } from './database' -import { createProject, deleteProject } from './rag/store' +import { createProject, deleteProject, listProjects } from './rag/store' import { saveArtifact, listArtifacts, deleteArtifact } from './artifacts' import { saveSkill } from './skills' import { addConnector, listConnectors } from './mcp' import { llm } from './llm' import { generateImage, listImageModels } from './imagegen' import { ragService } from './rag/index' +import { + getTaskExecutionDevice, + getTaskRun, + recordTaskRun, + taskScreenshotPath +} from './tasks/task-history' +import { encodeTaskExecutionPlan, encodeTaskPhase } from '../shared/task-execution-plan' +import sharp from 'sharp' +import { getRemoteVisionServerSettings, setRemoteVisionServerSettings } from './vision/remote-vision-server' const PROJECT_ID = 'offgrid-demo' @@ -122,7 +131,7 @@ async function seedKnowledge(): Promise { write: () => fs.writeFileSync( path.join(dir, 'offgrid-faq.txt'), - 'Off Grid AI — FAQ\n\nQ: Does anything leave my device?\nA: No. All inference runs locally; no cloud, no accounts, no API keys.\n\nQ: What can it run?\nA: Open models for text, vision, image, voice and speech, via one OpenAI-compatible gateway on 127.0.0.1:7878.\n\nQ: What is Pro?\nA: The sees/remembers/acts layer — capture, unified search, a proactive secretary — live now, $49/year or $69 once.\n' + 'Off Grid AI — FAQ\n\nQ: Does anything leave my device?\nA: No. All inference runs locally; no cloud, no accounts, no API keys.\n\nQ: What can it run?\nA: Open models for text, vision, image, voice and speech, via one OpenAI-compatible gateway on 127.0.0.1:7878.\n\nQ: What is Pro?\nA: The sees/remembers/acts layer — capture, unified search, a proactive secretary — live now, $119 once or $7.99/month.\n' ) }, { @@ -160,6 +169,144 @@ function cleanup(): void { } export async function seedDemo(live = false): Promise { + if (getRemoteVisionServerSettings().servers.length === 0) { + setRemoteVisionServerSettings({ + serverId: 'demo-acme-private-models', + name: 'Acme private models', + provider: 'custom', + endpoint: 'https://models.acme.example/v1', + model: 'Qwen 3.5 9B', + mediaModels: { text: 'Qwen 3.5 9B', image: 'Qwen Image 3', voice: 'Kokoro' }, + modelCatalog: [ + { id: 'Qwen 3.5 9B', name: 'Qwen 3.5 9B', kind: 'text' }, + { id: 'Qwen Image 3', name: 'Qwen Image 3', kind: 'image' }, + { id: 'Kokoro', name: 'Kokoro', kind: 'voice' } + ] + }) + } + // Marketing scenes use the normal data owners, so files, chats and task history also travel by Sync. + const acmeProjectId = 'demo-acme-pilot' + if (!listProjects().some((project) => project.id === acmeProjectId)) { + createProject({ + id: acmeProjectId, + name: 'Acme Corp pilot', + description: 'Synthetic customer pilot for Alex at Off Grid AI.' + }) + } + const acmeChats = [ + [ + 'promises', + 'What did I promise Sam?', + 'What did I promise Sam?', + 'You made three commitments to Sam:\n\n1. Send the pilot scope for 40 seats, starting on 14 November.\n2. Confirm the owners and checks: Priya owns the rollout, and Tom verifies the gateway policy.\n3. Arrange a kickoff call next Tuesday.' + ], + [ + 'owners', + 'Acme pilot owners and kickoff', + 'Who owns the rollout and when does the pilot start?', + 'Priya Nair owns the rollout plan. Tom Reyes verifies the gateway policy before kickoff. The pilot starts on 14 November with 40 seats. Source: Acme_rollout_v3.pdf.' + ], + [ + 'translation', + 'Translate the pilot email', + 'Translate my pilot update into Spanish. Keep the names unchanged.', + 'Hola Sam:\n\nEl piloto de Acme Corp comienza el 14 de noviembre con 40 plazas. Priya Nair se encarga del plan de despliegue y Tom Reyes verifica la política de la pasarela antes de la reunión inicial. Te enviaré el plan el viernes a las 10:00. Confirmemos la reunión inicial para el próximo martes.\n\nAlex' + ], + [ + 'reply', + 'Reply to Sam', + 'Draft a short reply to Sam confirming the pilot scope and next steps.', + 'Hi Sam,\n\nThe Acme Corp pilot starts on 14 November with 40 seats. Priya owns the rollout plan, and Tom will verify the gateway policy before kickoff. I will send the rollout plan on Friday at 10 AM. Let us confirm the kickoff call for next Tuesday.\n\nAlex' + ] + ] as const + const conversations = getRagConversations() + for (const [slug, title, prompt, answer] of acmeChats) { + const id = `demo-acme-${slug}` + if (conversations.some((chat) => chat.id === id)) continue + createRagConversation(id, title, acmeProjectId) + addRagMessage(id, 'user', prompt) + addRagMessage(id, 'assistant', answer) + } + const rolloutPdf = path.join(app.getAppPath(), 'resources', 'demo', 'Acme_rollout_v3.pdf') + if (fs.existsSync(rolloutPdf)) { + const documents = await ragService.listDocuments(acmeProjectId) + if (!documents.some((document) => document.name === 'Acme_rollout_v3.pdf')) { + await ragService.indexDocument({ + projectId: acmeProjectId, + path: rolloutPdf, + fileName: 'Acme_rollout_v3.pdf', + size: fs.statSync(rolloutPdf).size + }) + } + } + const taskId = 'demo-acme-reminder' + const frame = path.join(app.getAppPath(), 'resources', 'demo', 'acme-reminder.png') + if (!getTaskRun(taskId) && fs.existsSync(frame)) { + const conversationId = 'demo-acme-computer-use' + if (!getRagConversations().some((chat) => chat.id === conversationId)) { + createRagConversation(conversationId, 'Send Sam the rollout plan', acmeProjectId, 'god') + addRagMessage( + conversationId, + 'user', + 'Create reminder: Send Sam the rollout plan, Friday 10 AM' + ) + } + const device = getTaskExecutionDevice() + const screenshotPath = taskScreenshotPath(taskId, '4') + fs.copyFileSync(frame, screenshotPath) + const metadata = await sharp(frame).metadata() + const width = metadata.width + const height = metadata.height + const phases = [ + 'Open Reminders', + 'Create the rollout reminder', + 'Set Friday at 10 AM', + 'Check the reminder' + ].map((title, index) => ({ id: String(index + 1), title })) + const at = Date.now() + // A completed synthetic history item. It must not claim that a live controller is running. + recordTaskRun({ + taskId, + journeyId: conversationId, + kind: 'computer_use', + status: 'done', + title: 'Create reminder: Send Sam the rollout plan, Friday 10 AM', + summary: 'Created the reminder for Friday at 10 AM.', + executionDeviceId: device.id, + executionDeviceName: device.name, + phase: 'complete', + currentStep: 4, + screenshotPath, + screenshotDeviceId: device.id, + at, + steps: [ + encodeTaskExecutionPlan({ version: 1, phases }), + ...phases.flatMap((phase) => [encodeTaskPhase(phase.id), phase.title]) + ], + stepDetails: phases.map((phase, index) => ({ + stepId: phase.id, + at: at - (3 - index) * 1000, + phase: 'complete', + decisionSummary: phase.title, + execution: { status: 'complete', result: phase.title }, + ...(index === 3 + ? { + screenshot: { + path: screenshotPath, + availability: 'device_local', + executionDeviceId: device.id, + executionDeviceName: device.name, + originalWidth: width, + originalHeight: height, + inferenceWidth: width, + inferenceHeight: height + } + } + : {}) + })) + }) + } + if (!live && getSetting('demo:seeded', false)) { console.log('[seed] already seeded — skipping') return diff --git a/src/main/embeddings-env.ts b/src/main/embeddings-env.ts index deecd041d..162392fa9 100644 --- a/src/main/embeddings-env.ts +++ b/src/main/embeddings-env.ts @@ -16,6 +16,8 @@ import { env } from '@huggingface/transformers' */ export function configureTransformersEnv(modelsDir: string): void { env.localModelPath = modelsDir - env.allowRemoteModels = true // first run still downloads + // The first run still downloads. OFFGRID_EMBEDDINGS_OFFLINE=1 (tests that do not test embeddings) + // uses only what is already on disk, so a fresh temp folder fails fast instead of fetching ~23MB. + env.allowRemoteModels = process.env.OFFGRID_EMBEDDINGS_OFFLINE !== '1' env.cacheDir = path.join(modelsDir, '.cache') } diff --git a/src/main/extension-bridge/__tests__/bridge-conversations.test.ts b/src/main/extension-bridge/__tests__/bridge-conversations.test.ts index eaffd6df0..daeb94fa0 100644 --- a/src/main/extension-bridge/__tests__/bridge-conversations.test.ts +++ b/src/main/extension-bridge/__tests__/bridge-conversations.test.ts @@ -6,6 +6,9 @@ import { sqlTime, toBridgeConversation, turnsToAppend, + historyConflicts, + newerBrowserTitle, + type BridgeConversation, type BridgeTurn } from '../bridge-conversations' @@ -106,4 +109,64 @@ describe('turnsToAppend', () => { expect(turnsToAppend([u('q'), a('r')], [u('q')])).toEqual([]) expect(turnsToAppend([u('q'), a('r')], [u('different'), a('r'), u('x')])).toEqual([]) }) + + it("keeps syncing past the browser's 40-turn window", () => { + // Review finding: once the desktop had 40 turns, the browser's window of its latest 40 was + // never a superset, so new messages stopped syncing after about 20 exchanges. + const turn = (n: number): BridgeTurn => (n % 2 ? u(`q${n}`) : a(`r${n}`)) + const stored = Array.from({ length: 40 }, (_, i) => turn(i + 1)) + const window = Array.from({ length: 40 }, (_, i) => turn(i + 3)) + expect(turnsToAppend(stored, window)).toEqual([turn(41), turn(42)]) + expect(turnsToAppend(stored, stored)).toEqual([]) + }) + + it('takes the longest overlap, so a repeated short turn never re-adds old ones', () => { + expect( + turnsToAppend([u('ok'), a('done'), u('ok'), a('done')], [u('ok'), a('done'), u('next')]) + ).toEqual([u('next')]) + }) +}) + +describe('historyConflicts', () => { + const u = (content: string): BridgeTurn => ({ role: 'user', content }) + const a = (content: string): BridgeTurn => ({ role: 'assistant', content }) + + it('flags an edited or regenerated turn the desktop cannot apply', () => { + const stored = [u('q'), a('r'), u('q2'), a('r2')] + expect(historyConflicts(stored, [u('q'), a('r'), u('q2 edited'), a('r3')])).toBe(true) + expect(historyConflicts(stored, [u('q'), a('r'), u('q2'), a('regenerated')])).toBe(true) + }) + + it('accepts a continuation, an older window and a new chat', () => { + const stored = [u('q'), a('r'), u('q2'), a('r2')] + expect(historyConflicts(stored, [...stored, u('q3')])).toBe(false) + expect(historyConflicts(stored, [u('q'), a('r')])).toBe(false) + expect(historyConflicts(stored, [a('r'), u('q2')])).toBe(false) + expect(historyConflicts([], [u('q')])).toBe(false) + }) +}) + +describe('newerBrowserTitle', () => { + const row = { id: 'conv-12345678', title: 'Trip', updated_at: '2026-10-02 11:00:00' } + const pushed = (title: string, updatedAt: number): BridgeConversation => ({ + id: row.id, + title, + createdAt: 0, + updatedAt, + origin: 'browser', + turns: [] + }) + + it('takes a rename made after the desktop copy changed', () => { + expect(newerBrowserTitle(row, pushed('Trip to Goa', Date.parse('2026-10-02T12:00:00Z')))).toBe( + 'Trip to Goa' + ) + }) + + it('keeps the desktop title against an older or unchanged one', () => { + expect( + newerBrowserTitle(row, pushed('Old name', Date.parse('2026-10-02T10:00:00Z'))) + ).toBeNull() + expect(newerBrowserTitle(row, pushed('Trip', Date.parse('2026-10-03T10:00:00Z')))).toBeNull() + }) }) diff --git a/src/main/extension-bridge/__tests__/bridge-service.test.ts b/src/main/extension-bridge/__tests__/bridge-service.test.ts index 5ea2c20a1..4b0e8e207 100644 --- a/src/main/extension-bridge/__tests__/bridge-service.test.ts +++ b/src/main/extension-bridge/__tests__/bridge-service.test.ts @@ -81,10 +81,33 @@ async function setup(opts: { approve?: boolean; features?: BridgeFeatures } = {} deleteConversation: async (id) => { calls.push(`delete:${id}`) }, - listTools: async () => [{ name: 'notion_search' }], - runTool: async (name) => ({ ok: true, output: `ran ${name}` }), - latestTask: async (browser, since) => - since > 2_000_000 ? null : { status: 'done', summary: `for ${browser.name}` }, + // As the desktop lists them: connector tools always, its own tools only when it has them. + listTools: async () => + (opts.features ?? FREE).tools + ? [{ name: 'notion_search' }, { name: 'mail_send' }, { name: 'web_use' }] + : [{ name: 'notion_search' }], + runTool: async (name, _args, _browser, tabId) => ({ + ok: true, + output: `ran ${name}${tabId === undefined ? '' : ` in tab ${tabId}`}` + }), + latestTask: async (browser, since, taskId) => + since > 2_000_000 || (taskId !== undefined && taskId !== 'task-1') + ? null + : { + taskId: 'task-1', + status: 'done', + summary: `for ${browser.name}`, + plan: ['Open the shop'], + phase: 0, + steps: ['opened the shop'], + action: '' + }, + stopTask: async (_browser, taskId) => taskId === 'task-1', + // The desktop's role setter answers; a model it cannot use is refused. + setTaskRole: async (request) => + request.modelId === 'grounder-1' + ? { success: true } + : { success: false, error: 'That model cannot fill this role.' }, readSettings: async (section) => settings.read(section), writeSettings: async (section, patch) => settings.write(section, patch), vault: async () => ({ type: 'status', state: 'unlocked' }) @@ -236,6 +259,25 @@ describe('sealed rpc', () => { } }) + it('lets a paired browser give a model a task role, and refuses what the desktop refuses', async () => { + // Review finding: roles changed over the open gateway. A paired browser uses its sealed link. + const s = await setup() + await s.pair() + const ok = await s.call('device000001', 'models.setTaskRole', { + task: 'web_use', + role: 'grounding', + modelId: 'grounder-1' + }) + expect(ok.body).toMatchObject({ ok: true, result: { success: true } }) + const refused = await s.call('device000001', 'models.setTaskRole', { + task: 'web_use', + role: 'grounding', + modelId: 'made-up' + }) + expect(refused.body).toMatchObject({ ok: false }) + expect((await s.call('device999999', 'models.setTaskRole', {})).status).toBe(401) + }) + it('lists chats and tools, puts and deletes', async () => { const s = await setup() await s.pair() @@ -265,6 +307,12 @@ describe('sealed rpc', () => { (await free.call('device000001', 'tools.run', { name: 'mail_send', args: {} })).body ).toMatchObject({ ok: false, error: 'pro_required' }) + // A connector tool the desktop lists runs on a free desktop too (review finding: it was + // listed, then refused as Pro-only). + expect( + (await free.call('device000001', 'tools.run', { name: 'notion_search', args: {} })).body + ).toMatchObject({ ok: true, result: { output: 'ran notion_search' } }) + // Pro, but this desktop cannot answer for the vault or tools yet: never "upgrade". const stale = await setup({ features: { ...PRO, vault: false, tools: false } }) await stale.pair() @@ -289,19 +337,50 @@ describe('sealed rpc', () => { ok: false, error: 'invalid' }) + // Review finding: a tool turned off since the browser listed it must not run, Pro or not. + expect( + (await pro.call('device000001', 'tools.run', { name: 'calendar_delete', args: {} })).body + ).toMatchObject({ ok: false, error: 'tool_unavailable' }) + // A web task can name the chat's tab to start in; anything but a tab id is refused. + expect( + (await pro.call('device000001', 'tools.run', { name: 'web_use', args: {}, tabId: 42 })).body + ).toMatchObject({ ok: true, result: { output: 'ran web_use in tab 42' } }) + for (const tabId of [-1, 1.5, '42', null]) { + expect( + (await pro.call('device000001', 'tools.run', { name: 'web_use', args: {}, tabId })).body + ).toMatchObject({ ok: false, error: 'invalid' }) + } }) - it('tasks.latest reports this browser\'s latest task, for a run that started one', async () => { + it("tasks.latest reports this browser's latest task, for a run that started one", async () => { // web_use answers "started" at once; the browser asks here for the result it was waiting on. const pro = await setup({ features: PRO }) await pro.pair() expect((await pro.call('device000001', 'tasks.latest', { since: 1 })).body).toMatchObject({ ok: true, - result: { status: 'done', summary: 'for Chrome on this Mac' } + result: { + taskId: 'task-1', + status: 'done', + summary: 'for Chrome on this Mac', + plan: ['Open the shop'], + phase: 0, + steps: ['opened the shop'], + action: '' + } }) - expect((await pro.call('device000001', 'tasks.latest', { since: 3_000_000 })).body).toMatchObject( - { ok: true, result: null } - ) + expect( + (await pro.call('device000001', 'tasks.latest', { since: 3_000_000 })).body + ).toMatchObject({ ok: true, result: null }) + // A chat asks for its own task by id, so a newer task from another chat never hides it. + expect( + (await pro.call('device000001', 'tasks.latest', { since: 1, taskId: 'task-2' })).body + ).toMatchObject({ ok: true, result: null }) + expect( + (await pro.call('device000001', 'tasks.latest', { since: 1, taskId: 'task-1' })).body + ).toMatchObject({ ok: true, result: { taskId: 'task-1' } }) + expect( + (await pro.call('device000001', 'tasks.latest', { since: 1, taskId: 'a b' })).body + ).toMatchObject({ ok: false, error: 'invalid' }) expect((await pro.call('device000001', 'tasks.latest', { since: 'x' })).body).toMatchObject({ ok: false, error: 'invalid' @@ -314,6 +393,28 @@ describe('sealed rpc', () => { }) }) + it('tasks.stop stops the task this browser names, and refuses anything but a task id', async () => { + const pro = await setup({ features: PRO }) + await pro.pair() + expect((await pro.call('device000001', 'tasks.stop', { taskId: 'task-1' })).body).toMatchObject( + { ok: true, result: true } + ) + expect((await pro.call('device000001', 'tasks.stop', { taskId: 'task-2' })).body).toMatchObject( + { ok: true, result: false } + ) + for (const taskId of [3, '', 'a b', 'x'.repeat(200)]) { + expect((await pro.call('device000001', 'tasks.stop', { taskId })).body).toMatchObject({ + ok: false, + error: 'invalid' + }) + } + const free = await setup() + await free.pair() + expect( + (await free.call('device000001', 'tasks.stop', { taskId: 'task-1' })).body + ).toMatchObject({ ok: false, error: 'pro_required' }) + }) + it('unpair removes the browser; its key stops working', async () => { const s = await setup() await s.pair() diff --git a/src/main/extension-bridge/__tests__/bridge-settings-store.test.ts b/src/main/extension-bridge/__tests__/bridge-settings-store.test.ts index 96d88b88f..ce7ab7e47 100644 --- a/src/main/extension-bridge/__tests__/bridge-settings-store.test.ts +++ b/src/main/extension-bridge/__tests__/bridge-settings-store.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { createSettingsStore, type SettingsStoreDeps } from '../bridge-settings-store' +import type { ConnectorsSettings } from '../bridge-settings' // The desktop's own functions, faked: what a paired browser reads must be what Settings here // shows, and what it writes must go through the same setters. @@ -26,7 +27,7 @@ function fakeDesktop() { connectors: [ { id: 1, name: 'Notes', url: 'https://mcp.test/notes', enabled: 1 }, { id: 2, name: 'Local script', url: null, enabled: 1 } - ], + ] as Array<{ id: number; name: string; url: string | null; enabled: number; status?: string }>, remote: { activeServerId: null as string | null, textOnRemote: false }, servers: [ { @@ -93,7 +94,7 @@ function fakeDesktop() { ] }) } - return { state, store: createSettingsStore(deps) } + return { state, deps, store: createSettingsStore(deps) } } describe('a paired browser’s view of the desktop settings', () => { @@ -205,6 +206,31 @@ describe('a paired browser’s view of the desktop settings', () => { ]) }) + it('shows an account with what it can read and how it stands', async () => { + const { store, state, deps } = fakeDesktop() + state.connectors = [ + ...state.connectors, + { + id: 3, + name: 'Google (work@example.com)', + url: 'https://gmailmcp.googleapis.com/mcp/v1?offgrid-services=workspace', + enabled: 1, + status: 'error' + } + ] + deps.accountAccess = (id) => (id === 3 ? { services: ['Gmail'], missing: ['Drive'] } : null) + expect(((await store.read('connectors')) as ConnectorsSettings).connectors).toEqual([ + { id: '1', name: 'Notes', url: 'https://mcp.test/notes', enabled: true }, + expect.objectContaining({ + id: '3', + liveOnly: true, + health: 'needs-sign-in', + services: ['Gmail'], + missing: ['Drive'] + }) + ]) + }) + it('reads and writes Image, Transcription and Voice as the desktop keys them', async () => { const { store, state } = fakeDesktop() expect(await store.read('image')).toEqual({ diff --git a/src/main/extension-bridge/__tests__/bridge-task-progress.test.ts b/src/main/extension-bridge/__tests__/bridge-task-progress.test.ts new file mode 100644 index 000000000..5af981301 --- /dev/null +++ b/src/main/extension-bridge/__tests__/bridge-task-progress.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { + encodeTaskExecutionPlan, + encodeTaskPhase, + type TaskExecutionPlan +} from '../../../shared/task-execution-plan' +import { + browserTaskById, + createAcceptedTasks, + latestBrowserTask, + stopRoute +} from '../bridge-task-progress' + +const plan: TaskExecutionPlan = { + version: 1, + phases: [ + { id: 'phase-1', title: 'Search the shop for boots' }, + { id: 'phase-2', title: 'Report the first result' } + ] +} + +type Row = Parameters[0][number] + +const row = (over: Partial = {}): Row => ({ + taskId: 'task-1', + journeyId: 'browser:a', + status: 'running', + summary: '', + startedAt: 100, + currentAction: 'Typing boots', + steps: [ + encodeTaskExecutionPlan(plan), + 'working in your tab in Chrome', + encodeTaskPhase('phase-2'), + 'read the results' + ], + ...over +}) + +describe("a browser's view of the web task it started", () => { + it('carries the task id, plan titles, phase, trace steps and current action', () => { + expect(latestBrowserTask([row()], 'browser:a', 50)).toEqual({ + taskId: 'task-1', + status: 'running', + summary: '', + plan: ['Search the shop for boots', 'Report the first result'], + phase: 1, + steps: ['working in your tab in Chrome', 'read the results'], + action: 'Typing boots' + }) + }) + + it('has no plan or action before planning and once the task ends', () => { + expect(latestBrowserTask([row({ steps: [] })], 'browser:a', 0)).toMatchObject({ + plan: [], + phase: -1, + steps: [] + }) + expect( + latestBrowserTask([row({ status: 'done', summary: 'Found boots.' })], 'browser:a', 0) + ).toMatchObject({ status: 'done', summary: 'Found boots.', action: '' }) + }) + + it("sees only this browser's tasks since the time asked, newest started first", () => { + const runs = [ + row({ taskId: 'older', startedAt: 120 }), + row({ taskId: 'other-browser', journeyId: 'browser:b', startedAt: 500 }), + row({ taskId: 'newer', startedAt: 300 }) + ] + expect(latestBrowserTask(runs, 'browser:a', 0)?.taskId).toBe('newer') + expect(latestBrowserTask(runs, 'browser:a', 400)).toBeNull() + expect(latestBrowserTask(runs, 'browser:c', 0)).toBeNull() + }) + + it('follows the task a chat asked for, though a newer one started since', () => { + // Review finding: chat A lost its task once chat B started a newer one. + const runs = [ + row({ taskId: 'a-task', startedAt: 120 }), + row({ taskId: 'b-task', startedAt: 300 }) + ] + expect(latestBrowserTask(runs, 'browser:a', 0, 'a-task')?.taskId).toBe('a-task') + expect(latestBrowserTask(runs, 'browser:b', 0, 'a-task')).toBeNull() + }) +}) + +describe('a browser task by its own id', () => { + const journey = 'browser:chrome-1' + it("shows a task given to this browser as queued until it starts, and nobody else's", () => { + // Review finding: with no history row yet the bridge returned nothing, and chats fell back to + // the newest task of the shared browser journey. + const accepted = createAcceptedTasks() + accepted.remember('act_a', journey) + expect(browserTaskById(null, accepted, journey, 0, 'act_a')).toMatchObject({ + taskId: 'act_a', + status: 'queued' + }) + expect(browserTaskById(null, accepted, 'browser:other', 0, 'act_a')).toBeNull() + expect(browserTaskById(null, accepted, journey, 0, 'act_b')).toBeNull() + }) + + it('stops a running task through its session, a queued one before it starts, and no other', () => { + const accepted = createAcceptedTasks() + accepted.remember('act_a', journey) + expect(stopRoute({ journeyId: journey }, accepted, journey, 'act_a')).toBe('running') + expect(stopRoute(null, accepted, journey, 'act_a')).toBe('before-start') + expect(stopRoute({ journeyId: 'browser:other' }, accepted, journey, 'act_a')).toBeNull() + expect(stopRoute(null, accepted, journey, 'act_unknown')).toBeNull() + }) + + it('remembers a bounded number of ids, forgetting the oldest', () => { + const accepted = createAcceptedTasks(2) + accepted.remember('one', journey) + accepted.remember('two', journey) + accepted.remember('three', journey) + expect(accepted.owns('one', journey)).toBe(false) + expect(accepted.owns('three', journey)).toBe(true) + }) +}) diff --git a/src/main/extension-bridge/bridge-conversations.ts b/src/main/extension-bridge/bridge-conversations.ts index a0ca0d225..d1c55d954 100644 --- a/src/main/extension-bridge/bridge-conversations.ts +++ b/src/main/extension-bridge/bridge-conversations.ts @@ -97,23 +97,59 @@ export function parseBridgeConversation(raw: unknown): BridgeConversation | null } /** - * The turns to append so the desktop catches up with the browser: everything past the desktop's - * stored turns, provided the stored turns are a prefix of what the browser sent (same roles and - * text). A diverged history appends nothing, so the desktop's copy is never contradicted. + * The turns to append so the desktop catches up with the browser. The browser keeps only its + * latest turns, so what it sends is a window: the end of the desktop's stored turns must match the + * start of that window, and whatever follows the overlap is new. The longest overlap wins, so a + * repeated short turn never makes old turns look new. With no overlap (a diverged or stale + * history) nothing is appended, so the desktop's copy is never contradicted. */ export function turnsToAppend( stored: readonly MessageRow[], incoming: readonly BridgeTurn[] ): BridgeTurn[] { const have = stored.filter((m) => m.role === 'user' || m.role === 'assistant') - if (incoming.length <= have.length) return [] - const prefix = have.every((m, i) => { - const turn = incoming.at(i) - return ( - turn !== undefined && - m.role === turn.role && - m.content.slice(0, MAX_TURN_CHARS) === turn.content - ) - }) - return prefix ? incoming.slice(have.length) : [] + if (!have.length) return [...incoming] + const same = (m: MessageRow, turn: BridgeTurn | undefined): boolean => + turn !== undefined && + m.role === turn.role && + m.content.slice(0, MAX_TURN_CHARS) === turn.content + for (let overlap = Math.min(have.length, incoming.length); overlap > 0; overlap--) { + const tail = have.slice(have.length - overlap) + if (tail.every((m, i) => same(m, incoming[i]))) return incoming.slice(overlap) + } + return [] } + +/** + * Whether a browser's history contradicts the desktop's: it neither continues the stored turns + * (turnsToAppend) nor is an older window of them. An edited or regenerated turn reads this way. + * The desktop has no record of what the browser saw last, so it cannot tell an edit from turns + * added here since, and refuses rather than overwrite either one. + */ +export function historyConflicts( + stored: readonly MessageRow[], + incoming: readonly BridgeTurn[] +): boolean { + const have = stored.filter((m) => m.role === 'user' || m.role === 'assistant') + if (!have.length || !incoming.length || turnsToAppend(stored, incoming).length) return false + const same = (m: MessageRow, turn: BridgeTurn): boolean => + m.role === turn.role && m.content.slice(0, MAX_TURN_CHARS) === turn.content + for (let start = 0; start + incoming.length <= have.length; start++) { + if (incoming.every((turn, i) => same(have[start + i]!, turn))) return false + } + return true +} + +/** The title a browser renamed its chat to, when that rename is newer than the desktop's copy. */ +export function newerBrowserTitle( + row: ConversationRow, + conversation: BridgeConversation +): string | null { + const title = conversation.title.trim() + if (!title || title === (row.title ?? '')) return null + return conversation.updatedAt > sqlTime(row.updated_at) ? title : null +} + +/** What a refused browser edit says: the extension keeps an edit whose refusal names a conflict. */ +export const HISTORY_CONFLICT = + 'conflict: this chat changed differently on the desktop, so the edit was kept in the browser' diff --git a/src/main/extension-bridge/bridge-electron.ts b/src/main/extension-bridge/bridge-electron.ts index a2ec73f7c..aa6f55580 100644 --- a/src/main/extension-bridge/bridge-electron.ts +++ b/src/main/extension-bridge/bridge-electron.ts @@ -5,7 +5,8 @@ // core; the native action tools and the vault exist only when Pro is active, because only then // are they registered. Nothing here grants a browser more than the desktop app itself has. -import { listTaskRuns } from '../tasks/task-history' +import { getTaskRun, listTaskRuns } from '../tasks/task-history' +import { controlVisionTask, stopVisionTaskBeforeStart } from '../vision/vision-controller' import fs from 'fs' import path from 'path' import { randomUUID } from 'crypto' @@ -16,14 +17,16 @@ import { deleteRagConversation, getDB, getRagConversation, + updateRagConversationTitle, getRagConversations, getRagMessages, getSettings, saveSetting } from '../database' -import { getToolExtensions, runTool } from '../tools' +import { enabledToolSchemas, getToolExtensions, runTool } from '../tools' import { notifyRagConversationChanged } from '../rag-conversation-events' -import { callHookAsync, hasHook, HOOKS } from '../bootstrap/hookRegistry' +import { callHook, callHookAsync, hasHook, HOOKS } from '../bootstrap/hookRegistry' +import { getSecret } from '../secrets' import { proEnabled } from '../bootstrap/loadProFeaturesMain' import { getWebUseSettings, setWebUseSettings } from '../web-use-settings' import { getComputerUseSettings, setComputerUseSettings } from '../computer-use-settings' @@ -58,9 +61,18 @@ import { MAX_LISTED, parseBridgeConversation, toBridgeConversation, - turnsToAppend + turnsToAppend, + historyConflicts, + newerBrowserTitle, + HISTORY_CONFLICT } from './bridge-conversations' import { generateKeyPair } from './bridge-protocol' +import { + browserTaskById, + createAcceptedTasks, + latestBrowserTask, + stopRoute +} from './bridge-task-progress' import { createLinkRegistry, type BrowserLink } from './bridge-socket' const storePath = (): string => path.join(app.getPath('userData'), 'extension-bridge.json') @@ -190,21 +202,19 @@ function features(): BridgeFeatures { async function listTools(): Promise { const out: unknown[] = [] - for (const ext of getToolExtensions()) { - const source = ext.category === 'tool' ? 'native' : 'connector' - for (const schema of await ext.schemas()) { - const fn = ( - schema as { function?: { name?: unknown; description?: unknown; parameters?: unknown } } - ).function - if (typeof fn?.name === 'string') { - out.push({ - name: fn.name, - description: typeof fn.description === 'string' ? fn.description : '', - parameters: fn.parameters ?? { type: 'object', properties: {} }, - source, - ...(source === 'connector' ? { connector: ext.id } : {}) - }) - } + for (const { schema, extension } of await enabledToolSchemas(getToolExtensions())) { + const fn = ( + schema as { function?: { name?: unknown; description?: unknown; parameters?: unknown } } + ).function + if (typeof fn?.name === 'string') { + const source = !extension || extension.category === 'tool' ? 'native' : 'connector' + out.push({ + name: fn.name, + description: typeof fn.description === 'string' ? fn.description : '', + parameters: fn.parameters ?? { type: 'object', properties: {} }, + source, + ...(source === 'connector' && extension ? { connector: extension.id } : {}) + }) } } return out @@ -246,6 +256,14 @@ const settingsStore = createSettingsStore({ listTools: listDesktopTools, setToolEnabled, listConnectors, + // Accounts are live-only connectors; Pro names what each can read. + accountAccess: (id) => + getSecret(`connector:${id}:live-only`) === 'true' + ? (callHook<{ services: string[]; missing: string[] }>(HOOKS.accountAccess, id) ?? { + services: [], + missing: [] + }) + : null, addConnector: (c) => void addConnector(c), setConnectorEnabled, removeConnector, @@ -265,6 +283,12 @@ const settingsStore = createSettingsStore({ transcription: readTranscriptionInfo }) +/** The journey every task a browser starts belongs to: its chats' origin. */ +const journeyOf = (browser: PairedBrowser): string => `${BROWSER_ORIGIN_PREFIX}${browser.id}` + +/** The task ids each browser was given (bridge-task-progress.ts). */ +const acceptedTasks = createAcceptedTasks() + const data: BridgeData = { features, desktopName: () => 'Off Grid AI Desktop', @@ -275,15 +299,22 @@ const data: BridgeData = { putConversation: async (raw, browser) => { const conversation = parseBridgeConversation(raw) if (!conversation) throw new Error('invalid') - if (!getRagConversation(conversation.id)) { + const existing = getRagConversation(conversation.id) + const stored = getRagMessages(conversation.id) + // An edit the desktop cannot apply is refused, never reported as synced. + if (existing && historyConflicts(stored, conversation.turns)) throw new Error(HISTORY_CONFLICT) + if (!existing) { createRagConversation(conversation.id, conversation.title) getDB() .prepare( 'UPDATE rag_conversations SET origin_device_id = ?, origin_device_name = ? WHERE id = ?' ) - .run(`${BROWSER_ORIGIN_PREFIX}${browser.id}`, browser.name, conversation.id) + .run(journeyOf(browser), browser.name, conversation.id) + } else { + const title = newerBrowserTitle(existing, conversation) + if (title) updateRagConversationTitle(conversation.id, title) } - for (const turn of turnsToAppend(getRagMessages(conversation.id), conversation.turns)) { + for (const turn of turnsToAppend(stored, conversation.turns)) { addRagMessage(conversation.id, turn.role, turn.content) } // The chat list reloads now, as it does for a phone's chats, not on the next restart. @@ -294,21 +325,40 @@ const data: BridgeData = { notifyRagConversationChanged({ conversationId: id }) }, listTools, - runTool: async (name, args, browser) => { + runTool: async (name, args, browser, tabId) => { + const journeyId = journeyOf(browser) + // A web task works in the tab its chat offered: the tab travels with that one task. const result = await runTool( name, args, - { conversationId: `${BROWSER_ORIGIN_PREFIX}${browser.id}` }, + { + conversationId: journeyId, + ...(tabId === undefined ? {} : { startTab: { browserId: browser.id, tabId } }) + }, getToolExtensions() ) - return { ok: result.status !== 'failed', output: result.text } + const ok = result.status !== 'failed' + // The accepted task's own id (web_use and computer_use), from the moment it is accepted. + const taskId = ok ? result.taskId : undefined + if (taskId) acceptedTasks.remember(taskId, journeyId) + return { ok, output: result.text, ...(taskId ? { taskId } : {}) } }, - latestTask: async (browser, since) => { - const run = listTaskRuns(20).find( - (task) => - task.journeyId === `${BROWSER_ORIGIN_PREFIX}${browser.id}` && task.startedAt >= since - ) - return run ? { status: run.status, summary: run.summary ?? '' } : null + latestTask: async (browser, since, taskId) => { + const journeyId = journeyOf(browser) + if (taskId === undefined) return latestBrowserTask(listTaskRuns(20), journeyId, since) + // Its own task, however many have started since; one not started yet shows as queued. + return browserTaskById(getTaskRun(taskId) ?? null, acceptedTasks, journeyId, since, taskId) + }, + stopTask: async (browser, taskId) => { + const route = stopRoute(getTaskRun(taskId) ?? null, acceptedTasks, journeyOf(browser), taskId) + if (route === 'running') return controlVisionTask('stop', taskId) + // Accepted for this browser but not started (queued, awaiting approval): stopped as it starts. + if (route === 'before-start') stopVisionTaskBeforeStart(taskId) + return route === 'before-start' + }, + setTaskRole: async (request) => { + const { setTaskRoleFromRequest } = await import('../task-role-requests') + return setTaskRoleFromRequest(request) }, vault: async (request, browser) => callHookAsync(HOOKS.extensionVaultRequest, request, { diff --git a/src/main/extension-bridge/bridge-http.ts b/src/main/extension-bridge/bridge-http.ts index ae53dbb25..009e22abe 100644 --- a/src/main/extension-bridge/bridge-http.ts +++ b/src/main/extension-bridge/bridge-http.ts @@ -5,7 +5,7 @@ // POST /v1/extension/rpc every other call, sealed end to end // // Defence in layers, none of them the only one: -// loopback only the gateway binds to loopback; these routes check the peer again +// private peers loopback, LAN and Tailscale routes admitted by Shared Sync // no web pages a request carrying an http(s) Origin is refused outright, and pairing // requires an extension origin, so a site you visit cannot start one // sealed traffic rpc bodies are AES-GCM under a key only a paired browser holds @@ -18,12 +18,19 @@ import { addBrowserLink, getBridgeService } from './bridge-electron' import type { BridgeReply } from './bridge-service' import { acceptBrowserSocket } from './bridge-socket' import { SOCKET_PATH } from './bridge-socket-protocol' +import { validatePairingQrRoute } from '@offgrid/sync' +import { GATEWAY_PORT } from '../../shared/ports' const MAX_BODY = 4 * 1024 * 1024 -function isLoopback(req: http.IncomingMessage): boolean { +function isPrivatePeer(req: http.IncomingMessage): boolean { const remote = req.socket.remoteAddress - return remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1' + if (remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1') return true + const host = remote?.replace(/^::ffff:/, '') + // The same route policy that Sync uses: LAN and Tailscale peers, never public IPs. + return ['lan', 'tailscale'].some( + (kind) => validatePairingQrRoute({ kind, host, port: GATEWAY_PORT }) !== undefined + ) } function send(res: http.ServerResponse, origin: string | undefined, reply: BridgeReply): void { @@ -66,7 +73,7 @@ export async function handleExtensionBridge( method: string ): Promise { const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined - if (!isLoopback(req) || (origin && /^https?:/i.test(origin))) { + if (!isPrivatePeer(req) || (origin && /^https?:/i.test(origin))) { return send(res, origin, { status: 403, body: { error: 'forbidden' } }) } try { @@ -94,7 +101,7 @@ const sockets = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 * 10 const DEVICE_ID = /^[A-Za-z0-9_-]{8,64}$/ /** - * The live socket at /v1/extension/socket?d=. Same gates as pairing: loopback peer + * The live socket at /v1/extension/socket?d=. Same gates as pairing: private peer * and a browser-extension Origin. The sealed hello then proves the browser holds its key. * Returns false for any other upgrade so the gateway can decide what to do with it. */ @@ -107,7 +114,7 @@ export function handleExtensionUpgrade( if (url.pathname !== SOCKET_PATH) return false const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined const deviceId = url.searchParams.get('d') ?? '' - if (!isLoopback(req) || !isExtensionOrigin(origin) || !DEVICE_ID.test(deviceId)) { + if (!isPrivatePeer(req) || !isExtensionOrigin(origin) || !DEVICE_ID.test(deviceId)) { socket.destroy() return true } diff --git a/src/main/extension-bridge/bridge-protocol.ts b/src/main/extension-bridge/bridge-protocol.ts index d7fbbc2f2..659ca3174 100644 --- a/src/main/extension-bridge/bridge-protocol.ts +++ b/src/main/extension-bridge/bridge-protocol.ts @@ -183,6 +183,8 @@ export const RPC_METHODS = [ 'tools.list', 'tools.run', 'tasks.latest', + 'tasks.stop', + 'models.setTaskRole', 'vault', 'settings.get', 'settings.set', diff --git a/src/main/extension-bridge/bridge-service.ts b/src/main/extension-bridge/bridge-service.ts index 48ee1d175..e4c7e9e4d 100644 --- a/src/main/extension-bridge/bridge-service.ts +++ b/src/main/extension-bridge/bridge-service.ts @@ -51,6 +51,19 @@ export interface BridgeFeatures { readonly browserTasks?: boolean } +/** What a paired browser sees of a web task it started (desktop task-progress.ts). */ +export interface BrowserTaskProgress { + readonly taskId: string + readonly status: string + readonly summary: string + /** Phase titles in order; empty before the plan is made. */ + readonly plan: readonly string[] + /** Index into `plan` of the phase in progress, or -1 with no plan. */ + readonly phase: number + readonly steps: readonly string[] + readonly action: string +} + export interface BridgeData { features(): BridgeFeatures desktopName(): string @@ -58,14 +71,30 @@ export interface BridgeData { putConversation(conversation: unknown, browser: PairedBrowser): Promise deleteConversation(id: string): Promise listTools(): Promise - runTool(name: string, args: Record, browser: PairedBrowser): Promise - vault(request: unknown, browser: PairedBrowser): Promise + /** `tabId`: the browser's tab a web task should start in (browser-start-tab.ts). */ + runTool( + name: string, + args: Record, + browser: PairedBrowser, + tabId?: number + ): Promise /** This browser's latest task started at or after `since` (ms): web_use answers "started" at - * once, so a browser waiting on the result asks here. Null when none has started yet. */ + * once, so a browser waiting on the result asks here. Null when none has started yet. With + * `taskId`, that task only (when it is this browser's), so two chats never read each other's. */ latestTask( browser: PairedBrowser, - since: number - ): Promise<{ status: string; summary: string } | null> + since: number, + taskId?: string + ): Promise + /** Stops a task this browser started (its journey is this browser's). False otherwise. */ + stopTask(browser: PairedBrowser, taskId: string): Promise + /** Gives a model a Web Use or Computer Use role, through the desktop's one role setter. */ + setTaskRole(request: { + task: unknown + role: unknown + modelId: unknown + }): Promise<{ success: boolean; error?: string }> + vault(request: unknown, browser: PairedBrowser): Promise /** One section of the desktop's settings (settings.ts), as its Settings screen shows it. */ readSettings(section: SettingsSection): Promise /** Apply an already-validated patch through the desktop's own setters. */ @@ -86,6 +115,9 @@ export type BridgeReply = { status: number; body: unknown } const UNAUTHORIZED: BridgeReply = { status: 401, body: { error: 'unauthorized' } } +const isTaskId = (value: unknown): value is string => + typeof value === 'string' && /^[\w-]{1,128}$/.test(value) + type Handler = (params: Record, browser: PairedBrowser) => Promise export interface BridgeService { @@ -149,18 +181,51 @@ export function createBridgeService(deps: BridgeDeps): BridgeService { }, 'tools.list': async () => deps.data.listTools(), 'tools.run': async (p, browser) => { - requireFeature('tools') if (typeof p.name !== 'string' || typeof p.args !== 'object' || p.args === null) { throw new Error('invalid') } - return deps.data.runTool(p.name, p.args as Record, browser) + const tabId = p.tabId + if (tabId !== undefined && !(Number.isSafeInteger(tabId) && Number(tabId) >= 0)) { + throw new Error('invalid') + } + // What tools.list offers, this runs: a free desktop's connector tools included. Anything + // else (a tool turned off since the browser listed it) never runs. + const name = p.name + const listed = (await deps.data.listTools()).some( + (tool) => (tool as { name?: unknown }).name === name + ) + if (!listed) { + requireFeature('tools') + throw new Error('tool_unavailable') + } + return deps.data.runTool( + p.name, + p.args as Record, + browser, + tabId === undefined ? undefined : Number(tabId) + ) }, 'tasks.latest': async (p, browser) => { requireFeature('tools') if (typeof p.since !== 'number' || !Number.isFinite(p.since)) { throw new Error('invalid') } - return deps.data.latestTask(browser, p.since) + if (p.taskId !== undefined && !isTaskId(p.taskId)) { + throw new Error('invalid') + } + return deps.data.latestTask(browser, p.since, p.taskId) + }, + 'tasks.stop': async (p, browser) => { + requireFeature('tools') + if (!isTaskId(p.taskId)) { + throw new Error('invalid') + } + return deps.data.stopTask(browser, p.taskId) + }, + 'models.setTaskRole': async (p) => { + const result = await deps.data.setTaskRole({ task: p.task, role: p.role, modelId: p.modelId }) + if (!result.success) throw new Error(result.error ?? 'invalid') + return result }, vault: async (p, browser) => { requireFeature('vault') diff --git a/src/main/extension-bridge/bridge-settings-store.ts b/src/main/extension-bridge/bridge-settings-store.ts index 060b9b900..13ae0b991 100644 --- a/src/main/extension-bridge/bridge-settings-store.ts +++ b/src/main/extension-bridge/bridge-settings-store.ts @@ -40,7 +40,15 @@ export interface SettingsStoreDeps { saveSetting(key: string, value: unknown): void listTools(): { name: string; description: string; enabled: boolean }[] setToolEnabled(name: string, enabled: boolean): void - listConnectors(): { id: number; name: string; url: string | null; enabled: number }[] + listConnectors(): { + id: number + name: string + url: string | null + enabled: number + status?: string + }[] + /** An account's access, for accounts (live-only connectors); null for a plain connector. */ + accountAccess?(id: number): { services: string[]; missing: string[] } | null addConnector(c: { name: string; transport: 'http'; url: string }): void setConnectorEnabled(id: number, enabled: boolean): void removeConnector(id: number): void @@ -122,12 +130,28 @@ export function createSettingsStore(deps: SettingsStoreDeps): { connectors: deps .listConnectors() .filter((c) => typeof c.url === 'string' && c.url) - .map((c) => ({ - id: String(c.id), - name: c.name, - url: c.url as string, - enabled: c.enabled === 1 - })) + .map((c) => { + const access = deps.accountAccess?.(c.id) ?? null + return { + id: String(c.id), + name: c.name, + url: c.url as string, + enabled: c.enabled === 1, + ...(access + ? { + liveOnly: true, + health: + c.status === 'error' + ? ('needs-sign-in' as const) + : c.enabled === 1 + ? ('connected' as const) + : ('off' as const), + services: access.services, + missing: access.missing + } + : {}) + } + }) }), image: () => { const settings = deps.appSettings() diff --git a/src/main/extension-bridge/bridge-settings.ts b/src/main/extension-bridge/bridge-settings.ts index b50654890..909ce16a5 100644 --- a/src/main/extension-bridge/bridge-settings.ts +++ b/src/main/extension-bridge/bridge-settings.ts @@ -77,8 +77,26 @@ export interface ToolsSettings { readonly tools: readonly { name: string; description: string; enabled: boolean }[] } +/** How an account stands: usable, waiting for a new sign-in on the desktop, or switched off. */ +export type AccountHealth = 'connected' | 'needs-sign-in' | 'off' + export interface ConnectorsSettings { - readonly connectors: readonly { id: string; name: string; url: string; enabled: boolean }[] + readonly connectors: readonly { + id: string + name: string + url: string + enabled: boolean + /** + * An account (Google, Microsoft, an Obsidian vault, a work tool): read live when asked and + * never copied into memory. Accounts are added and signed in to on the desktop. + */ + liveOnly?: boolean + health?: AccountHealth + /** What the account can read, as the desktop names it (Gmail, Calendar, ...). */ + services?: readonly string[] + /** Picked, but not granted at sign-in. */ + missing?: readonly string[] + }[] } export interface ImageSettings { @@ -188,7 +206,9 @@ const nested = (v) => pick(v, rules) ?? undefined -const HTTP_URL = /^https?:\/\/[^\s]+$/ +// A plain web address. An account's internal identity on the desktop (a URL carrying +// offgrid-services) is not something a browser can add: accounts are signed in to on the desktop. +const HTTP_URL = /^https?:\/\/(?![^\s]*offgrid-services=)[^\s]+$/ const MODEL_ID = text(400, /^\S*$/) const yes: Rule = (v) => (v === true ? v : undefined) diff --git a/src/main/extension-bridge/bridge-socket-protocol.ts b/src/main/extension-bridge/bridge-socket-protocol.ts index 3f5892069..229307705 100644 --- a/src/main/extension-bridge/bridge-socket-protocol.ts +++ b/src/main/extension-bridge/bridge-socket-protocol.ts @@ -92,10 +92,16 @@ export function createChannel(opts: { let sent = 0 let received = 0 let broken = false + // Encryption is asynchronous, so two seals can finish out of order. Each frame is handed back + // only after the one numbered before it, so callers that send on resolve send in sequence. + let previous: Promise = Promise.resolve() return { - async seal(message) { + seal(message) { const aad = frameAad(opts, send, sent++) - return JSON.stringify(await seal(opts.key, aad, message)) + const sealing = seal(opts.key, aad, message).then((frame) => JSON.stringify(frame)) + const inOrder = previous.then(() => sealing) + previous = inOrder.catch(() => undefined) + return inOrder }, async open(frame) { // The expected number is taken now, in arrival order, before any await. @@ -122,6 +128,8 @@ export const SOCKET_OPS = [ 'browser.caps', 'tabs.list', 'tab.create', + /** Take the tab the browser offered for this task (tools.run tabId), instead of a new one. */ + 'tab.adopt', 'tab.close', 'cdp.attach', 'cdp.detach', diff --git a/src/main/extension-bridge/bridge-socket.ts b/src/main/extension-bridge/bridge-socket.ts index 5188914b8..a44f3e9c5 100644 --- a/src/main/extension-bridge/bridge-socket.ts +++ b/src/main/extension-bridge/bridge-socket.ts @@ -84,8 +84,14 @@ function makeLink( socket.close(1008, 'invalid frame') closed() } + // Frames are opened one at a time, in the order they arrived: decryption is asynchronous, and a + // reply must not overtake the event the browser sent before it. + let inbound: Promise = Promise.resolve() const receive = (data: unknown): void => { - void channel.open(text(data)).then((raw) => { + inbound = inbound.then(() => handle(data)).catch(() => drop()) + } + const handle = (data: unknown): Promise => + channel.open(text(data)).then((raw) => { const message = raw === null ? null : parseUpstream(raw) if (!message) return drop() if ('event' in message) { @@ -97,7 +103,6 @@ function makeLink( if (message.ok) waiter?.resolve(message.result) else waiter?.reject(new Error(message.error)) }) - } const link: BrowserLink = { browser, diff --git a/src/main/extension-bridge/bridge-task-progress.ts b/src/main/extension-bridge/bridge-task-progress.ts new file mode 100644 index 000000000..62d5b4019 --- /dev/null +++ b/src/main/extension-bridge/bridge-task-progress.ts @@ -0,0 +1,103 @@ +// What a paired browser sees of a web task it started (tasks.latest): the task's id, so it can +// stop it, and the plan, phase, steps and action the desktop's own task row shows. Pure. + +import { isTaskPlanControlStep, taskExecutionPlanProgress } from '../../shared/task-execution-plan' +import type { TaskRunSnapshot } from '../tasks/task-history-store' +import type { BrowserTaskProgress } from './bridge-service' + +type TaskRow = Pick< + TaskRunSnapshot, + 'taskId' | 'status' | 'summary' | 'steps' | 'currentAction' | 'journeyId' | 'startedAt' +> + +export function browserTaskProgress(run: TaskRow): BrowserTaskProgress { + const progress = taskExecutionPlanProgress(run.steps) + return { + taskId: run.taskId, + status: run.status, + summary: run.summary ?? '', + plan: progress?.plan.phases.map((phase) => phase.title) ?? [], + phase: progress ? progress.activePhaseIndex : -1, + steps: run.steps.filter((step) => !isTaskPlanControlStep(step)), + action: run.status === 'running' ? (run.currentAction ?? '') : '' + } +} + +/** The newest task of this browser's journey started at or after `since`, as it sees it; with + * `taskId`, that one task, so a chat follows its own task while another chat starts one. */ +export function latestBrowserTask( + runs: readonly TaskRow[], + journeyId: string, + since: number, + taskId?: string +): BrowserTaskProgress | null { + // Rows come newest-updated first; the browser asks for the task it started last. + const run = runs + .filter((task) => task.journeyId === journeyId && task.startedAt >= since) + .filter((task) => taskId === undefined || task.taskId === taskId) + .reduce((a, b) => (a && a.startedAt >= b.startedAt ? a : b), null) + return run ? browserTaskProgress(run) : null +} + +/** A task this browser's chat was given an id for, that has not started yet: queued behind + * another task, or waiting for approval. Its own id, so the chat follows and stops only it. */ +export function queuedBrowserTask(taskId: string): BrowserTaskProgress { + return { + taskId, + status: 'queued', + summary: '', + plan: [], + phase: -1, + steps: [], + action: 'Waiting to start' + } +} + +/** The task ids each paired browser was given, by id: a chat follows and stops its own task by + * that id before task history has a row for it. Bounded: the oldest are forgotten first. */ +export interface AcceptedTasks { + remember(taskId: string, journeyId: string): void + /** Whether `taskId` was given to this browser's journey. */ + owns(taskId: string, journeyId: string): boolean +} + +export function createAcceptedTasks(limit = 200): AcceptedTasks { + const byId = new Map() + return { + remember(taskId, journeyId) { + byId.delete(taskId) + byId.set(taskId, journeyId) + while (byId.size > limit) { + const oldest = byId.keys().next().value + if (oldest === undefined) break + byId.delete(oldest) + } + }, + owns: (taskId, journeyId) => byId.get(taskId) === journeyId + } +} + +/** One browser task by its id: its history row when it has started, queued when it was given to + * this browser and has not, and nothing for anyone else's task. */ +export function browserTaskById( + run: TaskRow | null, + accepted: AcceptedTasks, + journeyId: string, + since: number, + taskId: string +): BrowserTaskProgress | null { + if (run) return latestBrowserTask([run], journeyId, since, taskId) + return accepted.owns(taskId, journeyId) ? queuedBrowserTask(taskId) : null +} + +/** How Stop reaches a browser's task: through its running session, before it starts, or not at + * all (another browser's task, or one this browser was never given). */ +export function stopRoute( + run: Pick | null, + accepted: AcceptedTasks, + journeyId: string, + taskId: string +): 'running' | 'before-start' | null { + if (run) return run.journeyId === journeyId ? 'running' : null + return accepted.owns(taskId, journeyId) ? 'before-start' : null +} diff --git a/src/main/imagegen.ts b/src/main/imagegen.ts index d7ae5c7df..f4ac51ce4 100644 --- a/src/main/imagegen.ts +++ b/src/main/imagegen.ts @@ -546,7 +546,8 @@ export function cancelImageGen(): boolean { */ export async function generateImage( params: ImageGenParams, - onUpdate?: (update: ImageGenerationPipelineUpdateContract) => void + onUpdate?: (update: ImageGenerationPipelineUpdateContract) => void, + signal?: AbortSignal ): Promise { return recordAIRequest( { @@ -557,6 +558,7 @@ export async function generateImage( isCancelled: (error) => error instanceof Error && error.message === IMAGE_CANCELLED_MESSAGE }, async (log) => { + if (signal?.aborted) throw new Error(IMAGE_CANCELLED_MESSAGE) // Prompt enhancement runs FIRST, while the chat model is still resident — the // image job below evicts the LLM, so the text pass must precede it. Gated by a // setting; failure/timeout silently keeps the original prompt. @@ -566,6 +568,7 @@ export async function generateImage( params.enhancePrompt, params.initImage ? [params.initImage] : [] ) + if (signal?.aborted) throw new Error(IMAGE_CANCELLED_MESSAGE) const remote = getActiveRemoteVisionServerForModality('image') const remoteId = remote ? remoteVisionModelId(remote.id, remote.selectedModel) : null if ( @@ -580,6 +583,8 @@ export async function generateImage( if (remoteAbort) throw new Error('An image is already generating — please wait for it to finish.') const controller = new AbortController() + const abort = (): void => controller.abort() + signal?.addEventListener('abort', abort, { once: true }) remoteAbort = controller onUpdate?.({ stage: 'preparing', enhancedPrompt: enhanced }) try { @@ -591,6 +596,7 @@ export async function generateImage( params.allowUnsafeMemoryOverride === true, controller.signal ) + if (signal?.aborted) throw new Error(IMAGE_CANCELLED_MESSAGE) const extension = result.mime === 'image/jpeg' ? 'jpg' : result.mime === 'image/webp' ? 'webp' : 'png' const directory = path.join(dataDir(), 'generated-images') @@ -612,6 +618,7 @@ export async function generateImage( computeBackend: 'Remote' } } finally { + signal?.removeEventListener('abort', abort) remoteAbort = null } } @@ -644,7 +651,7 @@ export async function generateImage( // The queue evicts 'llm' before this runs AND re-warms it (mode-aware) when the // job finishes — so the image path no longer touches llm.pause/resume itself. const output = await modalityQueue.run(IMAGE_JOB, () => - runImageGen(effective, progressObserver) + runImageGen(effective, progressObserver, signal) ) return { ...output, @@ -702,12 +709,19 @@ async function maybeEnhancePrompt( async function runImageGen( params: ImageGenParams, - onProgress?: (p: ImageGenProgress & { preview?: string }) => void + onProgress?: (p: ImageGenProgress & { preview?: string }) => void, + signal?: AbortSignal ): Promise { + if (signal?.aborted) throw new Error(IMAGE_CANCELLED_MESSAGE) if (generationLifecycle.isRunning()) { throw new Error('An image is already generating — please wait for it to finish.') } if (!params.prompt.trim()) throw new Error('A prompt is required.') + const cancelRuntime = (): void => { + cancelMflux() + void sdServer.cancelCurrent() + currentChild?.kill('SIGKILL') + } // --- MLX / mflux runtime branch (FLUX / Z-Image with native LoRA) ---------- // Self-contained: reuses the single-flight guard; the LLM is already evicted by the @@ -723,7 +737,7 @@ async function runImageGen( const outDir = path.join(dataDir(), 'generated-images') fs.mkdirSync(outDir, { recursive: true }) const outPath = path.join(outDir, `img-${String(Date.now())}.png`) - generationLifecycle.start() + generationLifecycle.start(signal, cancelRuntime) // Give the OS a moment to reclaim the freed LLM pages before the image load spike. try { await generationLifecycle.waitForMemoryReclaim() @@ -896,7 +910,7 @@ async function runImageGen( const { defaultSize, defaultSteps, defaultCfg, sampler, scheduler } = standardModelDefaults(base) const taesd = params.fastVae ? resolveTaesd(base) : undefined - generationLifecycle.start() + generationLifecycle.start(signal, cancelRuntime) try { await sdServer.ensureUp({ modelPath: model, @@ -1092,7 +1106,7 @@ async function runImageGen( args.push('--lora-model-dir', loraDir()) } - generationLifecycle.start() + generationLifecycle.start(signal, cancelRuntime) // CRITICAL on Apple Silicon (unified memory): the LLM (gemma) and the image // model can't both be resident — together they overflow RAM and the whole // system swaps/hangs. The ModalityQueue has already evicted the LLM (evicts: diff --git a/src/main/imagegen/generation-lifecycle.ts b/src/main/imagegen/generation-lifecycle.ts index 5018b72af..5e8d555b2 100644 --- a/src/main/imagegen/generation-lifecycle.ts +++ b/src/main/imagegen/generation-lifecycle.ts @@ -9,6 +9,7 @@ type GenerationState = 'idle' | 'running' | 'cancelled' */ export class ImageGenerationLifecycle { private state: GenerationState = 'idle' + private releaseAbortListener?: () => void isRunning(): boolean { return this.state !== 'idle' @@ -18,11 +19,20 @@ export class ImageGenerationLifecycle { return this.state === 'cancelled' } - start(): void { + start(signal?: AbortSignal, cancelRuntime?: () => void): void { + if (signal?.aborted) throw new Error(IMAGE_CANCELLED_MESSAGE) if (this.isRunning()) { throw new Error('An image is already generating — please wait for it to finish.') } this.state = 'running' + if (signal) { + const abort = (): void => { + this.cancel() + cancelRuntime?.() + } + signal.addEventListener('abort', abort, { once: true }) + this.releaseAbortListener = () => signal.removeEventListener('abort', abort) + } } cancel(): boolean { @@ -32,6 +42,8 @@ export class ImageGenerationLifecycle { } finish(): void { + this.releaseAbortListener?.() + this.releaseAbortListener = undefined this.state = 'idle' } diff --git a/src/main/index.ts b/src/main/index.ts index f37cd971a..b49af76b5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -42,6 +42,7 @@ import { serveArtifactPreview } from './artifact-preview' import { ipcMain } from 'electron' import { loadProEntitlementProvider, loadProFeaturesMain } from './bootstrap/loadProFeaturesMain' import { resolveWindowPresentation } from './bootstrap/window-presentation' +import { callHook, HOOKS } from './bootstrap/hookRegistry' import { mayUseIsolatedEvidenceInstance } from './bootstrap/isolated-evidence-instance' /** @@ -585,6 +586,9 @@ app.whenReady().then(async () => { // imports and local-model startup begin competing for CPU and memory. await new Promise((resolve) => setImmediate(resolve)) + // Features that show something beside the main window (Pro's desktop companion) start now. + if (windowPresentation.showWindow) callHook(HOOKS.mainWindowShown) + // Network checks, model work, and optional services now run beside the visible shell. void runIndependentStartupStages([ { @@ -655,7 +659,10 @@ app.whenReady().then(async () => { const { llm } = await import('./llm') registerRuntime(llm.runtime) applyQueueConfig(modalityQueue, readQueueConfig(getSetting)) - if (llm.modelsExist()) await llm.init() + // A remote chat model needs no local engine: loading one anyway holds gigabytes of + // memory for nothing. Chat starts it on demand if the user switches back. + const { getActiveRemoteVisionServer } = await import('./vision/remote-vision-server') + if (llm.modelsExist() && !getActiveRemoteVisionServer()) await llm.init() } }) })() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 9354e204d..625807b4d 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -553,9 +553,7 @@ export async function summarizeSession(sessionId: string): Promise `[${m.role || 'unknown'}]: ${m.content}`) - .join('\n') + const conversationText = memories.map((m) => `[${m.role || 'unknown'}]: ${m.content}`).join('\n') const prompt = getPrompt('sessionSummary', { CONVERSATION_TEXT: conversationText }) try { @@ -914,7 +912,12 @@ export function setupIPC(): void { // which failed the whole retrieval. Preserves the any-term (OR) recall the retrieval expects. const ftsQuery = ftsMatchExpression(query) - let memories: Array<{ source_app: string | null; created_at: string; content: string; score?: number }> = [] + let memories: Array<{ + source_app: string | null + created_at: string + content: string + score?: number + }> = [] try { const queryVector = await embeddings.generateEmbedding(query) const vecStr = JSON.stringify(queryVector) @@ -966,7 +969,18 @@ export function setupIPC(): void { messageParams.push(msgFilter.param) } messageQuery += ` ORDER BY score ASC LIMIT 12` - const messages = db.prepare(messageQuery).all(...messageParams) + const messages = db + .prepare< + unknown[], + { + app_name: string | null + title: string | null + created_at: string + role: string + content: string + } + >(messageQuery) + .all(...messageParams) const summaryParams: unknown[] = [ftsQuery] let summaryQuery = ` @@ -983,7 +997,12 @@ export function setupIPC(): void { summaryParams.push(sumFilter.param) } summaryQuery += ` ORDER BY score ASC LIMIT 8` - const summaries = db.prepare(summaryQuery).all(...summaryParams) + const summaries = db + .prepare< + unknown[], + { app_name: string | null; title: string | null; summary: string } + >(summaryQuery) + .all(...summaryParams) const entityParams: unknown[] = [ftsQuery] let entityQuery = ` @@ -1006,7 +1025,12 @@ export function setupIPC(): void { entityParams.push(entFilter.param) } entityQuery += ` ORDER BY score ASC LIMIT 8` - const entities = db.prepare(entityQuery).all(...entityParams) + const entities = db + .prepare< + unknown[], + { type: string | null; name: string; summary: string | null } + >(entityQuery) + .all(...entityParams) const factParams: unknown[] = [ftsQuery] let factQuery = ` @@ -1023,15 +1047,16 @@ export function setupIPC(): void { factParams.push(factFilter.param) } factQuery += ` ORDER BY score ASC LIMIT 8` - const entityFacts = db.prepare(factQuery).all(...factParams) + const entityFacts = db + .prepare(factQuery) + .all(...factParams) // Supplementary context (no bracket labels — the ONLY citeable tags are the // numbered [S#] SOURCES below, so the model can't invent uncited labels). const memoryLines = memories .slice(0, 6) .map( - (m) => - `- (${m.source_app || 'Unknown'} | ${m.created_at}): ${clipText(m.content, 500)}` + (m) => `- (${m.source_app || 'Unknown'} | ${m.created_at}): ${clipText(m.content, 500)}` ) .join('\n') @@ -1265,8 +1290,14 @@ export function setupIPC(): void { ipcMain.handle( 'rag:create-conversation', - (_, id: string, title?: string, projectId?: string | null) => { - return createRagConversation(id, title, projectId) + ( + _, + id: string, + title?: string, + projectId?: string | null, + surface?: import('../shared/ipc-contracts').ConversationSurface + ) => { + return createRagConversation(id, title, projectId, surface) } ) @@ -1275,8 +1306,12 @@ export function setupIPC(): void { // page rather than the whole table. ipcMain.handle( 'rag:get-conversations', - (_, projectId?: string | null, page?: import('./database').RagConversationPage) => - getRagConversations(projectId, page) + ( + _, + projectId?: string | null, + page?: import('./database').RagConversationPage, + surface?: import('../shared/ipc-contracts').ConversationSurface + ) => getRagConversations(projectId, page, surface) ) ipcMain.handle('rag:search-conversation-ids', (_, query: string, limit?: number) => @@ -1346,8 +1381,13 @@ export function setupIPC(): void { // Browsers paired through the extension bridge, and which are connected (Tasks > Web Use). ipcMain.handle('extension-bridge:browsers', () => listBridgeBrowsers()) - // App version (for the Settings footer — so users know what build they're on). - ipcMain.handle('app:version', () => app.getVersion()) + // App version (for the Settings footer — so users know what build they're on). A packaged app + // reads its own; an unpackaged launch of a script reports Electron's, so it uses the build's. + ipcMain.handle('app:version', () => + !app.isPackaged && typeof __OFFGRID_APP_VERSION__ === 'string' + ? __OFFGRID_APP_VERSION__ + : app.getVersion() + ) ipcMain.handle('settings:save', (_, key: string, value: unknown) => { if (key === COMPUTER_USE_SETTINGS_KEY) setComputerUseSettings(value) @@ -1363,11 +1403,14 @@ export function setupIPC(): void { setResidencyMode(modality, mode) ) ipcMain.handle('runtime:backend:get', () => getBackendPreferences()) - ipcMain.handle('runtime:backend:set', async (_e, modality: BackendModality, preference: BackendPreference) => { - const next = setBackendPreference(modality, preference) - // A loaded engine keeps its present backend until its next load. The UI says so. - return next - }) + ipcMain.handle( + 'runtime:backend:set', + async (_e, modality: BackendModality, preference: BackendPreference) => { + const next = setBackendPreference(modality, preference) + // A loaded engine keeps its present backend until its next load. The UI says so. + return next + } + ) // Unload one modality's model from memory now (the "free RAM" button). Goes through // the same evict() seam as residency/shutdown; the engine reloads on next use. ipcMain.handle('runtime:unload', async (_e, modality: Modality) => { @@ -1692,7 +1735,7 @@ export function setupIPC(): void { import('./models-manager').then((m) => m.searchModels(query, kind)) ) ipcMain.handle('models:files', (_, modelId: string) => - import('@offgrid/models').then((m) => m.getModelFiles(modelId)) + import('./models-manager').then((m) => m.getModelFiles(modelId)) ) ipcMain.handle('models:download', async (_, modelId: string, fileName?: string) => { @@ -1741,6 +1784,13 @@ export function setupIPC(): void { ipcMain.handle('models:web-use-active', () => import('./vision/vision-task-model-strategy').then((m) => m.getWebUseActiveModelProjection()) ) + // Every Models screen's view of the task models, and the one way to give a model a role. + ipcMain.handle('models:task-roles', () => + import('./vision/vision-task-model-strategy').then((m) => m.getTaskRolesView()) + ) + ipcMain.handle('models:set-task-role', (_, task: unknown, role: unknown, modelId: unknown) => + import('./task-role-requests').then((m) => m.setTaskRoleFromRequest({ task, role, modelId })) + ) // Storage + download manager ipcMain.handle('models:storage', () => import('./models-manager').then((m) => m.getStorageInfo())) @@ -2021,6 +2071,7 @@ export function setupIPC(): void { history?: { role: string; content: string }[], opts?: { assistantOnly?: boolean + allTools?: boolean connectors?: boolean conversationId?: string projectId?: string @@ -2029,6 +2080,8 @@ export function setupIPC(): void { imageAvailable?: boolean streamId?: string thinking?: boolean + /** What the user is doing now (God's context), added to the system prompt. */ + context?: string } ) => { const { toolChat } = await import('./tools') diff --git a/src/main/llm.ts b/src/main/llm.ts index 630392606..e6bd81afe 100644 --- a/src/main/llm.ts +++ b/src/main/llm.ts @@ -35,14 +35,19 @@ import { detectThinkingDialect, type ThinkingDialect } from './llm/thinking-dial import { isValidGgufFile } from './models/gguf' import { CATALOG, isGrounderModel } from '@offgrid/models' import { readGgufContextLength, readGgufMtpSupport } from './models/gguf-metadata' -import { dflashFileName, primaryFileName, type CatalogEntry } from './models/catalog-logic' +import { + MTP_COMPANIONS, + dflashFileName, + primaryFileName, + type CatalogEntry +} from './models/catalog-logic' import { pickFreePort, isPortFree } from './free-port' import { postCompletionOnce } from './llm/http-post' import { engineSpawnEnv } from './llm/spawn-env' import { gpuDeviceAvailable } from './llm/gpu-device-probe' import { shouldAutoRecover } from './llm/crash-policy' import { enginePriority } from './llm/engine-priority' -import { modelStartupTimeout } from './llm/startup-timeout' +import { modelStartupQuietLimit, startupStalled } from './llm/startup-timeout' import { getBackendPreference } from './backend-preferences' import { prioritizeBackend, type BackendPreference } from '../shared/backend-preferences' import { streamCompletion, type StreamResult } from './llm/stream' @@ -87,6 +92,7 @@ export interface LlmSettings { threads?: number // CPU threads for inference batchSize?: number // -b: prompt batch size speculativeDecoding?: SpeculativeDecodingMode + speculativeDraftMax?: number // 0 = Auto, otherwise 1-16 draft tokens draftModel?: string // filename of an installed GGUF in the app's model directory supportsMtp?: boolean // read-only capability of the selected GGUF compatibleDraftModels?: string[] // read-only filenames with the same tokenizer/vocabulary @@ -283,6 +289,7 @@ export class LLMService { private threads: number | undefined private batchSize: number | undefined private speculativeDecoding: SpeculativeDecodingMode = 'off' + private speculativeDraftMax = 0 private draftModel = '' // Crash recovery: distinguish an intentional kill (stop/reload/settings respawn) // from an unexpected crash so we only auto-restart on real crashes. @@ -308,6 +315,8 @@ export class LLMService { // Last ~50 stderr lines from llama-server, so we can explain WHY it died on // load (unknown arch / OOM / OS-too-old) instead of a blank "Down". private stderrTail: string[] = [] + // When the starting llama-server last wrote anything; waitForReady gives up only on silence. + private lastServerOutputAt = 0 // Human, actionable reason the server failed to come up (null when healthy). private lastErrorMsg: string | null = null private get settingsFile(): string { @@ -366,6 +375,8 @@ export class LLMService { ) this.speculativeDecoding = s.speculativeDecoding if (typeof s.draftModel === 'string') this.draftModel = path.basename(s.draftModel) + if (Number.isInteger(s.speculativeDraftMax) && s.speculativeDraftMax >= 0 && s.speculativeDraftMax <= 16) + this.speculativeDraftMax = s.speculativeDraftMax if ( s.performanceMode === 'conservative' || s.performanceMode === 'balanced' || @@ -428,7 +439,23 @@ export class LLMService { } private supportsMtp(): boolean { - return !!this.modelPath && readGgufMtpSupport(this.modelPath, fs) + return ( + !!this.modelPath && + isValidGgufFile(this.modelPath, fs) && + (readGgufMtpSupport(this.modelPath, fs) || !!this.mtpModelPath()) + ) + } + + private mtpModelPath(): string | undefined { + const selected = path.basename(this.modelPath) + const companion = /^Qwen3\.8-27B-(?:UD-)?(?:Q|IQ)[A-Z0-9_]+\.gguf$/i.test(selected) + ? MTP_COMPANIONS['unsloth/Qwen3.8-27B-GGUF'] + : /^tencent_UI-Mate-9B-(?:Q|IQ)[A-Z0-9_]+(?:\+MTP)?\.gguf$/i.test(selected) + ? MTP_COMPANIONS['bartowski/tencent_UI-Mate-9B-GGUF+MTP'] + : undefined + if (!companion) return undefined + const candidate = path.join(getModelsDir(), companion.name) + return readGgufMtpSupport(candidate, fs, undefined, companion.sizeBytes) ? candidate : undefined } private speculativeModelCapabilities(): { @@ -517,6 +544,7 @@ export class LLMService { threads: this.threads, batchSize: this.batchSize, speculativeDecoding: this.speculativeDecoding, + speculativeDraftMax: this.speculativeDraftMax, draftModel: this.draftModel, supportsMtp: this.supportsMtp(), ...speculativeCapabilities, @@ -543,7 +571,11 @@ export class LLMService { /** Build argv for the selected context and a GPU-layer count. */ private launchArgsFor(effectiveCtxSize: number, gpuLayers: number): string[] { - const useSelectedModelSpeculation = this.runtimeModelOverride === null + const useSelectedModelSpeculation = + this.runtimeModelOverride === null && + this.speculativeModeSupported(this.speculativeDecoding, this.draftModel) + const useRuntimeMtp = + this.runtimeModelOverride !== null && this.speculativeDecoding === 'mtp' && this.supportsMtp() return buildLaunchArgs({ modelPath: this.modelPath, mmProjPath: this.mmProjPath, @@ -554,14 +586,26 @@ export class LLMService { kvCacheType: this.kvCacheType, threads: this.threads, batchSize: this.batchSize, - speculativeDecoding: useSelectedModelSpeculation ? this.speculativeDecoding : 'off', - draftModelPath: useSelectedModelSpeculation ? this.draftModelPath() : undefined, + speculativeDraftMax: this.speculativeDraftMax, + speculativeDecoding: useSelectedModelSpeculation + ? this.speculativeDecoding + : useRuntimeMtp + ? 'mtp' + : 'off', + draftModelPath: useSelectedModelSpeculation + ? this.draftModelPath() + : useRuntimeMtp + ? this.mtpModelPath() + : undefined, imageMinTokens: this.imageMinTokensForModel(), reportModelPlacement: true }) } private draftModelPath(): string | undefined { + if (this.speculativeDecoding === 'mtp') { + return this.mtpModelPath() + } if (!this.speculativeModeSupported(this.speculativeDecoding, this.draftModel)) return undefined if (!this.draftModel || path.basename(this.draftModel) !== this.draftModel) return undefined const candidate = path.join(getModelsDir(), this.draftModel) @@ -614,6 +658,10 @@ export class LLMService { /** Update inference settings; respawns the server if any launch-time arg changed * (context, KV-cache type, flash-attn, GPU layers, threads, batch). */ async setSettings(s: LlmSettings, options: LlmSettingsUpdateOptions = {}): Promise { + if (s.speculativeDraftMax !== undefined && + (!Number.isInteger(s.speculativeDraftMax) || s.speculativeDraftMax < 0 || s.speculativeDraftMax > 16)) { + throw new Error('Draft token limit must be Auto (0) or a whole number from 1 to 16.') + } this.ensureLoaded() this.resolveModel() const requestedMode = s.speculativeDecoding ?? this.speculativeDecoding @@ -668,6 +716,7 @@ export class LLMService { threads: this.threads, batchSize: this.batchSize, speculativeDecoding: this.speculativeDecoding, + speculativeDraftMax: this.speculativeDraftMax, draftModel: this.draftModel }, modeChanged @@ -711,6 +760,8 @@ export class LLMService { this.speculativeDecoding = compatibleSettings.speculativeDecoding if (typeof compatibleSettings.draftModel === 'string') this.draftModel = path.basename(compatibleSettings.draftModel) + if (typeof compatibleSettings.speculativeDraftMax === 'number') + this.speculativeDraftMax = compatibleSettings.speculativeDraftMax // Quantized KV cache requires FlashAttention — auto-enable it so the pair is valid. if (this.kvCacheType !== 'f16' && !this.flashAttn) this.flashAttn = true const restorePrior = ( @@ -738,6 +789,7 @@ export class LLMService { this.threads = priorSettings.threads this.batchSize = priorSettings.batchSize this.speculativeDecoding = priorSettings.speculativeDecoding ?? this.speculativeDecoding + this.speculativeDraftMax = priorSettings.speculativeDraftMax ?? 0 this.draftModel = priorSettings.draftModel ?? this.draftModel this.userExplicit.clear() priorExplicit.forEach((field) => this.userExplicit.add(field)) @@ -873,6 +925,7 @@ export class LLMService { /** Load a Computer Use specialist without changing the saved Text model. */ useRuntimeModel(model: { id: string; primary: string; mmproj: string | null }): void { + this.ensureLoaded() this.runtimeModelOverride = { ...model } this.reloadModel() } @@ -1180,6 +1233,13 @@ export class LLMService { console.warn(`[LLMService] out of memory — retrying load at ${at.reason}`) } const args = this.launchArgsFor(at.ctxSize, at.gpuLayers) + // The six-token Qwen3.5 9B gain was measured with stock llama.cpp on + // Metal. Preserve the existing limit for other engines and backends. + const draftMaxIndex = args.indexOf('--spec-draft-n-max') + if (draftMaxIndex >= 0 && this.speculativeDecoding === 'mtp' && this.speculativeDraftMax === 0 && + (engineDir !== 'llama' || cpuOnly || process.platform !== 'darwin')) { + args[draftMaxIndex + 1] = '2' + } // Captured in the same step as the arguments: a save can land during init's earlier awaits, // so only this matches what actually ran. A successful spawn makes it the working launch. const launchedWith = { settings: this.getSettings(), explicit: new Set(this.userExplicit) } @@ -1270,6 +1330,7 @@ export class LLMService { this.backendModelPath = this.modelPath this.backendPlacement = undefined this.stderrTail = [] + this.lastServerOutputAt = Date.now() this.invalidateHealth() let abandoned = false // set when we give up on this proc so its close handler is inert // True until waitForReady() confirms THIS engine. A close while probing is a failed @@ -1284,7 +1345,11 @@ export class LLMService { console.error(`[LLMService] llama-server process error:`, e) }) + proc.stdout?.on('data', () => { + this.lastServerOutputAt = Date.now() + }) proc.stderr?.on('data', (data) => { + this.lastServerOutputAt = Date.now() const text = String(data) // Verbosity 4 also emits request traces. Read and log it only during model // load so user prompts cannot enter the app log or its diagnostic tail. @@ -1329,10 +1394,9 @@ export class LLMService { }) try { - // A cold CUDA load can spend over a minute on model weights and CLIP - // initialization while the server is still healthy. Do not kill it and - // fall back to CPU at the normal one-minute deadline. - await this.waitForReady(modelStartupTimeout(path.basename(binDir))) + // No total deadline: a large model loads for as long as llama-server keeps + // printing progress. Only a crash or a silent, stuck server moves to the next engine. + await this.waitForReady(modelStartupQuietLimit(path.basename(binDir))) if (this.server !== proc) throw new Error('Model load was cancelled') // Confirmed healthy: from here a close IS a crash worth recovering from. probing = false @@ -1486,10 +1550,9 @@ export class LLMService { } } - private async waitForReady(timeout = 60000): Promise { - const start = Date.now() + private async waitForReady(quietLimit = 60000): Promise { let healthOk = false - while (Date.now() - start < timeout) { + while (!startupStalled(Date.now(), this.lastServerOutputAt, quietLimit)) { // The server died during startup (e.g. model load failure) — stop waiting. if (!this.server) throw new Error('llama-server exited during startup — model failed to load') try { @@ -1512,7 +1575,7 @@ export class LLMService { } await new Promise((r) => setTimeout(r, 500)) // NOSONAR: wait between sequential startup probes. } - throw new Error('Server started but no model was loaded within the timeout') + throw new Error('Server stopped responding while loading the model') } // Use Node http module instead of fetch to avoid undici's headersTimeout (300s) diff --git a/src/main/llm/__tests__/mtp-launch.integration.test.ts b/src/main/llm/__tests__/mtp-launch.integration.test.ts new file mode 100644 index 000000000..8d7ecb509 --- /dev/null +++ b/src/main/llm/__tests__/mtp-launch.integration.test.ts @@ -0,0 +1,196 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { LLMService, SpeculativeDecodingMode } from '../../llm' +import { MTP_COMPANIONS, catalogEntryInstalled, visionStatus } from '../../models/catalog-logic' +import { readGgufMtpSupport } from '../../models/gguf-metadata' + +const companion = MTP_COMPANIONS['unsloth/Qwen3.8-27B-GGUF'] +const primary = 'Qwen3.8-27B-UD-Q4_K_M.gguf' + +// A small GGUF metadata fixture on real disk. The sidecar is sparse, so its +// expected file size is faithful without allocating or reading model weights. +function gguf(file: string, embeddedMtp: boolean, size = 1024, metadataPadding = 0): void { + const u32 = (value: number): Buffer => { + const buffer = Buffer.alloc(4) + buffer.writeUInt32LE(value) + return buffer + } + const u64 = (value: number): Buffer => { + const buffer = Buffer.alloc(8) + buffer.writeBigUInt64LE(BigInt(value)) + return buffer + } + const str = (value: string): Buffer => + Buffer.concat([u64(Buffer.byteLength(value)), Buffer.from(value)]) + const values = [Buffer.concat([str('general.architecture'), u32(8), str('qwen35')])] + if (metadataPadding) values.push(Buffer.concat([str('tokenizer.chat_template'), u32(8), str('x'.repeat(metadataPadding))])) + if (embeddedMtp) values.push(Buffer.concat([str('qwen35.nextn_predict_layers'), u32(4), u32(1)])) + fs.writeFileSync( + file, + Buffer.concat([Buffer.from('GGUF'), u32(3), u64(0), u64(values.length), ...values]) + ) + fs.truncateSync(file, size) +} + +describe('MTP installation and persisted launch contract', () => { + let profile: string + const previousDataDir = process.env.OFFGRID_DATA_DIR + + beforeEach(() => { + profile = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-mtp-')) + fs.mkdirSync(path.join(profile, 'models')) + process.env.OFFGRID_DATA_DIR = profile + }) + + afterEach(() => { + if (previousDataDir === undefined) delete process.env.OFFGRID_DATA_DIR + else process.env.OFFGRID_DATA_DIR = previousDataDir + fs.rmSync(profile, { recursive: true, force: true }) + }) + + async function load( + selected = primary, + embeddedMtp = false, + speculativeDecoding: SpeculativeDecodingMode = 'mtp' + ): Promise { + const models = path.join(profile, 'models') + gguf(path.join(models, selected), embeddedMtp) + const { importLocalModel, setActiveModel } = await import('../../models-manager') + const imported = await importLocalModel(path.join(models, selected)) + expect(imported.success).toBe(true) + expect((await setActiveModel(imported.id!)).success).toBe(true) + const { LLMService } = await import('../../llm') + const service = new LLMService() + await service.pause() + await service.setSettings( + { + speculativeDecoding, + draftModel: 'unrelated.gguf', + gpuLayers: 99, + ctxSize: 8192, + kvCacheType: 'q4_0', + flashAttn: true + }, + { emitSync: false } + ) + return new LLMService() + } + + it('loads the matching sidecar after restart and keeps the unrelated draft out of the engine', async () => { + const sidecar = path.join(profile, 'models', companion.name) + gguf(sidecar, true, companion.sizeBytes) + const service = await load() + expect(service.getSettings()).toMatchObject({ supportsMtp: true, speculativeDecoding: 'mtp' }) + const args = service.launchArgs() + expect(args).toEqual( + expect.arrayContaining(['--spec-type', 'draft-mtp', '--spec-draft-model', sidecar]) + ) + expect(args[args.indexOf('--spec-draft-n-max') + 1]).toBe('2') + expect(args[args.indexOf('--spec-draft-ngl') + 1]).toBe('99') + expect(args).not.toContain('unrelated.gguf') + }) + + it('keeps embedded MTP working without loading an unrelated or unnecessary draft', async () => { + const service = await load(primary, true) + expect(service.getSettings().supportsMtp).toBe(true) + expect(service.launchArgs()).toContain('draft-mtp') + expect(service.launchArgs()).not.toContain('--spec-draft-model') + }) + + it('recognizes prediction heads after large tokenizer metadata', () => { + const model = path.join(profile, 'models', 'late-mtp.gguf') + gguf(model, true, 5 * 1024 * 1024, 4 * 1024 * 1024) + expect(readGgufMtpSupport(model, fs)).toBe(true) + }) + + it('keeps the user draft token limit after restart and can return to Auto', async () => { + const service = await load('Qwen3.5-9B-Q4_K_M.gguf', true) + await service.pause() + await service.setSettings({ speculativeDraftMax: 6 }, { emitSync: false }) + const { LLMService } = await import('../../llm') + const restarted = new LLMService() + expect(restarted.getSettings().speculativeDraftMax).toBe(6) + await restarted.pause() + await restarted.setSettings({ speculativeDraftMax: 0 }, { emitSync: false }) + expect(new LLMService().getSettings().speculativeDraftMax).toBe(0) + }) + + it.each([-1, 17, 2.5, NaN])('rejects an invalid draft token limit (%s)', async (limit) => { + const service = await load(primary, true) + await service.pause() + await expect(service.setSettings({ speculativeDraftMax: limit }, { emitSync: false })) + .rejects.toThrow('Draft token limit') + expect(service.getSettings().speculativeDraftMax).toBe(0) + }) + + it('uses the installed MTP companion even when the target also declares native heads', async () => { + const sidecar = path.join(profile, 'models', companion.name) + gguf(sidecar, true, companion.sizeBytes) + const service = await load(primary, true) + const args = service.launchArgs() + expect(args[args.indexOf('--spec-draft-model') + 1]).toBe(sidecar) + }) + + it('does not apply the selected model MTP setting to a runtime specialist', async () => { + const service = await load(primary, true) + gguf(path.join(profile, 'models', 'specialist.gguf'), false) + service.useRuntimeModel({ id: 'local:specialist', primary: 'specialist.gguf', mmproj: null }) + expect(service.launchArgs()).not.toContain('draft-mtp') + service.restoreSelectedModel() + expect(service.launchArgs()).toContain('draft-mtp') + }) + + it.each(['missing', 'truncated', 'no-head', 'wrong-model'])( + 'does not enable MTP for a %s companion', + async (condition) => { + if (condition !== 'missing') { + gguf( + path.join(profile, 'models', companion.name), + condition !== 'no-head', + condition === 'truncated' ? 1024 : companion.sizeBytes + ) + } + const service = await load(condition === 'wrong-model' ? 'Qwen3.5-9B-Q4_K_M.gguf' : primary) + expect(service.getSettings()).toMatchObject({ + supportsMtp: false, + speculativeDecoding: 'off' + }) + expect(service.launchArgs()).not.toContain('draft-mtp') + } + ) + + it('does not offer the Qwen MTP companion to Bonsai Prism', async () => { + gguf(path.join(profile, 'models', companion.name), true, companion.sizeBytes) + const service = await load('Ternary-Bonsai-2-27B-PQ2_0.gguf') + expect(service.getSettings()).toMatchObject({ supportsMtp: false, speculativeDecoding: 'off' }) + expect(service.launchArgs()).not.toContain('--spec-type') + expect(service.launchArgs()).not.toContain('--spec-draft-model') + }) + + it.each([primary, 'Ternary-Bonsai-2-27B-PQ2_0.gguf'])( + 'preserves N-gram decoding for %s without adding an MTP companion', + async (selected) => { + gguf(path.join(profile, 'models', companion.name), true, companion.sizeBytes) + const service = await load(selected, false, 'ngram') + expect(service.getSettings().speculativeDecoding).toBe('ngram') + const args = service.launchArgs() + expect(args[args.indexOf('--spec-type') + 1]).toBe('ngram-cache') + expect(args).not.toContain('--spec-draft-model') + expect(args).not.toContain('--spec-draft-n-max') + } + ) + + it('offers companion repair while leaving the main model installed', async () => { + const { desktopCatalog } = await import('../../models-manager') + const entry = (await desktopCatalog()).find((model) => model.id === 'unsloth/Qwen3.8-27B-GGUF')! + expect(entry.files.some((file) => file.name === companion.name && file.role === 'aux')).toBe( + true + ) + const present = (name: string): boolean => name !== companion.name + expect(catalogEntryInstalled(entry, present, () => false)).toBe(true) + expect(visionStatus(entry, present)).toMatchObject({ supportsMtp: true, mtpInstalled: false }) + expect(visionStatus(entry, () => true)).toMatchObject({ supportsMtp: true, mtpInstalled: true }) + }) +}) diff --git a/src/main/llm/__tests__/remote-reasoning-fields.test.ts b/src/main/llm/__tests__/remote-reasoning-fields.test.ts new file mode 100644 index 000000000..881db7c83 --- /dev/null +++ b/src/main/llm/__tests__/remote-reasoning-fields.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { remoteReasoningFields } from '../remote-chat' + +// The one place Chat and the gateway turn a remote model's reasoning on or off. +describe('remoteReasoningFields', () => { + it('never switches off reasoning an OpenRouter model requires', () => { + // OpenRouter answers HTTP 400 "Reasoning is mandatory for this endpoint and cannot be disabled". + expect(remoteReasoningFields(false, 512, { control: 'openrouter', mandatory: true })).toEqual({}) + }) + + it('switches it off when the model allows that, and on with the budget', () => { + expect(remoteReasoningFields(false, 512, { control: 'openrouter' })).toEqual({ + reasoning: { effort: 'none' } + }) + expect(remoteReasoningFields(true, 512, { control: 'openrouter' })).toHaveProperty('reasoning') + }) + + it('leaves a request alone when nobody asked or the model has no control', () => { + expect(remoteReasoningFields(undefined, 512, { control: 'openrouter' })).toEqual({}) + expect(remoteReasoningFields(false, 512, { control: 'none' })).toEqual({}) + }) +}) diff --git a/src/main/llm/__tests__/startup-timeout.test.ts b/src/main/llm/__tests__/startup-timeout.test.ts index 73f446a7e..29cabad67 100644 --- a/src/main/llm/__tests__/startup-timeout.test.ts +++ b/src/main/llm/__tests__/startup-timeout.test.ts @@ -1,12 +1,20 @@ import { expect, it } from 'vitest' -import { modelStartupTimeout } from '../startup-timeout' +import { modelStartupQuietLimit, startupStalled } from '../startup-timeout' -it('allows a cold CUDA model load to finish after the normal startup limit', () => { - expect(modelStartupTimeout('llama-cuda')).toBe(180_000) - expect(modelStartupTimeout('llama-prism-cuda')).toBe(180_000) +it('allows a cold CUDA load to stay quiet for longer than other engines', () => { + expect(modelStartupQuietLimit('llama-cuda')).toBe(180_000) + expect(modelStartupQuietLimit('llama-prism-cuda')).toBe(180_000) + expect(modelStartupQuietLimit('llama')).toBe(60_000) + expect(modelStartupQuietLimit('llama-cpu')).toBe(60_000) }) -it('keeps the normal startup limit for Vulkan and CPU engines', () => { - expect(modelStartupTimeout('llama')).toBe(60_000) - expect(modelStartupTimeout('llama-cpu')).toBe(60_000) +it('keeps waiting on a slow load that still prints progress, however long it takes', () => { + const start = 0 + // Ten minutes in, but the server wrote a line 5 seconds ago. + expect(startupStalled(start + 600_000, start + 595_000, 60_000)).toBe(false) +}) + +it('gives up on a server that has gone silent past the quiet limit', () => { + expect(startupStalled(61_000, 0, 60_000)).toBe(true) + expect(startupStalled(60_000, 0, 60_000)).toBe(false) }) diff --git a/src/main/llm/remote-chat.ts b/src/main/llm/remote-chat.ts index c6e010ea6..f1149f39d 100644 --- a/src/main/llm/remote-chat.ts +++ b/src/main/llm/remote-chat.ts @@ -209,35 +209,58 @@ export function remoteTextModelProviderError(status: number, rawBody: string): E ) } +/** + * The request fields that turn a remote model's reasoning on or off, for its provider. One owner for + * Chat and the gateway: a model whose reasoning is mandatory never gets it switched off, which the + * provider refuses ("Reasoning is mandatory for this endpoint and cannot be disabled"). + */ +export function remoteReasoningFields( + thinking: boolean | undefined, + budget: number, + capability: RemoteReasoningCapability +): Record { + const ollamaEffort = + budget > 0 ? (budget <= 1024 ? 'low' : budget <= 4096 ? 'medium' : 'high') : 'medium' + return thinking === undefined || capability.control === 'none' + ? {} + : capability.control === 'openrouter' + ? thinking + ? openRouterReasoningPayload(true, budget) + : capability.mandatory + ? {} + : { reasoning: { effort: 'none' } } + : capability.control === 'ollama' + ? { reasoning_effort: thinking ? ollamaEffort : 'none' } + : capability.control === 'reasoning-strength' + ? { chat_template_kwargs: { reasoning_strength: thinking ? 'high' : 'none' } } + : { + chat_template_kwargs: { enable_thinking: thinking }, + ...(thinking && capability.tokenBudget && budget > 0 + ? { reasoning_budget_tokens: budget } + : {}) + } +} + /** The OpenAI-compatible request body. Pure: what we send, with nothing about how we send it. */ function completionRequestBody( remote: RemoteTextModelConnection, request: RemoteChatRequest, capability: RemoteReasoningCapability ): string { - const thinking = request.thinking - const budget = request.reasoningBudget ?? REASONING_BUDGET_AUTO - const ollamaEffort = - budget > 0 ? (budget <= 1024 ? 'low' : budget <= 4096 ? 'medium' : 'high') : 'medium' - const reasoning = - thinking === undefined || capability.control === 'none' - ? {} - : capability.control === 'openrouter' - ? thinking - ? openRouterReasoningPayload(true, budget) - : capability.mandatory - ? {} - : { reasoning: { effort: 'none' } } - : capability.control === 'ollama' - ? { reasoning_effort: thinking ? ollamaEffort : 'none' } - : capability.control === 'reasoning-strength' - ? { chat_template_kwargs: { reasoning_strength: thinking ? 'high' : 'none' } } - : { - chat_template_kwargs: { enable_thinking: thinking }, - ...(thinking && capability.tokenBudget && budget > 0 - ? { reasoning_budget_tokens: budget } - : {}) - } + const reasoning = remoteReasoningFields( + request.thinking, + request.reasoningBudget ?? REASONING_BUDGET_AUTO, + capability + ) + // Alibaba rejects forced tool choice while Qwen is thinking. Keep reasoning and let the + // model select a tool; the visual policy caller still validates exactly one transition. + const toolChoice = + remote.provider === 'openrouter' && + remote.model.startsWith('qwen/') && + request.thinking === true && + request.toolChoice === 'required' + ? 'auto' + : (request.toolChoice ?? 'auto') return JSON.stringify({ model: remote.model, messages: request.messages, @@ -252,7 +275,7 @@ function completionRequestBody( : { repeat_penalty: request.repeatPenalty, repetition_penalty: request.repeatPenalty }), ...(request.responseFormat ? { response_format: request.responseFormat } : {}), ...(request.tools?.length - ? { tools: request.tools, tool_choice: request.toolChoice ?? 'auto' } + ? { tools: request.tools, tool_choice: toolChoice } : {}), ...reasoning, stream: true @@ -408,7 +431,12 @@ export async function streamRemoteChatCompletion(input: { log?.update({ response: result, metrics: { ...result.metrics } }) writeDiagnosticLog('remote_chat', 'request.completed', { provider: remote.provider, - model: remote.model + model: remote.model, + // Why it stopped and what came back (sizes only), so an empty answer can be explained. + finishReason: result.finishReason ?? 'none', + contentChars: result.content.length, + toolCalls: result.toolCalls.length, + maxTokens: request.maxTokens }) return result } catch (error) { diff --git a/src/main/llm/settings-math.ts b/src/main/llm/settings-math.ts index e60c7e684..b3193a05c 100644 --- a/src/main/llm/settings-math.ts +++ b/src/main/llm/settings-math.ts @@ -89,6 +89,7 @@ export interface LaunchState { threads: number | undefined batchSize: number | undefined speculativeDecoding?: SpeculativeDecodingMode + speculativeDraftMax?: number draftModel?: string } @@ -107,6 +108,7 @@ export interface LaunchArgsInput { threads: number | undefined batchSize: number | undefined speculativeDecoding?: SpeculativeDecodingMode + speculativeDraftMax?: number draftModelPath?: string // Floor on image tokens. GUI-grounding (Qwen-VL / UI-TARS) models need >=1024 // or they ground inaccurately (llama.cpp warns); undefined = engine default. @@ -156,7 +158,13 @@ export function buildLaunchArgs(i: LaunchArgsInput): string[] { if (i.speculativeDecoding === 'ngram') { args.push('--spec-type', 'ngram-cache') } else if (i.speculativeDecoding === 'mtp') { - args.push('--spec-type', 'draft-mtp') + // A short draft limits rejected work on normal chat prompts. Larger + // user-selected limits remain available for more predictable output. + const draftMax = i.speculativeDraftMax ? String(i.speculativeDraftMax) : '2' + args.push('--spec-type', 'draft-mtp', '--spec-draft-n-max', draftMax) + if (i.draftModelPath) { + args.push('--spec-draft-model', i.draftModelPath, '--spec-draft-ngl', String(i.gpuLayers)) + } } else if ( i.draftModelPath && (i.speculativeDecoding === 'draft' || i.speculativeDecoding === 'dflash') @@ -167,6 +175,7 @@ export function buildLaunchArgs(i: LaunchArgsInput): string[] { '--spec-draft-model', i.draftModelPath ) + if (i.speculativeDraftMax) args.push('--spec-draft-n-max', String(i.speculativeDraftMax)) } // Grounding models (UI-TARS / Qwen-VL) need a minimum image-token budget or // clicks land in the wrong place; only set when a projector is present. @@ -189,6 +198,7 @@ export function launchArgsChanged( threads?: number batchSize?: number speculativeDecoding?: SpeculativeDecodingMode + speculativeDraftMax?: number draftModel?: string }, current: LaunchState, @@ -204,6 +214,8 @@ export function launchArgsChanged( (typeof patch.batchSize === 'number' && patch.batchSize !== current.batchSize) || (patch.speculativeDecoding !== undefined && patch.speculativeDecoding !== current.speculativeDecoding) || + (typeof patch.speculativeDraftMax === 'number' && + patch.speculativeDraftMax !== (current.speculativeDraftMax ?? 0)) || (typeof patch.draftModel === 'string' && patch.draftModel !== current.draftModel) ) } diff --git a/src/main/llm/startup-timeout.ts b/src/main/llm/startup-timeout.ts index 60d6e11b7..baee7a523 100644 --- a/src/main/llm/startup-timeout.ts +++ b/src/main/llm/startup-timeout.ts @@ -1,4 +1,15 @@ -/** CUDA model and CLIP loads can exceed one minute on a cold device. */ -export function modelStartupTimeout(engineName: string): number { +/** + * How long a starting llama-server may stay silent before we treat it as stuck. + * There is no total deadline: a large model on a slow disk keeps printing load progress and + * gets all the time it needs, and an engine that crashes fails at once. Only a server that + * stops writing anything is abandoned, so the next engine can try. CUDA and CLIP setup can sit + * quiet for over a minute on a cold device, so CUDA engines get longer. + */ +export function modelStartupQuietLimit(engineName: string): number { return engineName.endsWith('-cuda') ? 180_000 : 60_000 } + +/** True when the server has written nothing for longer than `quietLimit`. */ +export function startupStalled(now: number, lastOutputAt: number, quietLimit: number): boolean { + return now - lastOutputAt > quietLimit +} diff --git a/src/main/mcp-ipc.ts b/src/main/mcp-ipc.ts index 9d7b47aa3..0a19ab793 100644 --- a/src/main/mcp-ipc.ts +++ b/src/main/mcp-ipc.ts @@ -1,26 +1,44 @@ // Core MCP wiring: basic connector management + the chat tool extension. The Pro // layer adds CRM ingestion (mcp:ingest / mcp:items) and approval-gating on top. +import { getSecret } from './secrets' import { ipcMain } from 'electron' import { listConnectors, addConnector, setConnectorEnabled, removeConnector, + updateConnector, + cancelConnectorAuthorization, testConnector, + setConnectorSecrets, callConnectorTool, - type NewConnector + type NewConnector, + type ConnectorChanges } from './mcp' import { registerToolExtension } from './tools' import { mcpConnectorToolExtension } from './tools/mcpConnectorToolExtension' export function setupMcpIpc(): void { - ipcMain.handle('mcp:list', () => listConnectors()) + // Each row says whether it is live-only: read when asked, never synced into memory. + ipcMain.handle('mcp:list', () => + listConnectors().map((c) => ({ + ...c, + liveOnly: getSecret(`connector:${c.id}:live-only`) === 'true' + })) + ) ipcMain.handle('mcp:add', (_e, c: NewConnector) => addConnector(c)) ipcMain.handle('mcp:set-enabled', (_e, id: number, enabled: boolean) => setConnectorEnabled(id, enabled) ) + ipcMain.handle('mcp:update', (_e, id: number, changes: ConnectorChanges) => + updateConnector(id, changes) + ) ipcMain.handle('mcp:remove', (_e, id: number) => removeConnector(id)) + ipcMain.handle('mcp:cancel', (_e, id: number) => cancelConnectorAuthorization(id)) ipcMain.handle('mcp:test', (_e, id: number) => testConnector(id)) + ipcMain.handle('mcp:set-secrets', (_e, id: number, values: Record) => + setConnectorSecrets(id, values) + ) ipcMain.handle('mcp:call', (_e, id: number, tool: string, args: unknown) => callConnectorTool(id, tool, args) ) diff --git a/src/main/mcp-oauth-loopback.ts b/src/main/mcp-oauth-loopback.ts index b1a1d715b..e1227abe9 100644 --- a/src/main/mcp-oauth-loopback.ts +++ b/src/main/mcp-oauth-loopback.ts @@ -31,7 +31,8 @@ export class OAuthLoopbackServer { constructor(private readonly options: OAuthLoopbackOptions) { this.host = options.host ?? '127.0.0.1' - this.authorizationTimeoutMs = options.authorizationTimeoutMs ?? 3 * 60 * 1000 + // Account selection, MFA, and workspace approval can take several minutes. + this.authorizationTimeoutMs = options.authorizationTimeoutMs ?? 10 * 60 * 1000 } get redirectUrl(): string { @@ -51,7 +52,18 @@ export class OAuthLoopbackServer { this.startPromise = null this.rejectAll(new Error('OAuth callback server unavailable')) this.options.onError?.(error) - reject(error) + // The raw "listen EADDRINUSE" reaches the sign-in screen; say what to do instead. + reject( + (error as NodeJS.ErrnoException).code === 'EADDRINUSE' + ? Object.assign( + new Error( + 'Sign-in could not start: another app is using its port on this computer. If another Off Grid AI window is open, close it and try again.', + { cause: error } + ), + { code: 'EADDRINUSE' } + ) + : error + ) } server.once('error', handleStartupError) diff --git a/src/main/mcp-oauth.ts b/src/main/mcp-oauth.ts index 159561ce4..8046921c0 100644 --- a/src/main/mcp-oauth.ts +++ b/src/main/mcp-oauth.ts @@ -43,7 +43,7 @@ function logoBytes(): Buffer | null { // least-privilege read scope + offline access (to get a refresh token). export interface StaticOAuthClient { client_id: string - client_secret: string + client_secret?: string scope: string } @@ -84,7 +84,7 @@ export function makeOAuthProvider( grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], // Google Desktop clients send the secret on token exchange; DCR uses none. - token_endpoint_auth_method: google ? 'client_secret_post' : 'none', + token_endpoint_auth_method: google?.client_secret ? 'client_secret_post' : 'none', ...(google ? { scope: google.scope } : {}) } }, @@ -93,7 +93,11 @@ export function makeOAuthProvider( }, clientInformation() { // Static (Google) client → skip DCR; otherwise use the registered one. - if (google) return { client_id: google.client_id, client_secret: google.client_secret } + if (google) + return { + client_id: google.client_id, + ...(google.client_secret ? { client_secret: google.client_secret } : {}) + } return loadJson('client') }, saveClientInformation(info: unknown): void { @@ -123,12 +127,12 @@ export function makeOAuthProvider( // saved token is stale, the sync just fails quietly and the user can // reconnect from the UI. Only interactive connects open the browser. if (!interactive) return - if (google) { + if (google && url.hostname === 'accounts.google.com') { // Google needs these for a refresh token, and we pin the scope so we // only ever request the least-privilege read scope (not every scope the // MCP server advertises, which would exceed the consent screen). url.searchParams.set('access_type', 'offline') - url.searchParams.set('prompt', 'consent') + url.searchParams.set('prompt', 'consent select_account') url.searchParams.set('scope', google.scope) } // Register for the redirect (keyed by state) BEFORE opening the browser, so diff --git a/src/main/mcp.ts b/src/main/mcp.ts index b128b959b..345b15fc0 100644 --- a/src/main/mcp.ts +++ b/src/main/mcp.ts @@ -6,7 +6,7 @@ // closed — we don't hold long-lived child processes. import { getDB } from './database' -import { deleteSecretsByPrefix, getSecret } from './secrets' +import { deleteSecret, deleteSecretsByPrefix, getSecret, setSecret } from './secrets' import { makeOAuthProvider, ensureLoopback, hasOAuthTokens } from './mcp-oauth' import { cancelOAuthAuthorization } from './mcp-oauth-cancellation' import { callHook, HOOKS } from './bootstrap/hookRegistry' @@ -42,6 +42,8 @@ export interface ConnectorToolCallResult { */ export interface ConnectorToolSource { tools: ConnectorToolDefinition[] + /** Optional direct authorization owned by the provider, without a remote MCP handshake. */ + authorize?: () => Promise verify: () => Promise callTool: (tool: string, args: unknown) => Promise } @@ -90,6 +92,8 @@ export interface NewConnector { args?: string[] envKeys?: string[] // names of secrets to inject as env vars url?: string + /** Connect for live tools without background memory imports. */ + liveOnly?: boolean } export function listConnectors(): Connector[] { @@ -113,7 +117,88 @@ export function addConnector(c: NewConnector): number { c.url ?? null, Date.now() ) - return Number(info.lastInsertRowid) + const id = Number(info.lastInsertRowid) + if (c.liveOnly && !setSecret(`connector:${id}:live-only`, 'true')) { + getDB().prepare('DELETE FROM connectors WHERE id = ?').run(id) + throw new Error('Could not protect the connection settings.') + } + if (c.liveOnly) setConnectorEnabled(id, false) + return id +} + +export interface ConnectorChanges { + name?: string + url?: string + command?: string + args?: string[] +} + +/** + * Change a connector's name or where it connects. A new address or command is a different + * server: its sign-in and discovered tools no longer apply, so they are cleared and the + * connector waits for a new test. Other saved secrets (an API token) are kept. + */ +export function updateConnector(id: number, changes: ConnectorChanges): Connector { + ensure() + const current = getConnector(id) + if (!current) throw new Error('This connection no longer exists.') + const name = changes.name === undefined ? current.name : changes.name.trim() + if (!name) throw new Error('Enter a name.') + const url = current.transport === 'http' ? (changes.url?.trim() ?? current.url) : null + const command = + current.transport === 'stdio' ? (changes.command?.trim() ?? current.command) : null + const args = + current.transport === 'stdio' + ? changes.args === undefined + ? current.args + : changes.args.length + ? JSON.stringify(changes.args) + : null + : null + if (current.transport === 'http' && !url) throw new Error('Enter the server address.') + if (current.transport === 'stdio' && !command) throw new Error('Enter the command.') + const moved = url !== current.url || command !== current.command || args !== current.args + const database = getDB() + if (moved) cancelOAuthAuthorization(id) + database.transaction(() => { + if (moved) { + deleteSecretsByPrefix(`connector:${id}:oauth:`) + // The secrets it handed the old server or command (API keys as environment variables) + // are not the new one's to receive. + for (const key of current.env_keys ? (JSON.parse(current.env_keys) as string[]) : []) { + deleteSecret(`connector:${id}:${key}`) + } + database + .prepare( + "UPDATE connectors SET name=?, url=?, command=?, args=?, env_keys=NULL, tools=NULL, status='unknown', status_detail=? WHERE id=?" + ) + .run(name, url, command, args, 'Settings changed. Test the connection.', id) + } else { + database.prepare('UPDATE connectors SET name=? WHERE id=?').run(name, id) + } + })() + return getConnector(id)! +} + +/** A secret's name as a connector receives it: an environment variable name. */ +const SECRET_KEY = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/ + +/** + * Save a connector's credentials (a token, an API key) and record them as what it receives, so + * a connector whose command was changed (which cleared them) works again once they are entered. + */ +export function setConnectorSecrets(id: number, values: Record): void { + const current = getConnector(id) + if (!current) throw new Error('This connection no longer exists.') + const entered = Object.entries(values).filter(([key, value]) => SECRET_KEY.test(key) && value) + for (const [key, value] of entered) { + if (!setSecret(`connector:${id}:${key}`, value)) throw new Error('Could not save the token.') + } + const known = current.env_keys ? (JSON.parse(current.env_keys) as string[]) : [] + const keys = [...new Set([...known, ...entered.map(([key]) => key)])] + getDB() + .prepare('UPDATE connectors SET env_keys=? WHERE id=?') + .run(keys.length ? JSON.stringify(keys) : null, id) } export function setConnectorEnabled(id: number, enabled: boolean): void { @@ -141,6 +226,12 @@ export function setConnectorStatus( export function removeConnector(id: number): void { ensure() cancelOAuthAuthorization(id) + // Before the stored sign-in goes: the provider may be told to forget it too. + try { + callHook(HOOKS.mcpBeforeRemove, id, getConnector(id)?.url ?? null) + } catch { + /* Removing locally never waits on, or fails for, the provider. */ + } const database = getDB() database.transaction(() => { deleteSecretsByPrefix(`connector:${id}:`) @@ -148,6 +239,10 @@ export function removeConnector(id: number): void { })() } +export function cancelConnectorAuthorization(id: number): void { + cancelOAuthAuthorization(id) +} + function getConnector(id: number): Connector | undefined { ensure() return getDB().prepare('SELECT * FROM connectors WHERE id = ?').get(id) as Connector | undefined @@ -298,15 +393,26 @@ export async function testConnector( // A fresh account still needs the existing interactive OAuth handshake. Once tokens exist, // provider verification must not touch a preview-gated MCP endpoint. if (!hasOAuthTokens(c.id)) { - const session = await connect(c, true) - await session.close() + if (source.authorize) await source.authorize() + else { + const session = await connect(c, true) + await session.close() + } } await source.verify() - tools = source.tools + // Built again after sign-in: the services the user granted at consent decide its tools, + // not the ones it was created with before they chose. + tools = (connectorToolSource(c) ?? source).tools } else { const { client, close } = await connect(c, true) // user-initiated → allow browser OAuth try { const res = await client.listTools() + await callHook( + 'mcp:identifyAccount', + id, + client, + res.tools.map((tool) => tool.name) + ) tools = res.tools.map((tool) => ({ name: tool.name, description: tool.description diff --git a/src/main/media-server.ts b/src/main/media-server.ts index c30dc60e8..803986144 100644 --- a/src/main/media-server.ts +++ b/src/main/media-server.ts @@ -17,7 +17,7 @@ import path from 'path' import { randomUUID } from 'crypto' import { app } from 'electron' import { parseRange, isPathAllowed } from './media-range' -import { MEDIA_PORT } from '../shared/ports' +import { MEDIA_PORT, MEDIA_PORT_RANGE } from '../shared/ports' import { isPortFree, pickFreePort } from './free-port' import { mimeForExt } from './mime' import { localMediaRoots } from './media-roots' @@ -73,7 +73,11 @@ export class LoopbackMediaServer { // for a free one. requestedPort 0 = let the OS assign (tests) — inherently free. urlFor() serves // the LIVE boundPort, so downstream links follow wherever it bound. const target = this.requestedPort > 0 - ? await pickFreePort(this.requestedPort, (port) => isPortFree(port, '127.0.0.1')) + ? await pickFreePort( + this.requestedPort, + (port) => isPortFree(port, '127.0.0.1'), + MEDIA_PORT_RANGE + ) : 0 if (target === null) { this.startPromise = null diff --git a/src/main/model-server.ts b/src/main/model-server.ts index 9649800ed..6e9e112d6 100644 --- a/src/main/model-server.ts +++ b/src/main/model-server.ts @@ -64,13 +64,18 @@ import { tagLlmEntries, modelEntry, ollamaMirror } from './model-server/models-l import { buildGatewayModalities, type GatewayModalities } from './model-server/health' import { safeProxyResponse } from './model-server/proxy-response' import { writeDiagnosticLog } from './diagnostics-log' +import { IMAGE_CANCELLED_MESSAGE } from './imagegen/generation-lifecycle' import { parseRemoteVisionModelId, remoteVisionModelId } from '../shared/remote-vision-server' import { getActiveRemoteVisionServer, getActiveRemoteVisionServerForModality } from './vision/remote-vision-server' -import { REASONING_BUDGET_AUTO, openRouterReasoningPayload } from '@offgrid/models' -import { remoteReasoningCapability, remoteTextModelProviderError } from './llm/remote-chat' +import { REASONING_BUDGET_AUTO } from '@offgrid/models' +import { + remoteReasoningCapability, + remoteReasoningFields, + remoteTextModelProviderError +} from './llm/remote-chat' const UPSTREAM_HOST = '127.0.0.1' // The upstream llama-server port is LIVE, not fixed: llm.getPort() moves off LLAMA_SERVER_PORT when @@ -130,6 +135,7 @@ interface ApiRequest { result?: unknown error?: { message: string; type: string } progress?: { step: number; total: number } + controller?: AbortController } const requests = new Map() @@ -142,6 +148,7 @@ function createRequest(id: string, kind: string, collection: string): ApiRequest } const now = Date.now() const r: ApiRequest = { id, kind, collection, status: 'queued', created_at: now, updated_at: now } + if (kind === 'image') r.controller = new AbortController() requests.set(id, r) return r } @@ -152,6 +159,7 @@ function settle(r: ApiRequest, work: Promise): Promise { r.updated_at = Date.now() return work.then( (result) => { + if (r.controller?.signal.aborted) throw new Error(IMAGE_CANCELLED_MESSAGE) r.status = 'completed' r.result = result r.updated_at = Date.now() @@ -160,13 +168,30 @@ function settle(r: ApiRequest, work: Promise): Promise { (e) => { const { type, message } = errMeta(e) r.status = 'failed' - r.error = { message, type } + r.error = r.controller?.signal.aborted + ? { message: IMAGE_CANCELLED_MESSAGE, type: 'cancelled' } + : { message, type } r.updated_at = Date.now() throw e } ) } +/** Cancel only the named image request. Completed and previously cancelled requests are safe to repeat. */ +function handleCancel(res: http.ServerResponse, id: string): void { + const r = requests.get(id) + if (!r) return json(res, 404, errBody(`No request with id '${id}'.`, 'not_found')) + if (!r.controller) + return json(res, 409, errBody('This request cannot be cancelled.', 'not_cancellable')) + if (r.status === 'queued' || r.status === 'running') { + r.status = 'failed' + r.error = { message: IMAGE_CANCELLED_MESSAGE, type: 'cancelled' } + r.updated_at = Date.now() + r.controller.abort() + } + json(res, 200, { request_id: r.id, status: r.status, cancelled: r.error?.type === 'cancelled' }) +} + /** 202 Accepted with the request resource + Location for polling. */ function dispatchAsync(res: http.ServerResponse, r: ApiRequest): void { const pollUrl = `${r.collection}/${r.id}` @@ -280,11 +305,23 @@ function proxyToSelectedRemote( return true } + void forwardToRemote(res, body, remote, activity) + return true +} + +/** Send the request to the remote provider, with its reasoning control translated for it. */ +async function forwardToRemote( + res: http.ServerResponse, + body: Record, + remote: NonNullable>, + activity?: AIRequestHandle +): Promise { const target = new URL(`${remote.endpoint.replace(/\/+$/, '')}/chat/completions`) const thinkingRequested = requestedThinking(body) const forwarded: Record = { ...body, model: remote.model } - // The phone sends llama.cpp controls to this gateway. OpenRouter needs its - // reasoning control for both OFF and the selected thinking budget. + // The phone and the browser send llama.cpp controls to this gateway. OpenRouter needs its own + // reasoning control, built exactly as Chat builds it (remoteReasoningFields): a model whose + // reasoning is mandatory is never told to switch it off. if (remote.provider === 'openrouter' && thinkingRequested !== undefined) { const budget = typeof body.reasoning_budget_tokens === 'number' && body.reasoning_budget_tokens > 0 @@ -293,9 +330,11 @@ function proxyToSelectedRemote( delete forwarded.chat_template_kwargs delete forwarded.reasoning_format delete forwarded.reasoning_budget_tokens - forwarded.reasoning = thinkingRequested - ? openRouterReasoningPayload(true, budget).reasoning - : { effort: 'none' } + delete forwarded.reasoning + Object.assign( + forwarded, + remoteReasoningFields(thinkingRequested, budget, await remoteReasoningCapability(remote)) + ) } writeDiagnosticLog('gateway', 'remote_chat.thinking_control', { requestId: String(res.getHeader('X-Request-Id') ?? ''), @@ -374,7 +413,6 @@ function proxyToSelectedRemote( } }) proxyReq.end(payload) - return true } // Fetch an image reference into a Buffer. Accepts data: URLs, http(s):// URLs, @@ -482,21 +520,27 @@ async function serve( kind: string, collection: string, asyncFlag: boolean, - run: () => Promise, + run: (signal?: AbortSignal) => Promise, syncRespond: (result: unknown) => void ): Promise { const r = createRequest(rid, kind, collection) if (asyncFlag) { - settle(r, run()).catch(() => {}) // errors captured on the request resource + settle(r, run(r.controller?.signal)).catch(() => {}) // errors captured on the request resource dispatchAsync(res, r) return } + const cancelDisconnectedImage = (): void => { + if (!res.writableEnded) r.controller?.abort() + } + if (r.controller) res.once('close', cancelDisconnectedImage) try { - const result = await settle(r, run()) + const result = await settle(r, run(r.controller?.signal)) syncRespond(result) } catch (e) { const { status, type, message } = errMeta(e) json(res, status, errBody(message, type)) + } finally { + res.off('close', cancelDisconnectedImage) } } @@ -940,7 +984,8 @@ async function executeImage( params: ImageGenParams, responseFormat: string, cleanup?: () => void, - onProgress?: (progress: { step: number; total: number }) => void + onProgress?: (progress: { step: number; total: number }) => void, + signal?: AbortSignal ): Promise { try { const status = imageGenStatus() @@ -951,11 +996,15 @@ async function executeImage( err.status = 501 throw err } - const out = await generateImage(params, (update) => { - if (update.stage === 'generating' && update.progress) { - onProgress?.({ step: update.progress.step, total: update.progress.total }) - } - }) + const out = await generateImage( + params, + (update) => { + if (update.stage === 'generating' && update.progress) { + onProgress?.({ step: update.progress.step, total: update.progress.total }) + } + }, + signal + ) const b64 = out.dataUrl.slice(out.dataUrl.indexOf(',') + 1) const datum = responseFormat === 'url' @@ -1013,14 +1062,20 @@ async function handleImageGeneration( 'image', '/v1/images/generations', isAsync(req, payload), - () => - executeImage(params, fmt, undefined, (progress) => { - const request = requests.get(rid) - if (request) { - request.progress = progress - request.updated_at = Date.now() - } - }), + (signal) => + executeImage( + params, + fmt, + undefined, + (progress) => { + const request = requests.get(rid) + if (request) { + request.progress = progress + request.updated_at = Date.now() + } + }, + signal + ), (r) => jsonWithId(res, rid, r) ) } @@ -1094,7 +1149,7 @@ async function handleImagesUnified( 'image', '/v1/images', isAsync(req, payload), - () => executeImage(params, fmt, cleanup), + (signal) => executeImage(params, fmt, cleanup, undefined, signal), (r) => jsonWithId(res, rid, r) ) } @@ -1160,7 +1215,7 @@ async function handleImageEdit( 'image', '/v1/images/edits', isAsync(req, undefined, fields), - () => executeImage(params, fmt, cleanup), + (signal) => executeImage(params, fmt, cleanup, undefined, signal), (r) => jsonWithId(res, rid, r) ) } @@ -1174,6 +1229,13 @@ export function getGatewayPort(): number { } let startingGateway = false +/** A request from this machine. The gateway listens on every interface (phones reach the models), + * so a route that changes settings checks this itself: nothing else stands in front of it. */ +function isLocalCaller(req: http.IncomingMessage): boolean { + const remote = req.socket.remoteAddress + return remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1' +} + /** Start the unified local model gateway. Bound to loopback (local-only). Async because it scans * for a free port when the preferred one is taken. */ export async function startModelServer(port = GATEWAY_PORT): Promise { @@ -1193,7 +1255,7 @@ export async function startModelServer(port = GATEWAY_PORT): Promise { server = http.createServer(async (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Headers', '*') - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS') res.setHeader('Access-Control-Allow-Private-Network', 'true') if (req.method === 'OPTIONS') { res.writeHead(204) @@ -1241,6 +1303,10 @@ export async function startModelServer(port = GATEWAY_PORT): Promise { if (url.startsWith('/v1/requests/') && id) return handlePoll(res, id) if (id && isPollCollection && requests.has(id)) return handlePoll(res, id) } + if (method === 'DELETE' && url.startsWith('/v1/requests/')) { + const { prefix, id } = matchPollRoute(url) + if (prefix === '/v1/requests' && id) return handleCancel(res, id) + } if (url === '/' || url === '/health') { const img = imageGenStatus() @@ -1352,10 +1418,7 @@ export async function startModelServer(port = GATEWAY_PORT): Promise { // Mutating launch-time LLM args triggers a llama-server respawn. The listener is // on every interface so a phone can reach the models, which makes this check the // ONLY thing standing between the LAN and a respawn - not defense in depth. - const remote = req.socket.remoteAddress - const isLocalhost = - remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1' - if (!isLocalhost) { + if (!isLocalCaller(req)) { json(res, 403, errBody('Settings mutations are restricted to localhost.', 'forbidden')) return } @@ -1402,6 +1465,32 @@ export async function startModelServer(port = GATEWAY_PORT): Promise { return json(res, 200, { installed: await mm.listInstalled() }) if (url === '/v1/models/active' && method === 'GET') return json(res, 200, mm.getActiveModalities()) + // Every model this desktop counts active, as catalog ids (the Models screen's own + // answer): a kind can hold more than one, as Computer Use's specialist and decider do. + if (url === '/v1/models/active-ids' && method === 'GET') + return json(res, 200, { ids: await mm.getActiveModelIds() }) + // The task models' lineups and roles, as the desktop's Models screen shows them, and the + // one way to give a model a role. Additive: no existing route changes. + if (url === '/v1/models/task-roles' && method === 'GET') { + const { getTaskRolesView } = await import('./vision/vision-task-model-strategy') + return json(res, 200, await getTaskRolesView()) + } + if (url === '/v1/models/task-role' && method === 'POST') { + // A persistent settings change, on a gateway every interface can reach: this machine + // only, as /v1/settings. A paired browser changes roles over its sealed link instead. + if (!isLocalCaller(req)) { + return json( + res, + 403, + errBody('Task role changes are restricted to localhost.', 'forbidden') + ) + } + const { setTaskRoleFromRequest } = await import('./task-role-requests') + const result = await setTaskRoleFromRequest( + (await readJson(req)) as Record + ) + return json(res, result.success ? 200 : 400, result) + } if (url === '/v1/models/pull/status' && method === 'GET') { const id = (req.url || '').split('?')[1]?.match(/(?:^|&)id=([^&]+)/)?.[1] return json( diff --git a/src/main/models-manager.ts b/src/main/models-manager.ts index 4fc9c206b..a8ce18436 100644 --- a/src/main/models-manager.ts +++ b/src/main/models-manager.ts @@ -6,6 +6,7 @@ import fs from 'fs' import path from 'path' import { llm } from './llm' import { isValidGgufFile } from './models/gguf' +import { readGgufMtpSupport } from './models/gguf-metadata' import { pumpToFile } from './models/download-pump' import { downloadIntegrityError, sha256IntegrityError } from './models/download-verify' import { downloadFailureMessage, isStorageCapacityError } from './models/download-error' @@ -32,6 +33,7 @@ import { type DownloadedModel } from './downloaded-models' import { + MTP_COMPANIONS, mergeCatalog, installedIds, buildDiskEntry, @@ -66,7 +68,10 @@ import { deactivateRemoteVisionMediaModel, getRemoteVisionServerSettings } from './vision/remote-vision-server' -import { getComputerUseSettings, setComputerUseSettings } from './computer-use-settings' +import { getComputerUseSettings } from './computer-use-settings' +import { getWebUseSettings } from './web-use-settings' +import { clearTaskRolesFor, setTaskRoleModel } from './task-role-models' +import { strategyTaskRoles } from '../shared/computer-use-settings' import { binRoots } from './runtime-env' // Desktop ships the Prism llama.cpp engine required by these packed weights. @@ -316,10 +321,35 @@ export function resolveKevRuntimeArtifact(): KevRuntimeArtifact | null { export async function desktopCatalog(): Promise { const { CATALOG } = await import('@offgrid/models') - const catalog = CATALOG.map((model) => - model.grounder ? { ...model, tags: [...new Set(['Specialist', ...(model.tags ?? [])])] } : model - ) - return [BONSAI_2, DECIDER_2B, DECIDER_2B_VISION, KEV_4B, ...catalog] + const catalog = CATALOG.map((model) => { + const companion = Object.entries(MTP_COMPANIONS).find(([id]) => id === model.id)?.[1] + const files = + companion && !model.files.some((file) => file.name === companion.name) + ? [...model.files, companion] + : model.files + return { + ...model, + files, + ...(model.grounder ? { tags: [...new Set(['Specialist', ...(model.tags ?? [])])] } : {}) + } + }) + const uiMate = catalog.find((model) => model.id === 'bartowski/tencent_UI-Mate-9B-GGUF') + const uiMateMtp = uiMate + ? [ + { + ...uiMate, + id: `${uiMate.id}+MTP`, + name: `${uiMate.name} MTP`, + availability: 'coming_soon' as const, + availabilityNote: + 'The separate UI-Mate MTP download is not yet verified. Installed models with built-in MTP heads remain supported.', + description: + 'UI-Mate trained weights with a matching Qwen3.5 9B MTP draft. Includes the main model, vision projector, and prediction weights.', + files: [...uiMate.files, MTP_COMPANIONS['bartowski/tencent_UI-Mate-9B-GGUF+MTP']] + } + ] + : [] + return [BONSAI_2, DECIDER_2B, DECIDER_2B_VISION, KEV_4B, ...catalog, ...uiMateMtp] } export interface DownloadProgress { @@ -490,7 +520,12 @@ export async function resolveModelIdentity(modelId: string): Promise> { const CATALOG = await desktopCatalog() const dir = llm.getModelsDir() - const present = (name: string): boolean => fileSizeOf(dir, name) > 0 + const present = (name: string): boolean => { + const mtp = Object.values(MTP_COMPANIONS).find((file) => file.name === name) + return mtp + ? readGgufMtpSupport(path.join(dir, name), fs, undefined, mtp.sizeBytes) + : fileSizeOf(dir, name) > 0 + } const downloaded = reconcileDownloadedModelRegistry(dir, CATALOG as unknown as CatalogEntry[]) const merged = mergeCatalog({ locals: getLocalModels(), @@ -502,7 +537,11 @@ export async function getVisionStatuses(): Promise> const out: Record = {} for (const m of merged) { const st = visionStatus(m, present) - if (st.supportsVision || st.supportsDflash) { + if (readGgufMtpSupport(path.join(dir, primaryFileName(m) ?? ''), fs)) { + st.supportsMtp = true + st.mtpInstalled = true + } + if (st.supportsVision || st.supportsDflash || st.supportsMtp) { out[m.id] = st } } @@ -584,6 +623,14 @@ export async function searchModels(query: string, kind?: string): Promise { + const { getModelFiles: listFiles } = await import('@offgrid/models') + const repo = modelId === 'bartowski/tencent_UI-Mate-9B-GGUF+MTP' ? modelId.slice(0, -4) : modelId + return (await listFiles(repo)).filter( + (file) => !/^(?:mtp[-_.])|[-_.]mtp[-_.]only[-_.]/i.test(file.fileName) + ) +} + export function downloadStatus(modelId: string): DownloadProgress | null { return lastProgress.get(modelId) ?? null } @@ -624,7 +671,7 @@ export async function downloadModel( }) return publishRefusal(modelId, DOWNLOAD_INTERRUPTED_ERROR, onProgress) } - const { getModelFiles, resolveHuggingFaceModel } = await import('@offgrid/models') + const { resolveHuggingFaceModel } = await import('@offgrid/models') const CATALOG = await desktopCatalog() const inCatalog = CATALOG.find((m) => m.id === modelId) let entry = inCatalog ?? (await resolveHuggingFaceModel(modelId)) @@ -660,6 +707,18 @@ export async function downloadModel( ] } } + // Full MTP exports often reuse the standard export's filename. Keep their + // weights distinct and record the actual local filename in the existing registry. + if (entry && /-MTP-GGUF$/i.test(modelId)) { + entry = { + ...entry, + files: entry.files.map((file) => + file.role === 'primary' + ? { ...file, name: file.name.replace(/\.gguf$/i, '+MTP.gguf') } + : file + ) + } + } if (!entry) { writeDiagnosticLog('models.download', 'request.rejected', { modelId, reason: 'unknown_model' }) return publishRefusal(modelId, 'unknown model', onProgress) @@ -747,7 +806,16 @@ export async function downloadModel( // is the set of files this run must actually fetch (a file already on disk is not work), // and one percent measures the whole of it. const pending = entry.files.filter((file) => { - const present = fileSizeOf(dir, file.name) > 0 + const mtp = Object.values(MTP_COMPANIONS).find( + (companion) => companion.name === file.name + ) + const nativeUiMate = + modelId === 'bartowski/tencent_UI-Mate-9B-GGUF+MTP' && + readGgufMtpSupport(path.join(dir, primaryFileName(entry) ?? ''), fs) + const present = mtp + ? nativeUiMate || + readGgufMtpSupport(path.join(dir, file.name), fs, undefined, mtp.sizeBytes) + : fileSizeOf(dir, file.name) > 0 if (present) { writeDiagnosticLog('models.download', 'file.skipped', { modelId, @@ -1099,6 +1167,7 @@ export async function deleteModel(modelId: string): Promise { ;(Object.keys(modals) as Modality[]).forEach((k) => { if (modalSelectionMatches(modals[k], modelId, primaryFile)) setModal(k, null) }) + clearTaskRolesFor(modelId) return { success: true, freedFiles: freed } } @@ -1251,37 +1320,49 @@ export async function getActiveModelIds(): Promise { const settings = getRemoteVisionServerSettings() const remote = settings.servers.find((server) => server.id === settings.activeServerId) const activeChatId = getActiveModel() + // Computer Use models are active by the role Tasks gives them (below), not by a pick of their + // own: the Models screens then show exactly what Tasks > Computer Use and Web Use will run. const localIds = info.models .filter( (model) => - model.active && (!remote || !remoteModelSelected('text') || model.id !== activeChatId) + model.active && + model.kind !== 'computer_use' && + (!remote || !remoteModelSelected('text') || model.id !== activeChatId) ) .map((model) => model.id) - const computerUseSettings = getComputerUseSettings() - const decisionModelId = - computerUseSettings.decisionModelId ?? - (computerUseSettings.modelStrategy === 'decision_plus_specialist' || - computerUseSettings.modelStrategy === 'decision_plus_reasoning' - ? DECIDER_2B.id - : null) - const withDecision = - decisionModelId && - (info.models.some((model) => model.id === decisionModelId) || - (decisionModelId === KEV_4B_ID && Boolean(resolveKevRuntimeArtifact()))) - ? [...new Set([...localIds, decisionModelId])] - : localIds - return remote && remote.enabled !== false - ? [ - ...withDecision, - ...remoteVisionInventoryModels([remote]) - .filter((model) => - remoteModelSelected( - model.kind === 'vision' ? 'text' : model.kind === 'speech' ? 'voice' : model.kind - ) + const remoteIds = + remote && remote.enabled !== false + ? remoteVisionInventoryModels([remote]) + .filter( + (model) => + model.kind !== 'computer_use' && + remoteModelSelected( + model.kind === 'vision' ? 'text' : model.kind === 'speech' ? 'voice' : model.kind + ) ) .map((model) => model.id) - ] - : withDecision + : [] + return [...new Set([...localIds, ...remoteIds, ...(await taskRoleModelIds())])] +} + +/** + * The models Computer Use and Web Use run besides Chat: each one's decision model and grounding + * specialist, local or remote, by the roles its strategy uses (strategyTaskRoles, the same map + * the task projection reads). The reasoner is the Chat model, already active as text. + */ +async function taskRoleModelIds(): Promise { + const { selectedGrounderModelId } = await import('./vision/grounder-selection') + const computer = getComputerUseSettings() + const web = getWebUseSettings() + const tasks = [ + { strategy: computer.modelStrategy, decision: computer.decisionModelId }, + { strategy: web.modelStrategy, decision: web.decisionModelId } + ] + return tasks.flatMap(({ strategy, decision }) => + strategyTaskRoles(strategy).map((role) => + role === 'grounding' ? selectedGrounderModelId() : (decision ?? DECIDER_2B.id) + ) + ) } /** @@ -1290,6 +1371,20 @@ export async function getActiveModelIds(): Promise { * transcription set that modality's default pick. Callers pass only the id and * never branch on kind. Adding a new modality needs zero caller changes. */ +/** + * "Use" on a task model, from any Models screen: a grounding specialist is shared by both tasks + * already; a decision model becomes the decision model of both Web Use and Computer Use. Tasks + * settings can still give each task its own. + */ +function useForTasks(role: 'decision' | 'grounding', modelId: string): void { + if (role === 'grounding') { + setTaskRoleModel('computer_use', 'grounding', modelId) + return + } + setTaskRoleModel('computer_use', 'decision', modelId) + setTaskRoleModel('web_use', 'decision', modelId) +} + export async function activateModel( modelId: string, requestedKind?: string @@ -1303,12 +1398,25 @@ export async function activateModel( const modality = (['text', 'image', 'transcription', 'voice'] as const).find( (kind) => selected[kind] === remote.modelId ) + // A server's Computer Use role model is activated as that role, as Tasks sets it. + const role = (['grounding', 'decision'] as const).find( + (candidate) => server?.roleModels?.[candidate] === remote.modelId + ) + if (!modality && role) { + useForTasks(role, modelId) + return { success: true } + } const activated = modality === 'text' ? activateRemoteVisionModel(remote.serverId, remote.modelId) : modality ? activateRemoteVisionMediaModel(remote.serverId, modality, remote.modelId) : false + // Chat now runs remotely: the local chat engine lets go of its memory. Chat starts it again + // on demand if the user switches back to a local model. + if (activated && modality === 'text') { + void llm.unload().catch((error) => console.error('[models] local chat unload failed', error)) + } return activated ? { success: true } : { success: false, error: 'Remote model is no longer available.' } @@ -1336,7 +1444,7 @@ export async function activateModel( requestedKind === 'computer_use' && catalogEntry?.tags?.some((tag) => tag.toLowerCase() === 'decision') ) { - setComputerUseSettings({ ...getComputerUseSettings(), decisionModelId: modelId }) + useForTasks('decision', modelId) return { success: true } } } @@ -1369,6 +1477,8 @@ export async function setActiveModalChoice( } } setModal(modal, stored) + // A Computer Use pick, from any screen, is the grounding specialist both tasks share. + if (modal === 'computer_use' && modelId) setTaskRoleModel('computer_use', 'grounding', modelId) if (modal === 'image') deactivateRemoteVisionMediaModel('image') if (modal === 'speech') deactivateRemoteVisionMediaModel('voice') if (modal === 'transcription') deactivateRemoteVisionMediaModel('transcription') diff --git a/src/main/models/__tests__/model-download-matrix.integration.test.ts b/src/main/models/__tests__/model-download-matrix.integration.test.ts index 96e4e6b42..223a35790 100644 --- a/src/main/models/__tests__/model-download-matrix.integration.test.ts +++ b/src/main/models/__tests__/model-download-matrix.integration.test.ts @@ -17,6 +17,8 @@ process.env.OFFGRID_DATA_DIR = dataDir process.env.OFFGRID_BIN_DIR = binDir vi.mock('electron', () => ({ + // Activating a Computer Use model tells open windows its role changed: there are none here. + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: () => dataDir, isPackaged: false, @@ -200,34 +202,49 @@ describe('model download release matrix', () => { const selected = 'Ternary-Bonsai-2-27B-PTQ1_0.gguf' const projector = 'Ternary-Bonsai-2-27B-mmproj-Q8_0.gguf' const requested: string[] = [] - vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => { - const url = String(input) - requested.push(url) - if (url.includes('/api/models/')) { - return new Response(JSON.stringify({ siblings: [ - { rfilename: selected, size: 2048 }, - { rfilename: projector, size: 2048 } - ] }), { status: 200, headers: { 'content-type': 'application/json' } }) - } - if (url.endsWith(selected) || url.endsWith(projector)) { - return new Response(new Uint8Array(Buffer.concat([Buffer.from('GGUF'), Buffer.alloc(2044)])), { - status: 200, - headers: { 'content-length': '2048' } - }) - } - throw new Error(`Unexpected download: ${url}`) - })) + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL | Request) => { + const url = String(input) + requested.push(url) + if (url.includes('/api/models/')) { + return new Response( + JSON.stringify({ + siblings: [ + { rfilename: selected, size: 2048 }, + { rfilename: projector, size: 2048 } + ] + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + } + if (url.endsWith(selected) || url.endsWith(projector)) { + return new Response( + new Uint8Array(Buffer.concat([Buffer.from('GGUF'), Buffer.alloc(2044)])), + { + status: 200, + headers: { 'content-length': '2048' } + } + ) + } + throw new Error(`Unexpected download: ${url}`) + }) + ) try { expect(await manager.downloadModel(repoId, undefined, selected)).toEqual({ success: true }) expect(requested.some((url) => url.endsWith(selected))).toBe(true) expect(requested.some((url) => url.endsWith('Ternary-Bonsai-2-27B-PQ2_0.gguf'))).toBe(false) expect(fs.existsSync(path.join(dataDir, 'models', selected))).toBe(true) - expect((await manager.getStorageInfo()).models.find((model) => model.name === 'Bonsai 2 27B')?.kind).toBe('vision') + expect( + (await manager.getStorageInfo()).models.find((model) => model.name === 'Bonsai 2 27B')?.kind + ).toBe('vision') const catalogPrimary = 'Ternary-Bonsai-2-27B-PQ2_0.gguf' fs.writeFileSync(path.join(dataDir, 'models', catalogPrimary), 'GGUF') expect(await manager.setActiveModel(repoId)).toEqual({ success: true }) - const active = JSON.parse(fs.readFileSync(path.join(dataDir, 'models', 'active-model.json'), 'utf8')) + const active = JSON.parse( + fs.readFileSync(path.join(dataDir, 'models', 'active-model.json'), 'utf8') + ) expect(active.primary).toBe(catalogPrimary) } finally { fs.rmSync(path.join(dataDir, 'models', selected), { force: true }) diff --git a/src/main/models/__tests__/model-integrity.integration.test.ts b/src/main/models/__tests__/model-integrity.integration.test.ts index 5e01e45c7..259876176 100644 --- a/src/main/models/__tests__/model-integrity.integration.test.ts +++ b/src/main/models/__tests__/model-integrity.integration.test.ts @@ -462,4 +462,30 @@ describe('active model persistence', () => { expect(await restartedManager.getActiveModelIds()).toEqual(expect.arrayContaining(selectedIds)) expect(restartedManager.getActiveModalities()).toEqual(activeBeforeRestart) }) + + it('shows a Computer Use model active only in the role Tasks gives it', async () => { + // Review finding: the Models screens marked an old pick active while Tasks ran another model. + const fixture = activeSelectionFixtures.find(({ kind }) => kind === 'computer_use') + if (!fixture) throw new Error('Model catalog needs an installable computer use fixture') + const { entry } = fixture + for (const file of entry.files) { + fs.writeFileSync(path.join(dataDir, 'models', file.name), Buffer.alloc(2_048, 1)) + } + const { getComputerUseSettings, setComputerUseSettings } = + await import('../../computer-use-settings') + + // Activating it on the Models screen makes it the grounding specialist Tasks uses. + expect(await manager.activateModel(entry.id)).toEqual({ success: true }) + expect(getComputerUseSettings().groundingModelId).toBe(entry.id) + expect(await manager.getActiveModelIds()).toContain(entry.id) + + // Tasks picks another grounding specialist: this one is no longer active anywhere. + setComputerUseSettings({ + ...getComputerUseSettings(), + groundingModelId: 'remote-vision:router:bytedance%2Fui-tars-7b' + }) + const active = await manager.getActiveModelIds() + expect(active).not.toContain(entry.id) + expect(active).toContain('remote-vision:router:bytedance%2Fui-tars-7b') + }) }) diff --git a/src/main/models/catalog-logic.ts b/src/main/models/catalog-logic.ts index 350ff1d43..d4cc6d798 100644 --- a/src/main/models/catalog-logic.ts +++ b/src/main/models/catalog-logic.ts @@ -36,6 +36,27 @@ export interface CatalogEntry { availability?: 'ready' | 'coming_soon' availabilityNote?: string } +// Desktop MTP companions use the existing aux-file download contract. Keep the +// pinned file identity shared by catalog installation and engine launch. +export const MTP_COMPANIONS = { + 'bartowski/tencent_UI-Mate-9B-GGUF+MTP': { + name: 'Qwen3.5-9B-MTP-ONLY-Q4_K_M.gguf', + url: 'https://huggingface.co/a4lg/Qwen3.5-9B-MTP-ONLY-GGUF/resolve/b609b41c40921b0d1912f0d42347e53e28d22731/Qwen3.5-9B-MTP-ONLY-Q4_K_M.gguf', + sizeBytes: 1576551712, + sha256: '82f356f754e70172f474e70229153bb0fe7137f3a181af5bc5e6fe43d56876fa', + role: 'aux' as const + }, + 'unsloth/Qwen3.8-27B-GGUF': { + name: 'mtp-Qwen3.8-27B-Q4_0.gguf', + url: 'https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/4ca720788d1e01f1bff70c033e0d0028fd02e502/MTP/mtp-Qwen3.8-27B-Q4_0.gguf', + sizeBytes: 1369590656, + sha256: '50d9ce5a6da381bbcfb31061cf73df94a90e6faf8efeddee379a9cb8f1501c6e', + role: 'aux' as const + } +} + +export const isMtpFileName = (name: string): boolean => /(?:^|[-_.])mtp(?:[-_.]|$)/i.test(name) + export interface LocalModelLike { id: string name: string @@ -108,7 +129,7 @@ export function downloadedForCatalog( name, url: '', sizeBytes: sizeOf(name) || family?.files.find((file) => file.name === name)?.sizeBytes || 0, - role: isProjectorFileName(name) ? 'mmproj' : 'primary' + role: isProjectorFileName(name) ? 'mmproj' : isMtpFileName(name) ? 'aux' : 'primary' })) } }) @@ -147,14 +168,16 @@ export function mergeCatalog(opts: { } /** Whether a single catalog entry counts as installed. mflux entries defer to the - * runtime cache (`mfluxCached`); DFlash is an optional repairable companion. */ + * runtime cache (`mfluxCached`); DFlash and MTP are optional repairable companions. */ export function catalogEntryInstalled( entry: CatalogEntry, present: FilePresent, mfluxCached: (id: string) => boolean ): boolean { if (entry.runtime === 'mflux') return mfluxCached(entry.id) - const required = entry.files.filter((file) => !isDflashFileName(file.name)) + const required = entry.files.filter( + (file) => !isDflashFileName(file.name) && !isMtpFileName(file.name) + ) return required.length > 0 && required.every((file) => present(file.name)) } @@ -220,6 +243,9 @@ export interface VisionStatus { supportsDflash?: boolean dflashInstalled?: boolean dflashFile?: string + /** Optional MTP companion readiness; native embedded MTP is checked by the engine owner. */ + supportsMtp?: boolean + mtpInstalled?: boolean } /** Per-model vision capability + readiness, derived from files (does it ship a @@ -231,7 +257,9 @@ export function visionStatus( ): VisionStatus { const projector = projectorFileName(entry) const dflash = dflashFileName(entry) + const mtp = entry.files.find((file) => isMtpFileName(file.name))?.name return { + ...(mtp ? { supportsMtp: true, mtpInstalled: present(mtp) } : {}), supportsVision: !!projector, projectorInstalled: !!projector && present(projector), ...(dflash diff --git a/src/main/models/gguf-metadata.ts b/src/main/models/gguf-metadata.ts index 5f5676148..bcf406c0a 100644 --- a/src/main/models/gguf-metadata.ts +++ b/src/main/models/gguf-metadata.ts @@ -147,7 +147,7 @@ function skipArray(c: Cursor): void { * (so a caller still gets `architecture` even if the buffer ended before `context_length`). * Not a valid GGUF header → returns {}. */ -export function parseGgufMetadata(buf: Buffer): GgufMetadata { +export function parseGgufMetadata(buf: Buffer, scanPastTokenizer = false): GgufMetadata { const result: GgufMetadata = {} if (buf.length < 8 || buf.subarray(0, 4).toString('ascii') !== 'GGUF') { return result @@ -164,10 +164,13 @@ export function parseGgufMetadata(buf: Buffer): GgufMetadata { for (let i = 0; i < kvCount; i++) { const key = c.str() const type = c.u32() - // Model hyperparameters precede the tokenizer payload in modern GGUF files. Once that - // payload begins, an absent NextN field means the model has no embedded MTP heads. Do not - // synchronously walk megabytes of token strings each time Settings asks for capabilities. - if (key.startsWith('tokenizer.')) return result + // Context reads can stop here. MTP exports can put NextN metadata after + // the tokenizer, so capability reads must continue through its payload. + if (key.startsWith('tokenizer.')) { + if (!scanPastTokenizer) return result + readValue(c, type) + continue + } const value = readValue(c, type) if (value !== undefined) { scalars.set(key, value) @@ -245,10 +248,14 @@ export function readGgufContextLength( export function readGgufMtpSupport( p: string, fs: GgufFs, - maxBytes = GGUF_METADATA_PREFIX_BYTES + // Some exports put prediction metadata after the tokenizer (about 11 MiB + // for UI-Mate 9B). Keep this read bounded while covering that ordering. + maxBytes = GGUF_METADATA_PREFIX_BYTES * 4, + expectedSize?: number ): boolean { try { const size = fs.statSync(p).size + if (expectedSize !== undefined && size !== expectedSize) return false const toRead = Math.min(size, maxBytes) if (toRead <= 0) return false const fd = fs.openSync(p, 'r') @@ -258,7 +265,7 @@ export function readGgufMtpSupport( } finally { fs.closeSync(fd) } - return (parseGgufMetadata(buf).nextnPredictLayers ?? 0) > 0 + return (parseGgufMetadata(buf, true).nextnPredictLayers ?? 0) > 0 } catch { return false } diff --git a/src/main/task-role-models.ts b/src/main/task-role-models.ts new file mode 100644 index 000000000..c801d647e --- /dev/null +++ b/src/main/task-role-models.ts @@ -0,0 +1,34 @@ +// The one place a task model role is set or cleared. Web Use and Computer Use share one grounding +// specialist; each has its own decision model. The Models screens, Active models, Tasks settings +// and a paired browser all change roles through here, so they can never disagree. + +import type { TaskModelRole } from '../shared/computer-use-settings' +import { getComputerUseSettings, setComputerUseSettings } from './computer-use-settings' +import { getWebUseSettings, setWebUseSettings } from './web-use-settings' + +export type TaskKind = 'computer_use' | 'web_use' + +/** Give `modelId` a role. Grounding is shared by both tasks, whichever one is named. */ +export function setTaskRoleModel(task: TaskKind, role: TaskModelRole, modelId: string): void { + if (role === 'grounding') { + setComputerUseSettings({ ...getComputerUseSettings(), groundingModelId: modelId }) + } else if (task === 'computer_use') { + setComputerUseSettings({ ...getComputerUseSettings(), decisionModelId: modelId }) + } else { + setWebUseSettings({ ...getWebUseSettings(), decisionModelId: modelId }) + } +} + +/** A model that is gone leaves every role it held: each falls back to its default. */ +export function clearTaskRolesFor(modelId: string): void { + const computer = getComputerUseSettings() + if (computer.groundingModelId === modelId || computer.decisionModelId === modelId) { + setComputerUseSettings({ + ...computer, + groundingModelId: computer.groundingModelId === modelId ? null : computer.groundingModelId, + decisionModelId: computer.decisionModelId === modelId ? null : computer.decisionModelId + }) + } + const web = getWebUseSettings() + if (web.decisionModelId === modelId) setWebUseSettings({ ...web, decisionModelId: null }) +} diff --git a/src/main/task-role-requests.ts b/src/main/task-role-requests.ts new file mode 100644 index 000000000..3e148b738 --- /dev/null +++ b/src/main/task-role-requests.ts @@ -0,0 +1,39 @@ +// A task role change someone asked for: over IPC, the local gateway or a paired browser. Checked +// against the model inventory, then set through task-role-models.ts. Kept apart from that module +// because the model inventory itself clears roles through it. + +import type { TaskModelRole } from '../shared/computer-use-settings' +import { getCatalog, listInstalled } from './models-manager' +import { setTaskRoleModel } from './task-role-models' + +/** Whether `modelId` can fill `role`: an installed or remote task model of that kind (a decision + * model carries the Decision tag; a grounding specialist does not). */ +export async function canFillTaskRole(modelId: string, role: TaskModelRole): Promise { + const [catalog, installed] = await Promise.all([getCatalog(), listInstalled()]) + const models = catalog.models as Array<{ id?: string; kind?: string; tags?: readonly string[] }> + const entry = models.find((model) => model.id === modelId) + if (!entry || entry.kind !== 'computer_use' || !installed.includes(modelId)) return false + return (entry.tags?.includes('Decision') ?? false) === (role === 'decision') +} + +/** A role change asked for over IPC, the local gateway or a paired browser: checked, then set. */ +export async function setTaskRoleFromRequest(request: { + task?: unknown + role?: unknown + modelId?: unknown +}): Promise<{ success: boolean; error?: string }> { + const { task, role, modelId } = request + if ( + (task !== 'computer_use' && task !== 'web_use') || + (role !== 'decision' && role !== 'grounding') || + typeof modelId !== 'string' || + !modelId + ) { + return { success: false, error: 'Choose a task, a role and a model.' } + } + if (!(await canFillTaskRole(modelId, role))) { + return { success: false, error: 'That model cannot fill this role.' } + } + setTaskRoleModel(task, role, modelId) + return { success: true } +} diff --git a/src/main/task-settings-events.ts b/src/main/task-settings-events.ts new file mode 100644 index 000000000..2b43c7f25 --- /dev/null +++ b/src/main/task-settings-events.ts @@ -0,0 +1,15 @@ +// Web Use and Computer Use settings have one home, the desktop's settings store, and two places +// to change them: the desktop's Settings and the paired browser's. Every window hears about a +// change, wherever it came from, so the screens never show different values. + +import { BrowserWindow } from 'electron' + +export const TASK_SETTINGS_CHANGED = 'task-settings:changed' + +export function announceTaskSettings(key: string, value: unknown): void { + // The model manager also runs without Electron (no windows to tell), as runtime-env allows. + if (typeof BrowserWindow === 'undefined') return + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send(TASK_SETTINGS_CHANGED, { key, value }) + } +} diff --git a/src/main/tasks/__tests__/task-retry-context.test.ts b/src/main/tasks/__tests__/task-retry-context.test.ts new file mode 100644 index 000000000..3c1dd0049 --- /dev/null +++ b/src/main/tasks/__tests__/task-retry-context.test.ts @@ -0,0 +1,80 @@ +// Continue runs in the same task context as a new run of its kind. Electron, the hosts and the +// settings stores are the boundaries replaced; the gate and the task session run for real. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TaskRetryRunResult } from '../task-retry' + +const world = vi.hoisted(() => ({ + runner: null as null | { + web: (task: unknown, taskId: string, checkpoint?: unknown) => Promise + computer: (task: unknown, taskId: string, checkpoint?: unknown) => Promise + }, + seen: [] as Array<{ taskKind?: string; modelStrategy?: string }> +})) + +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn() }, + app: { getPath: () => '/tmp/offgrid-task-retry-context' } +})) +vi.mock('node:fs/promises', () => ({ + appendFile: vi.fn(async () => undefined), + mkdir: vi.fn(async () => undefined), + stat: vi.fn(async () => ({ size: 0 })) +})) +vi.mock('../task-history', () => ({ + initializeTaskHistory: vi.fn(), + listTaskRuns: vi.fn(), + removeTaskRuns: vi.fn() +})) +vi.mock('../task-retry', () => ({ + configureTaskRetryRunner: (runner: typeof world.runner) => { + world.runner = runner + } +})) +vi.mock('../../web-use-settings', () => ({ + getWebUseSettings: () => ({ modelStrategy: 'separate_specialist', decisionModelId: null }) +})) +vi.mock('../../computer-use-settings', () => ({ + getComputerUseSettings: () => ({ modelStrategy: 'same_as_chat', decisionModelId: null }) +})) +vi.mock('../../vision/remote-vision-server', () => ({ + getActiveRemoteVisionServer: () => null, + getRemoteVisionServerForModel: () => null +})) +vi.mock('../../vision/grounder-loader', () => ({ selectedGrounderModelId: () => null })) +vi.mock('../../accessibility/decision-model-loader', () => ({ + selectedDecisionModelId: () => null +})) +vi.mock('../../vision/vision-controller', () => ({ + forgetVisionStopBeforeStart: vi.fn(), + hasActiveVisionSession: () => false +})) +vi.mock('../../browser/web-use-host', async () => { + const { currentRemoteScreenTaskSession } = await import('../../actions/remote-screen-session') + return { + getWebUseRailHost: () => ({ + runTask: async () => { + world.seen.push({ ...currentRemoteScreenTaskSession() }) + return { ok: true, summary: 'Found the release notes' } + } + }) + } +}) + +import { registerTaskHistoryIpc } from '../task-history-ipc' + +const task = { title: 'Find the release notes', lastUrl: 'https://example.com', journeyId: 'j-1' } + +beforeEach(() => { + world.seen = [] + registerTaskHistoryIpc() +}) + +describe('Continue', () => { + it('runs a web task with the Web Use model settings, never the Computer Use ones', async () => { + const result = await world.runner!.web(task, 'task-1') + expect(result).toEqual({ ok: true, summary: 'Found the release notes' }) + expect(world.seen).toEqual([ + expect.objectContaining({ taskKind: 'web_use', modelStrategy: 'separate_specialist' }) + ]) + }) +}) diff --git a/src/main/tasks/task-history-ipc.ts b/src/main/tasks/task-history-ipc.ts index 48ee52fdf..d3eb0c430 100644 --- a/src/main/tasks/task-history-ipc.ts +++ b/src/main/tasks/task-history-ipc.ts @@ -3,24 +3,53 @@ import { ipcMain } from 'electron' import { initializeTaskHistory, listTaskRuns, removeTaskRuns } from './task-history' import { registerTaskRetryIpc } from './task-retry-ipc' import { registerTaskGuideIpc } from './task-guide-ipc' -import { configureTaskRetryRunner } from './task-retry' -import { hasActiveVisionSession } from '../vision/vision-controller' +import { configureTaskRetryRunner, type TaskRetryRunResult } from './task-retry' +import { runInRemoteScreenGate } from '../actions/remote-screen-gate' +import { withTaskModelMemory } from '../model-memory' +import type { ScreenTaskKind } from '../../shared/remote-screen-privacy' +import { forgetVisionStopBeforeStart, hasActiveVisionSession } from '../vision/vision-controller' + +/** A retry runs in the same task context as a new run of its kind: the privacy gate, that task + * kind's own model settings and the task's model memory. */ +function runRetryInTaskContext( + taskKind: ScreenTaskKind, + taskId: string, + goal: string, + run: () => Promise +): Promise { + return runInRemoteScreenGate(taskKind, { id: taskId, goal }, () => withTaskModelMemory(run), { + outcome: (result) => + result.ok + ? { ok: true, detail: result.summary, effectId: taskId } + : { ok: false, detail: result.summary }, + refused: (detail) => ({ ok: false, summary: detail ?? '' }) + }) +} export function registerTaskHistoryIpc(): void { initializeTaskHistory() configureTaskRetryRunner({ isActive: hasActiveVisionSession, async web(task, taskId, checkpoint) { - const { getBrowserRailHost } = await import('../browser/browser-host') - return getBrowserRailHost().runTask({ - goal: task.title, - url: task.lastUrl, - taskId, - journeyId: task.journeyId, - checkpoint - }) + // A retry runs with the model-facing goal; the title is only the name a person reads. + const goal = task.goal ?? task.title + // Continue is a new run: a Stop sent to an earlier, never-started one does not apply. + forgetVisionStopBeforeStart(taskId) + // The same choice as a new task: Tasks > Web Use decides which browser a retry runs in. + const { getWebUseRailHost } = await import('../browser/web-use-host') + return runRetryInTaskContext('web_use', taskId, goal, () => + getWebUseRailHost().runTask({ + goal, + url: task.lastUrl, + taskId, + journeyId: task.journeyId, + checkpoint + }) + ) }, async computer(task, taskId, checkpoint) { + const goal = task.goal ?? task.title + forgetVisionStopBeforeStart(taskId) const [{ getVisionRailHost }, { getAxRailHost }, { axRailViable }] = await Promise.all([ import('../vision/vision-host'), import('../accessibility/ax-host'), @@ -32,7 +61,7 @@ export function registerTaskHistoryIpc(): void { targetLabel?: string ): Promise => { return getVisionRailHost().runTask( - task.title, + goal, taskId, task.journeyId, recoveryCheckpoint, @@ -40,27 +69,29 @@ export function registerTaskHistoryIpc(): void { targetLabel ) } - const axHost = getAxRailHost() - const routing = await axHost.routingSnapshot(task.title) - if (routing && axRailViable(routing.snapshot)) { - return axHost.runTask(task.title, taskId, routing.app, routing.snapshot, { - journeyId: task.journeyId, - checkpoint, - recoverWithVision: async (recoveryCheckpoint, continuation) => { - const result = await runVision(recoveryCheckpoint, continuation, routing.app) - return result.ok - ? { - ok: true, - effectId: taskId, - ...(result.performedActions?.length - ? { performedActions: result.performedActions } - : {}) - } - : { ok: false, detail: result.summary } - } - }) - } - return runVision() + return runRetryInTaskContext('computer_use', taskId, goal, async () => { + const axHost = getAxRailHost() + const routing = await axHost.routingSnapshot(goal) + if (routing && axRailViable(routing.snapshot)) { + return axHost.runTask(goal, taskId, routing.app, routing.snapshot, { + journeyId: task.journeyId, + checkpoint, + recoverWithVision: async (recoveryCheckpoint, continuation) => { + const result = await runVision(recoveryCheckpoint, continuation, routing.app) + return result.ok + ? { + ok: true, + effectId: taskId, + ...(result.performedActions?.length + ? { performedActions: result.performedActions } + : {}) + } + : { ok: false, detail: result.summary } + } + }) + } + return runVision() + }) } }) ipcMain.handle('tasks:list', (_event, limit: unknown) => diff --git a/src/main/tasks/task-history-store.ts b/src/main/tasks/task-history-store.ts index ca3357d6d..29b0c2af6 100644 --- a/src/main/tasks/task-history-store.ts +++ b/src/main/tasks/task-history-store.ts @@ -22,6 +22,7 @@ import { type ComputerUsePhase, type ComputerUseStepDetail } from './task-step-details' +import { taskDisplayTitle } from '../../shared/task-display-title' export type TaskRunKind = AutomationTaskKind export type TaskRunStatus = AutomationTaskReadStatus @@ -34,7 +35,10 @@ export interface TaskRunSnapshot { modelId?: string modelName?: string kind: TaskRunKind + /** The short, plain name a person reads. */ title: string + /** The model-facing goal a retry runs with (the stored title column). Never shown. */ + goal?: string status: TaskRunStatus summary?: string steps: string[] @@ -65,7 +69,9 @@ export interface TaskRunUpdate { modelId?: string modelName?: string kind: TaskRunKind + /** Writers pass the task's goal or a title; the store keeps a display title either way. */ title?: string + goal?: string status?: TaskRunStatus summary?: string steps?: string[] @@ -126,6 +132,11 @@ interface TaskRunRow { step_details_json?: string | null } +/** The first goal a task was recorded with stays its goal; later writes only refresh the title. */ +function goalOf(update: TaskRunUpdate, previous: TaskRunSnapshot | undefined): string | undefined { + return update.goal?.trim() || previous?.goal || update.title?.trim() || undefined +} + export const TASK_HISTORY_LIMIT = 50 export const ORPHANED_LOCAL_WEB_TASK_SUMMARY = 'Stopped because the earlier local Web Use process is no longer active.' @@ -162,7 +173,9 @@ function rowToSnapshot(row: TaskRunRow): TaskRunSnapshot | undefined { ...(row.model_id ? { modelId: row.model_id } : {}), ...(row.model_name ? { modelName: row.model_name } : {}), kind, - title: row.title, + // The title column keeps the goal the task was recorded with; a person reads its plain name. + title: taskDisplayTitle(row.title) || row.title, + ...(row.title ? { goal: row.title } : {}), status, ...(row.summary ? { summary: row.summary } : {}), steps: safeSteps(row.steps_json), @@ -356,7 +369,8 @@ export class TaskHistoryStore { ...persistedModelIdentity(previous, update), ...persistedReasoning(previous, update, kind, status), kind, - title: update.title?.trim() || previous?.title || automationTaskKindLabel(kind), + title: taskDisplayTitle(update.title) || previous?.title || automationTaskKindLabel(kind), + ...(goalOf(update, previous) ? { goal: goalOf(update, previous) } : {}), status, ...(update.summary !== undefined ? { summary: update.summary } @@ -470,7 +484,8 @@ export class TaskHistoryStore { snapshot.modelId ?? null, snapshot.modelName ?? null, snapshot.kind, - snapshot.title, + // Stored as before: the goal the task runs with. Reads derive the title from it. + snapshot.goal ?? snapshot.title, snapshot.status, snapshot.summary ?? null, JSON.stringify(snapshot.steps), diff --git a/src/main/tasks/task-history.ts b/src/main/tasks/task-history.ts index 954e56cc3..1d7703fc2 100644 --- a/src/main/tasks/task-history.ts +++ b/src/main/tasks/task-history.ts @@ -142,7 +142,17 @@ function pruneSnapshots(): void { } } +const ENDED: ReadonlySet = new Set(['completed', 'done', 'failed', 'stopped']) +const endedListeners = new Set<(task: TaskRunSnapshot) => void>() + +/** Hear once when a web or computer task ends (completed, failed or stopped). */ +export function onTaskRunEnded(listener: (task: TaskRunSnapshot) => void): () => void { + endedListeners.add(listener) + return () => endedListeners.delete(listener) +} + export function recordTaskRun(update: TaskRunUpdate): TaskRunSnapshot { + const before = latest.get(update.taskId)?.status const snapshot = taskHistoryStore().upsert(update) if (persistTaskResultInChat(getDB(), snapshot) && snapshot.journeyId) { notifyRagConversationChanged({ conversationId: snapshot.journeyId }) @@ -152,6 +162,15 @@ export function recordTaskRun(update: TaskRunUpdate): TaskRunSnapshot { live.delete(snapshot.taskId) latest.set(snapshot.taskId, snapshot) publishTaskRun(snapshot) + if (ENDED.has(snapshot.status) && !(before && ENDED.has(before))) { + for (const listener of endedListeners) { + try { + listener(snapshot) + } catch (error) { + console.error('[tasks] ended listener failed', error) + } + } + } return snapshot } diff --git a/src/main/tools.ts b/src/main/tools.ts index 84fdae305..d5ce846fc 100644 --- a/src/main/tools.ts +++ b/src/main/tools.ts @@ -5,14 +5,11 @@ // tool schemas; we parse its tool_calls, run them, feed results back, and loop // until it answers. Built-in tools and selected connector extensions share it. +import { offgridGuideTool } from './tools/offgrid-guide-tool' import { llm } from './llm' import type { GenerationMetrics } from '../shared/generation-metrics' import type { ResponseCutoffContract } from '../shared/ipc-contracts' -import { - SEARCH_KB_TOOL, - makeSearchKnowledgeBaseHandler, - sanitizePromptExcerpt -} from '@offgrid/rag' +import { SEARCH_KB_TOOL, makeSearchKnowledgeBaseHandler, sanitizePromptExcerpt } from '@offgrid/rag' import { stripChatControlTokens } from '@offgrid/sync' import { isMemoryToolAllowed } from './tools/memory-scope' import { parseToolCallsFromText, stripQwenToolCallMarkup } from './tools/tool-call-parse' @@ -33,6 +30,7 @@ import { toolResultCharBudget } from '@offgrid/models' import { toolPromptChars } from './tools/prompt-budget' +import { ANSWER_NOW_INSTRUCTION, emptyAnswerReason } from '../shared/empty-answer' // Per-tool enable/disable, persisted as a list of disabled tool names. function disabledSet(): Set { @@ -54,8 +52,13 @@ export function setToolEnabled(name: string, enabled: boolean): void { // excludes the current conversation so it can't cite itself). export interface ToolContext { conversationId?: string + /** Who asked: a person in a chat, or a routine nobody is watching (its actions always wait for approval). */ + actionSource?: 'chat' | 'routine' /** Authenticated Mobile launch identity. Only the MCP admission boundary sets it. */ taskLaunch?: { launchId: string; requestingDeviceId: string } + /** The paired browser's tab a web task should run in: the tab of the chat that asked. Only + * the extension bridge sets it; it travels with that one task (browser-start-tab.ts). */ + startTab?: { browserId: string; tabId: number } /** The exact user message. Approval-gated tools use this instead of trusting model-made args. */ userQuery?: string /** Bounded prior user/assistant turns. Intake tools combine these facts with @@ -101,6 +104,9 @@ export interface ToolResult { sources?: UnifiedSource[] imageRequest?: { prompt: string; enhancePrompt?: boolean } imageRequests?: { prompt: string; enhancePrompt?: boolean }[] + /** For a task tool (web_use, computer_use): the accepted task's id. Its run, its progress and + * Stop all use it, from the moment it is accepted, queued or waiting for approval. */ + taskId?: string } type ToolDef = { @@ -175,6 +181,7 @@ export async function readUrlText(url: string): Promise { // --- Built-in tools -------------------------------------------------------- const TOOLS: ToolDef[] = [ + offgridGuideTool, { name: 'web_search', description: @@ -448,9 +455,10 @@ const TOOLS: ToolDef[] = [ // otherwise; every other built-in obeys only the disabled-set. function schemas( imageAvailable: boolean, - scope: { projectActive: boolean; allMemory: boolean } + scope: { projectActive: boolean; allMemory: boolean }, + allTools = false ): unknown[] { - const off = disabledSet() + const off = allTools ? new Set() : disabledSet() return ( TOOLS.filter((t) => !off.has(t.name)) .filter((t) => t.name !== 'generate_image' || imageAvailable) @@ -463,6 +471,25 @@ function schemas( ) } +/** The tools a paired browser's agent is offered: the same built-ins and extensions this chat + * uses, minus what Settings turned off. generate_image is left out; its image lands in chat. */ +export async function enabledToolSchemas( + exts: ToolExtension[] +): Promise<{ schema: unknown; extension?: ToolExtension }[]> { + const off = disabledSet() + const builtins = schemas(false, { projectActive: false, allMemory: true }).map((schema) => ({ + schema + })) + const extended = await Promise.all( + exts.map(async (extension) => + (await extension.schemas()) + .filter((s) => !off.has((s as { function?: { name?: string } }).function?.name ?? '')) + .map((schema) => ({ schema, extension })) + ) + ) + return [...builtins, ...extended.flat()] +} + /** Normalize a tool's return (bare string or structured) to a ToolResult. */ function asToolResult(r: string | ToolResult): ToolResult { return typeof r === 'string' ? { text: r } : r @@ -561,6 +588,12 @@ export async function toolChat( opts: { /** Assistant is selected for this turn; always include its browser and desktop tools. */ assistantOnly?: boolean + /** God: every tool is on, whatever the Tools switch and per-tool settings say. */ + allTools?: boolean + /** A routine runs this turn: what it tries to do waits for approval (god-routines.ts). */ + actionSource?: 'chat' | 'routine' + /** What the user is doing now (God's context): added to the system prompt, bounded. */ + context?: string connectors?: boolean conversationId?: string /** Active project — offers search_knowledge_base + scopes it to this project. */ @@ -610,6 +643,7 @@ export async function toolChat( const onDelta = opts.onDelta ?? ((): void => {}) const toolContext: ToolContext = { conversationId: opts.conversationId, + ...(opts.actionSource ? { actionSource: opts.actionSource } : {}), projectId: opts.projectId, userQuery: query, history: boundedToolHistory(history), @@ -619,7 +653,7 @@ export async function toolChat( // Offer generate_image only when an image model is available. The renderer passes // this; fall back to the main-process check so a caller that omits it still gates // correctly (single source of truth for "can we make an image right now"). - const toolsEnabled = getSetting('toolsEnabled', true) !== false + const toolsEnabled = opts.allTools || getSetting('toolsEnabled', true) !== false let imageAvailable = opts.imageAvailable ?? false if ((!opts.assistantOnly || toolsEnabled) && opts.imageAvailable === undefined) { try { @@ -641,7 +675,7 @@ export async function toolChat( const extSchemas: unknown[] = [] const hints: string[] = [] const extensionHints: { names: Set; text: string }[] = [] - const disabled = disabledSet() + const disabled = opts.allTools ? new Set() : disabledSet() for (const e of exts) { try { const s = await e.schemas() @@ -669,10 +703,11 @@ export async function toolChat( } } const builtins = toolsEnabled - ? schemas(imageAvailable, { - projectActive: !!opts.projectId, - allMemory: !!opts.allMemory - }) + ? schemas( + imageAvailable, + { projectActive: !!opts.projectId, allMemory: !!opts.allMemory }, + opts.allTools + ) : [] const assistantRequiredTools = opts.assistantOnly ? extSchemas.filter((schema) => { @@ -745,6 +780,13 @@ export async function toolChat( ...relevantTools.filter((schema) => !explicitNames.has(schema)) ] } + // Acting is offered whenever the assistant may act, and the model decides from the prompt + // (answer questions; act when asked to do something on a website or in an app). Routing by the + // words of the message must not hide it: "can you check this site?" shares no words with it. + if (assistantRequiredTools.length) { + const acting = new Set(assistantRequiredTools) + relevantTools = [...assistantRequiredTools, ...relevantTools.filter((t) => !acting.has(t))] + } if (!/\bbrave\b/i.test(query)) { const hasPrimarySearch = relevantTools.some( (schema) => (schema as { function?: { name?: unknown } }).function?.name === 'web_search' @@ -766,7 +808,9 @@ export async function toolChat( .filter((hint) => [...hint.names].some((name) => relevantNames.has(name))) .map((hint) => hint.text) ) - const protectedToolCount = explicitlyNamedTools.length + const protectedToolCount = + explicitlyNamedTools.length + + assistantRequiredTools.filter((t) => !explicitlyNamedTools.includes(t)).length const { budgetTools } = await import('./tools/tool-budget') const ctx = llm.effectiveContextSize() // Cap tool tokens in ABSOLUTE terms too, not just as a fraction of context: @@ -805,7 +849,7 @@ export async function toolChat( ?.systemPrompt.trim() : undefined const sys = - 'You are Off Grid AI, a private on-device assistant. Answer general questions using your knowledge. Use the provided tools when they help answer precisely. Before calling web_use, use the full conversation and ask the user one concise set of questions only when a material fact is missing. If the task is actionable, call web_use immediately. Keep answers concise.' + + 'You are Off Grid AI, a private on-device assistant. Answer general questions using your knowledge. Use the provided tools when they help answer precisely. Before calling web_use, use the full conversation and ask the user one concise set of questions only when a material fact is missing. Answer questions directly. Call web_use only when the user asks you to do something on a website. Keep answers concise.' + (opts.allMemory ? ' Use search_memory when the user asks about their memories, past conversations, people, or captured activity. Do not invent personal facts or claim to have searched when you have not. Cite retrieved sources accurately.' : '') + @@ -814,9 +858,12 @@ export async function toolChat( : '') + (projectPrompt ? `\n\nProject instructions:\n${projectPrompt}` : '') + (opts.assistantOnly - ? ' web_use and computer_use are available for website and desktop tasks.' + ? ' web_use and computer_use are available when the user asks you to do something on a website or in a desktop app.' : '') + - (hints.length ? ' ' + hints.join(' ') : '') + (hints.length ? ' ' + hints.join(' ') : '') + + (typeof opts.context === 'string' && opts.context.trim() + ? `\n\nWhat you know about the user right now:\n${opts.context.trim().slice(0, 2000)}` + : '') // Attached images ride on the current user turn so the vision model can read // them even in tools/connectors mode (otherwise they were silently dropped). @@ -1119,6 +1166,24 @@ export async function toolChat( continue // let the model use the results } // No tool calls this round: `content` is the final answer (already streamed via onDelta). + if (!answerFrom(content).trim() && !opts.signal?.aborted) { + // Finished with nothing to say (often reasoning used the whole allowance): ask once more + // for an answer from what is already here, then say why if there is still none. + const retry = await llm.streamChat( + [...messages, { role: 'system', content: ANSWER_NOW_INSTRUCTION }], + onDelta, + { temperature: 0.3, maxTokens: roundMaxTokens, thinking: false, signal: opts.signal } + ) + const retried = answerFrom(retry.content) + return resultWithImages({ + answer: retried.trim() + ? retried + : emptyAnswerReason(retry.finishReason ?? finishReason, roundMaxTokens), + toolCalls, + unified, + metrics: retry.metrics ?? metrics + }) + } return resultWithImages({ answer: answerFrom(content), toolCalls, diff --git a/src/main/tools/__tests__/nativeActionToolExtension-engine.test.ts b/src/main/tools/__tests__/nativeActionToolExtension-engine.test.ts index cb8cf9b2a..baef55f4b 100644 --- a/src/main/tools/__tests__/nativeActionToolExtension-engine.test.ts +++ b/src/main/tools/__tests__/nativeActionToolExtension-engine.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it, vi } from 'vitest' import type { TickOutcome } from '@offgrid/use' vi.mock('electron', () => ({ shell: { openExternal: vi.fn() } })) +import { startTabFromActionArgs } from '../../browser/browser-start-tab' import { NativeActionToolExtension, type ActionsPort } from '../nativeActionToolExtension' import { @@ -106,6 +107,34 @@ describe('the spec table', () => { }) describe('the engine path', () => { + it("reports the accepted task's id, and carries only the bridge's tab for a web task", async () => { + // Review findings: the bridge searched history for the id, and one tab offer served a whole + // browser. Now the id comes back with the result and the tab travels inside the task. + const port = makePort() + const extension = new NativeActionToolExtension( + { run, taskUseEnabled: proEntitled, actions: port }, + 'darwin' + ) + const result = await extension.execute( + 'web_use', + // A start tab written by the model is never kept. + { goal: 'Check the order page', __offgridStartBrowserId: 'other', __offgridStartTabId: 5 }, + { conversationId: 'browser:chrome-1', startTab: { browserId: 'chrome-1', tabId: 42 } } + ) + expect(result).toMatchObject({ status: 'pending', taskId: 'act_1' }) + expect(startTabFromActionArgs((port.proposed[0] as { args: unknown }).args)).toEqual({ + browserId: 'chrome-1', + tabId: 42 + }) + const computer = await extension.execute( + 'computer_use', + { goal: 'Open Notes' }, + { conversationId: 'browser:chrome-1', startTab: { browserId: 'chrome-1', tabId: 42 } } + ) + expect(computer).toMatchObject({ taskId: 'act_1' }) + expect(startTabFromActionArgs((port.proposed[1] as { args: unknown }).args)).toBeNull() + }) + it('starts the exact Web Use brief selected by the Chat model without a second gate', async () => { const port = makePort() const extension = new NativeActionToolExtension( diff --git a/src/main/tools/__tests__/tool-embedding-ranking.test.ts b/src/main/tools/__tests__/tool-embedding-ranking.test.ts index fc9df229f..2b240dac7 100644 --- a/src/main/tools/__tests__/tool-embedding-ranking.test.ts +++ b/src/main/tools/__tests__/tool-embedding-ranking.test.ts @@ -20,7 +20,7 @@ const tool = (name: string, description: string): unknown => ({ function: { name, description, parameters: { type: 'object', properties: {} } } }) -const CALENDAR = tool('calendar_events', 'Meetings, availability and scheduling on your calendar') +const CALENDAR = tool('calendar_events', 'Availability, events and scheduling on your calendar') const SLACK = tool('slack_send', 'Post a message to a Slack channel or direct message') const GITHUB = tool('github_issue', 'Open an issue on a code repository') const BUILTIN = tool('web_search', 'Search the web') diff --git a/src/main/tools/__tests__/tool-ranking.test.ts b/src/main/tools/__tests__/tool-ranking.test.ts index 2fec6bca6..a3ebb5103 100644 --- a/src/main/tools/__tests__/tool-ranking.test.ts +++ b/src/main/tools/__tests__/tool-ranking.test.ts @@ -6,7 +6,7 @@ * the off-topic one is dropped — the actual behavior the user gets. */ import { describe, it, expect } from 'vitest' -import { terms, scoreTool, rankConnectorTools } from '../tool-ranking' +import { terms, scoreTool, rankConnectorTools, selectRelevantTools } from '../tool-ranking' import { budgetTools } from '../tool-budget' const tool = (name: string, description: string): unknown => ({ @@ -78,3 +78,62 @@ describe('rankConnectorTools', () => { expect(keptNames).toContain('calendar_list_events') // relevant tool kept }) }) + +describe('email across several accounts', () => { + const tool = (name: string, description: string): unknown => ({ + type: 'function', + function: { name, description } + }) + // A catalog shaped like a real one: three Google accounts, one Microsoft account, and a + // Notion connector whose user tools mention email addresses. All synthetic. + const catalog = [ + tool( + 'mcp__4__notion-get-users', + '[Notion] List all users in the workspace, with their names and email addresses.' + ), + tool('mcp__4__notion-create-comment', '[Notion] Add a comment to a page or discussion.'), + ...[7, 8, 9].map((id) => + tool( + `mcp__${id}__search_messages`, + `[Google (person${id}@example.test)] Search this account's email in Gmail (inbox and other mail) and return message subjects, senders, recipients, dates, snippets, and links.` + ) + ), + tool( + 'mcp__10__search_messages', + "[Microsoft (person@example.test)] Read or search this account's email in Outlook (inbox and other mail). Returns message previews and pagination." + ) + ] + const names = (tools: unknown[]): string[] => + tools.map((t) => (t as { function: { name: string } }).function.name) + + it('offers every account mailbox for "top emails across my email accounts"', () => { + const picked = names( + selectRelevantTools( + 'Tell me the top 10 emails today across all of my email accounts', + catalog + ) + ) + expect(picked).toEqual( + expect.arrayContaining([ + 'mcp__7__search_messages', + 'mcp__8__search_messages', + 'mcp__9__search_messages', + 'mcp__10__search_messages' + ]) + ) + expect(picked.indexOf('mcp__7__search_messages')).toBeLessThan( + picked.indexOf('mcp__4__notion-get-users') === -1 + ? Infinity + : picked.indexOf('mcp__4__notion-get-users') + ) + }) + + it('folds plurals both ways', () => { + expect(terms('emails calendars inboxes class')).toEqual([ + 'email', + 'calendar', + 'inboxe', + 'class' + ]) + }) +}) diff --git a/src/main/tools/mcpConnectorToolExtension.ts b/src/main/tools/mcpConnectorToolExtension.ts index bcabcd155..d96248797 100644 --- a/src/main/tools/mcpConnectorToolExtension.ts +++ b/src/main/tools/mcpConnectorToolExtension.ts @@ -48,7 +48,7 @@ const productionBoundary: McpConnectorToolBoundary = { function formatChatConnectorExecution( execution: ChatConnectorExecution, - meta: { tool: string; connector: string } + meta: { tool: string; connector: string; unattended?: boolean } ): string { switch (execution.kind) { case 'unavailable': @@ -58,6 +58,10 @@ function formatChatConnectorExecution( case 'deduped': return `That exact connector action is already in flight — not starting a duplicate. Action reference: ${execution.actionId}.` case 'parked': + // A routine's change waits for a person; in Chat nothing should have parked. + if (meta.unattended) { + return `"${meta.tool}" on ${meta.connector}: Waiting for the user's approval in Action Approval; it runs once they approve. Tell the user it is waiting for them. Action reference: ${execution.actionId}.` + } return `Error: the action engine held this Chat connector action instead of starting it. No approval was created. Action reference: ${execution.actionId}.` case 'running': return `"${meta.tool}" on ${meta.connector} is running now. It does NOT need approval. Action reference: ${execution.actionId}.` @@ -143,9 +147,13 @@ export class McpConnectorToolExtension implements ToolExtension { connector: meta.connector, tool: meta.tool, args, - sourceRef: context?.conversationId + sourceRef: context?.conversationId, + ...(context?.actionSource ? { source: context.actionSource } : {}) + }) + return formatChatConnectorExecution(execution, { + ...meta, + unattended: context?.actionSource === 'routine' }) - return formatChatConnectorExecution(execution, meta) } try { const r = await this.boundary.callTool(meta.id, meta.tool, args) diff --git a/src/main/tools/nativeActionToolExtension.ts b/src/main/tools/nativeActionToolExtension.ts index a050476dc..8b51989b5 100644 --- a/src/main/tools/nativeActionToolExtension.ts +++ b/src/main/tools/nativeActionToolExtension.ts @@ -9,7 +9,7 @@ import { shell } from 'electron' import type { ToolCallStatus, ToolContext, ToolExtension, ToolResult } from '../tools' -import type { ProposeOutcome, TickOutcome } from '@offgrid/use' +import { WEB_USE_ACTION_TYPE, type ProposeOutcome, type TickOutcome } from '@offgrid/use' import { shouldGate } from '../actions/approval' import { getActionsRuntime } from '../actions/use-runtime' import { makeWinInlineRunner } from '../actions/semantic-rail-win' @@ -32,11 +32,17 @@ import { createHash } from 'node:crypto' import { getWebUseSettings } from '../web-use-settings' import { getBrowserLinks } from '../extension-bridge/bridge-electron' import { actionArgsWithTaskLaunch } from '../tasks/task-launch-identity' +import { actionArgsWithStartTab } from '../browser/browser-start-tab' +import { + TASK_GOAL_PREREQUISITES_LABEL, + TASK_GOAL_REQUEST_LABEL, + TASK_GOAL_SUMMARY_LABEL +} from '../../shared/task-display-title' /** The engine port the extension needs - implemented by the actions runtime, * faked in tests. Optional: absent means the legacy path only. */ export interface ActionsPort { - propose(input: unknown, meta: { source: 'chat' }): Promise + propose(input: unknown, meta: { source: 'chat' | 'routine' }): Promise waitForOutcome(actionId: string, timeoutMs: number): Promise whenParked(actionId: string): Promise kick(): void @@ -60,12 +66,12 @@ function taskGoalWithConversation(goal: unknown, context: ToolContext | undefine const currentRequest = context?.userQuery?.trim() ?? '' const completedPrerequisites = context?.completedPrerequisites?.filter(Boolean) ?? [] const sections = [ - currentRequest ? `Current user request (authoritative):\n${currentRequest}` : '', + currentRequest ? `${TASK_GOAL_REQUEST_LABEL}\n${currentRequest}` : '', completedPrerequisites.length - ? `Completed prerequisites (already done; continue from this state):\n${completedPrerequisites.map((item) => `- ${item}`).join('\n')}` + ? `${TASK_GOAL_PREREQUISITES_LABEL}\n${completedPrerequisites.map((item) => `- ${item}`).join('\n')}` : '', summary && summary.toLowerCase() !== 'placeholder' && summary !== currentRequest - ? `Structured task summary:\n${summary}` + ? `${TASK_GOAL_SUMMARY_LABEL}\n${summary}` : '' ].filter(Boolean) return sections.join('\n\n') || summary @@ -75,10 +81,16 @@ function engineResult( actionType: string, text: string, status: ToolCallStatus = 'completed', - authoritative = true + authoritative = true, + taskId?: string ): string | ToolResult { return isTaskAction(actionType) - ? { text, status, ...(authoritative ? { authoritative: true } : {}) } + ? { + text, + status, + ...(authoritative ? { authoritative: true } : {}), + ...(taskId ? { taskId } : {}) + } : text } @@ -278,11 +290,16 @@ export class NativeActionToolExtension implements ToolExtension { { type: actionType, intent: spec.title(args), - args: actionArgsWithTaskLaunch(cleanArgs, context?.taskLaunch), + // A web task carries the tab its chat offered; no other action, and never a tab the + // model wrote into its own arguments. + args: actionArgsWithStartTab( + actionArgsWithTaskLaunch(cleanArgs, context?.taskLaunch), + actionType === WEB_USE_ACTION_TYPE ? context?.startTab : undefined + ), risk: spec.risk }, { - source: 'chat', + source: context?.actionSource ?? 'chat', ...(context?.conversationId ? { sourceRef: context.conversationId } : {}), ...(context?.taskLaunch ? { @@ -297,16 +314,27 @@ export class NativeActionToolExtension implements ToolExtension { return reply(`Error: the action was refused: ${proposed.reason}`, 'failed') } const taskReference = isTaskAction(actionType) ? `Task reference: ${proposed.id}. ` : '' + // A task's reply carries its accepted id: the one its run, progress and Stop use. + const taskReply = (text: string, status: ToolCallStatus): string | ToolResult => + engineResult(actionType, text, status, true, proposed.id) if (proposed.deduped) { - return reply( + return taskReply( `${taskReference}A matching task is already in flight. No duplicate was started.`, 'pending' ) } actions.kick() + const unattendedRun = context?.actionSource === 'routine' + if (isTaskAction(actionType) && unattendedRun) { + const label = actionType === 'computer_use' ? 'Computer Use' : 'Web Use' + return taskReply( + `${taskReference}${label}: Waiting for the user's approval in Action Approval; it runs once they approve. Tell the user it is waiting for them.`, + 'pending' + ) + } if (isTaskAction(actionType)) { const label = actionType === 'computer_use' ? 'Computer Use' : 'Web Use' - return reply( + return taskReply( `${taskReference}${label} started. Live progress and the final result will appear in this chat. Do not call ${actionType} again for this goal.`, 'pending' ) @@ -317,6 +345,12 @@ export class NativeActionToolExtension implements ToolExtension { .then((outcome) => ({ kind: 'outcome' as const, outcome })), actions.whenParked(proposed.id).then(() => ({ kind: 'parked' as const })) ]) + if (raced.kind === 'parked' && unattendedRun) { + return reply( + `${taskReference}"${spec.title(args)}": Waiting for the user's approval in Action Approval; it runs once they approve. Tell the user it is waiting for them.`, + 'pending' + ) + } if (raced.kind === 'parked') { return reply( `${taskReference}Error: the action engine held this Chat action instead of starting it. No approval was created.`, diff --git a/src/main/tools/offgrid-guide-tool.ts b/src/main/tools/offgrid-guide-tool.ts new file mode 100644 index 000000000..43b76594e --- /dev/null +++ b/src/main/tools/offgrid-guide-tool.ts @@ -0,0 +1,116 @@ +// The offgrid_guide tool: what Off Grid AI does, how to do things in it, and what is new, from +// getoffgridai.co (quick start, guides, articles, perspectives, ethos), its public release notes +// and its community. Reads public pages only, and only when God or chat calls it. +// Wiring only: parsing and formatting are in shared/offgrid-guide.ts, tested. + +import { + OFFGRID_SITE_PAGES, + OFFGRID_SOURCES, + formatGuide, + newestUpdates, + pagesToRead, + parsePageBody, + parsePageSteps, + parseSiteCards, + type OffgridSite, + parseDevTo, + parseGithubReleases, + parseOrgRepos, + parseReddit, + type OffgridUpdate +} from '../../shared/offgrid-guide' + +const HEADERS = { 'User-Agent': 'OffGridAI-Desktop', Accept: 'application/json, text/html' } +// Release notes from the most recently pushed public repositories. +const RELEASE_REPOS = 4 + +async function get(url: string): Promise { + const res = await fetch(url, { headers: HEADERS, signal: AbortSignal.timeout(8_000) }) + if (!res.ok) { + throw new Error(`HTTP ${res.status}`) + } + return res +} + +async function releases(): Promise { + const repos = parseOrgRepos(await (await get(OFFGRID_SOURCES.githubOrg)).json()) + const lists = await Promise.all( + repos + .slice(0, RELEASE_REPOS) + .map(async (repo) => + parseGithubReleases( + await (await get(OFFGRID_SOURCES.releases(repo))).json().catch(() => []), + repo + ) + ) + ) + return lists.flat() +} + +const SOURCES: ReadonlyArray<{ name: string; read: () => Promise }> = [ + { name: 'GitHub releases', read: releases }, + { name: 'dev.to', read: async () => parseDevTo(await (await get(OFFGRID_SOURCES.devTo)).json()) }, + { + name: 'r/off_grid_ai', + read: async () => parseReddit(await (await get(OFFGRID_SOURCES.reddit)).json()) + } +] + +/** Every site page, read together: the cards they carry, once each, and the quick start's steps. */ +async function site(query: string): Promise<{ site: OffgridSite; failed: string[] }> { + const pages = await Promise.allSettled( + OFFGRID_SITE_PAGES.map(async (page) => (await get(page.url)).text()) + ) + const html = pages.map((p) => (p.status === 'fulfilled' ? p.value : '')) + const seen = new Set() + const cards = html + .flatMap((page) => parseSiteCards(page)) + .filter((card) => !seen.has(card.url) && Boolean(seen.add(card.url))) + // The pages that answer the question, or Mission and Vision, are read in full for detail. + const toRead = pagesToRead(cards, query) + const read = await Promise.allSettled( + toRead.map(async (page) => parsePageBody(await (await get(page.url)).text())) + ) + const bodies: Record = {} + toRead.forEach((page, i) => { + const result = read[i]! + if (result.status === 'fulfilled' && result.value) bodies[page.url] = result.value + }) + return { + site: { steps: parsePageSteps(html[0] ?? ''), pages: cards, bodies }, + failed: [ + ...OFFGRID_SITE_PAGES.filter((_, i) => pages[i]!.status === 'rejected').map( + (page) => `${page.name} (${page.url})` + ), + ...toRead.filter((_, i) => read[i]!.status === 'rejected').map((page) => page.url) + ] + } +} + +export const offgridGuideTool = { + name: 'offgrid_guide', + description: + "What Off Grid AI can do (free and Pro, desktop, mobile, browser extension), how to set it up and use it, and what is new: the quick start, guides, articles, perspectives and ethos on getoffgridai.co, plus the latest releases and community posts. Use when the user asks about Off Grid AI's features, how to do something in it, or what changed. Pass `query` to find the pages about one task and read the best of them in full. Reads public pages; requires network.", + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Optional: the task or topic to find pages about, e.g. "sync attachments"' + } + } + }, + run: async (args: Record = {}): Promise => { + const query = typeof args.query === 'string' ? args.query : '' + const [fromSite, news] = await Promise.all([ + site(query), + Promise.allSettled(SOURCES.map((s) => s.read())) + ]) + const failed = [ + ...fromSite.failed, + ...SOURCES.filter((_, i) => news[i]!.status === 'rejected').map((s) => s.name) + ] + const updates = news.flatMap((r) => (r.status === 'fulfilled' ? r.value : [])) + return formatGuide(newestUpdates(updates), failed, fromSite.site, query) + } +} diff --git a/src/main/tools/tool-ranking.ts b/src/main/tools/tool-ranking.ts index e3288829a..cea3d7213 100644 --- a/src/main/tools/tool-ranking.ts +++ b/src/main/tools/tool-ranking.ts @@ -35,12 +35,26 @@ const STOP = new Set([ 'please', 'show', 'get', - 'find' + 'find', + 'all', + 'tell', + 'any', + 'across' ]) -/** Lowercase word tokens, stop-words + 1-char noise removed. */ +/** One form for a word and its plural, so "emails" meets "email" and "calendars" meets + * "calendar". Deliberately small: no stemming library, only the endings that decide routing. */ +function singular(word: string): string { + if (word.length > 4 && word.endsWith('ies')) return `${word.slice(0, -3)}y` + if (word.length > 3 && word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1) + return word +} + +/** Lowercase word tokens, stop-words + 1-char noise removed, plurals folded. */ export function terms(s: string): string[] { - return (s.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter((w) => w.length > 1 && !STOP.has(w)) + return (s.toLowerCase().match(/[a-z0-9]+/g) ?? []) + .filter((w) => w.length > 1 && !STOP.has(w)) + .map(singular) } /** A tool's name + description, tolerant of both the OpenAI `{function:{…}}` wire diff --git a/src/main/transcription/select.ts b/src/main/transcription/select.ts index 536cd498e..6cb055086 100644 --- a/src/main/transcription/select.ts +++ b/src/main/transcription/select.ts @@ -153,6 +153,16 @@ export function getActiveTranscription( ) } } + return getLocalTranscription(readSetting) +} + +/** + * This machine's transcriber, never a remote server. For audio that must not leave the device + * whatever the user picked for transcription: features that listen all the time. + */ +export function getLocalTranscription( + readSetting: TranscriptionSettingReader = getSetting +): TranscriptionService { const active = getActiveModal('transcription') const engine = engineForActiveModel(active, modelsByKind('transcription')) const language = resolveConfiguredTranscriptionLanguage( diff --git a/src/main/tts.ts b/src/main/tts.ts index ed51a4b5c..5862c93f2 100644 --- a/src/main/tts.ts +++ b/src/main/tts.ts @@ -127,12 +127,14 @@ export async function prepareVoiceAssets( export async function synthesize( text: string, voice?: string, - onProgress?: (progress: DownloadProgress) => void + onProgress?: (progress: DownloadProgress) => void, + /** This machine's own voices, even when a remote voice is chosen (a WAV, always). */ + options: { readonly local?: boolean } = {} ): Promise<{ dataUrl: string }> { return recordAIRequest( { modality: 'tts', source: 'Speech synthesis', request: { text, voice } }, async (log) => { - const remote = getActiveRemoteVisionServerForModality('voice') + const remote = options.local ? null : getActiveRemoteVisionServerForModality('voice') if (remote) { log.update({ model: remote.selectedModel, backend: 'Remote' }) if (remote.provider !== 'openrouter') return synthesizeRemoteVoice(remote, text, voice) diff --git a/src/main/vision/__tests__/grounder-runtime.test.ts b/src/main/vision/__tests__/grounder-runtime.test.ts index d3aad72c5..cd97d84f3 100644 --- a/src/main/vision/__tests__/grounder-runtime.test.ts +++ b/src/main/vision/__tests__/grounder-runtime.test.ts @@ -1,114 +1,111 @@ -import { EventEmitter } from 'node:events' -vi.mock('../../llm/gpu-device-probe', async (importOriginal) => ({ - ...await importOriginal(), - gpuDeviceAvailable: vi.fn(async () => true) -})) -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { configureRuntime, dataDir, binRoots } from '../../runtime-env' -const mocks = vi.hoisted(() => ({ - artifact: vi.fn(), - exists: vi.fn(), - pickPort: vi.fn(), - reap: vi.fn(), - spawn: vi.fn() -})) +const previousDataDir = dataDir() +const previousBinRoots = binRoots() +const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-grounder-runtime-')) +const binaries = path.join(profile, 'bin') +const engine = path.join(binaries, 'llama-cpu', 'llama-server') +const modelId = 'bartowski/tencent_UI-Mate-9B-GGUF' +configureRuntime({ dataDir: profile, binRoots: [binaries] }) +const { GrounderRuntime } = await import('../grounder-runtime') +let runtime: InstanceType | undefined -vi.mock('node:child_process', async (importOriginal) => ({ - ...await importOriginal(), spawn: mocks.spawn -})) -vi.mock('node:fs', () => ({ default: { existsSync: mocks.exists } })) -vi.mock('../../llm/settings-math', () => ({ buildLaunchArgs: vi.fn(() => ['--ground']) })) -vi.mock('../../llm/spawn-env', () => ({ engineSpawnEnv: vi.fn(() => ({ TEST_ENGINE: '1' })) })) -vi.mock('../../runtime-env', () => ({ binRoots: () => ['/bundle'], exe: (name: string) => name })) -vi.mock('../../backend-preferences', () => ({ getBackendPreference: () => 'auto' })) -vi.mock('../../free-port', () => ({ isPortFree: vi.fn(), pickFreePort: mocks.pickPort })) -vi.mock('../../kill-orphan-port', () => ({ reapOrphanProcessesOnPort: mocks.reap })) -vi.mock('../../models-manager', () => ({ resolveComputerUseModelArtifact: mocks.artifact })) - -import { GrounderRuntime } from '../grounder-runtime' - -class FakeProcess extends EventEmitter { - exitCode: number | null = null - stderr = new EventEmitter() - killed: NodeJS.Signals[] = [] - - kill(signal: NodeJS.Signals): boolean { - this.killed.push(signal) - this.exitCode = 0 - this.emit('close', 0) - return true +// The native engine is an external boundary. Run a small process that implements +// its health and OpenAI HTTP contracts; keep model resolution, ports and shutdown real. +function writeEngine(fail = false): void { + fs.mkdirSync(path.dirname(engine), { recursive: true }) + fs.writeFileSync( + engine, + '#!/usr/bin/env node\n' + + (fail + ? "process.stderr.write('Metal allocation failed: out of memory\\n'); process.exit(1);\n" + : `const http = require('node:http'); +const args = process.argv.slice(2); +const port = Number(args[args.indexOf('--port') + 1]); +const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/health') return res.end(JSON.stringify({status: 'ok'})); + if (req.url === '/v1/chat/completions' && req.method === 'POST') { + req.resume(); + req.on('end', () => res.end(JSON.stringify({ + id: 'synthetic-grounding', object: 'chat.completion', + choices: [{index: 0, message: {role: 'assistant', content: 'Click the button.'}, finish_reason: 'stop'}] + }))); + return; } + res.statusCode = 404; res.end('{}'); +}); +server.listen(port, '127.0.0.1'); +process.on('SIGTERM', () => server.close(() => process.exit(0))); +`), + { mode: 0o755 } + ) } -beforeEach(() => { - mocks.artifact.mockResolvedValue({ - primaryPath: '/models/grounder.gguf', - projectorPath: '/models/mmproj.gguf' - }) - mocks.exists.mockImplementation((candidate: string) => !candidate.includes('llama-cuda')) - mocks.pickPort.mockResolvedValue(8490) - mocks.spawn.mockImplementation(() => new FakeProcess()) - vi.stubGlobal( - 'fetch', - vi.fn(async () => ({ ok: true })) - ) +beforeAll(() => { + const models = path.join(profile, 'models') + fs.mkdirSync(models) + for (const name of ['tencent_UI-Mate-9B-Q4_K_M.gguf', 'mmproj-tencent_UI-Mate-9B-f16.gguf']) { + fs.writeFileSync( + path.join(models, name), + Buffer.concat([Buffer.from('GGUF'), Buffer.alloc(2048)]) + ) + } }) -afterEach(() => vi.unstubAllGlobals()) +afterEach(async () => { + await runtime?.shutdown() + runtime = undefined +}) -describe('GrounderRuntime', () => { - it('starts one dedicated server, reuses it, and returns an OpenAI-compatible connection', async () => { - const runtime = new GrounderRuntime() - await expect(runtime.connection('offgrid/grounder')).resolves.toEqual({ - id: 'local-grounder:offgrid/grounder', - name: 'Local grounding specialist', - provider: 'custom', - endpoint: 'http://127.0.0.1:8490/v1', - model: 'offgrid/grounder', - apiKey: '' - }) - expect(runtime.running).toBe(true) - expect(mocks.reap).toHaveBeenCalled() - expect(mocks.spawn).toHaveBeenCalledWith( - '/bundle/llama/llama-server', - ['--ground'], - expect.objectContaining({ stdio: ['ignore', 'ignore', 'pipe'] }) - ) +afterAll(() => { + configureRuntime({ dataDir: previousDataDir, binRoots: previousBinRoots }) + fs.rmSync(profile, { recursive: true, force: true }) +}) - await runtime.connection('offgrid/grounder') - expect(mocks.spawn).toHaveBeenCalledTimes(1) - const child = mocks.spawn.mock.results[0]?.value as FakeProcess - await runtime.shutdown() - expect(child.killed).toEqual(['SIGTERM']) - expect(runtime.running).toBe(false) - }) +describe('GrounderRuntime', () => { + it.skipIf(process.platform === 'win32')( + 'serves grounding requests and closes its dedicated connection', + async () => { + writeEngine() + configureRuntime({ binRoots: [binaries] }) + runtime = new GrounderRuntime() + const connection = await runtime.connection(modelId) + expect(connection.model).toBe(modelId) + expect(runtime.running).toBe(true) + const response = await fetch(`${connection.endpoint}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: [{ role: 'user', content: 'Find the button.' }] }) + }) + expect(response.ok).toBe(true) + expect((await response.json()).choices[0].message.content).toBe('Click the button.') + expect((await runtime.connection(modelId)).endpoint).toBe(connection.endpoint) + await runtime.shutdown() + expect(runtime.running).toBe(false) + await expect(fetch(`${connection.endpoint}/models`)).rejects.toThrow() + } + ) - it('rejects missing models, exhausted ports, and missing engines', async () => { - const runtime = new GrounderRuntime() - mocks.artifact.mockResolvedValueOnce(null) + it('rejects missing models and missing engines', async () => { + configureRuntime({ binRoots: [path.join(profile, 'missing-bin')] }) + runtime = new GrounderRuntime() await expect(runtime.connection('missing')).rejects.toThrow('not installed') - - mocks.pickPort.mockResolvedValueOnce(null) - await expect(runtime.connection('no-port')).rejects.toThrow('No private port') - - mocks.exists.mockReturnValue(false) - await expect(runtime.connection('no-engine')).rejects.toThrow('engine is missing') + await expect(runtime.connection(modelId)).rejects.toThrow('engine is missing') }) - it('adds memory guidance when the server exits during startup', async () => { - mocks.spawn.mockImplementationOnce(() => { - const child = new FakeProcess() - queueMicrotask(() => { - child.stderr.emit('data', 'Metal allocation failed: out of memory') - child.exitCode = 1 - child.emit('close', 1) - }) - return child - }) - vi.mocked(fetch).mockRejectedValue(new Error('not ready')) - - await expect(new GrounderRuntime().connection('offgrid/grounder')).rejects.toThrow( - 'does not have enough free memory' - ) - }) + it.skipIf(process.platform === 'win32')( + 'reports memory guidance when the engine exits during startup', + async () => { + writeEngine(true) + configureRuntime({ binRoots: [binaries] }) + runtime = new GrounderRuntime() + await expect(runtime.connection(modelId)).rejects.toThrow('does not have enough free memory') + expect(runtime.running).toBe(false) + } + ) }) diff --git a/src/main/vision/__tests__/task-role-models.test.ts b/src/main/vision/__tests__/task-role-models.test.ts new file mode 100644 index 000000000..80c406e0e --- /dev/null +++ b/src/main/vision/__tests__/task-role-models.test.ts @@ -0,0 +1,40 @@ +// The models a strategy runs, two ways: the task projection (what Tasks and a run name) and the +// shared role map (what the Models screens mark active). They must name the same roles. +import { describe, expect, it } from 'vitest' +import { + getComputerUseActiveModelProjection, + type VisionTaskModelStrategyDependencies +} from '../vision-task-model-strategy' +import { + strategyTaskRoles, + type ComputerUseModelStrategy +} from '../../../shared/computer-use-settings' + +const STRATEGIES: ComputerUseModelStrategy[] = [ + 'same_as_chat', + 'separate_specialist', + 'text_plus_specialist', + 'decision_plus_specialist', + 'decision_plus_reasoning' +] + +const dependencies = (strategy: ComputerUseModelStrategy): VisionTaskModelStrategyDependencies => + ({ + strategy: () => strategy, + activeArtifacts: () => null, + activeRemote: () => null, + selectedChatId: () => 'chat/reasoner', + selectedSpecialistId: () => 'vision/specialist', + selectedDecisionId: () => 'decision/selector', + resolveIdentity: async (modelId: string) => ({ modelId, modelName: modelId }) + }) as unknown as VisionTaskModelStrategyDependencies + +describe('task role models', () => { + it.each(STRATEGIES)('%s: the projection and the role map name the same roles', async (s) => { + const projection = await getComputerUseActiveModelProjection(dependencies(s)) + const projected = projection.models + .filter((model) => model.role !== 'reasoner') + .map((model) => (model.role === 'grounding_specialist' ? 'grounding' : model.role)) + expect(projected).toEqual(strategyTaskRoles(s)) + }) +}) diff --git a/src/main/vision/__tests__/vision-controller.test.ts b/src/main/vision/__tests__/vision-controller.test.ts index d0e13f0f7..e36eaac27 100644 --- a/src/main/vision/__tests__/vision-controller.test.ts +++ b/src/main/vision/__tests__/vision-controller.test.ts @@ -80,6 +80,38 @@ describe('registerVisionIpc', () => { dispose() }) + it('stops a queued run as it starts when Stop came first, and lets a later Continue run', () => { + // Stopped while waiting for its turn: no session existed yet. + owner.markStoppedBeforeStart('queued-task') + const guard = new VisionGuard({ taskId: 'queued-task', kind: 'web_use' }) + const request = new AbortController() + const release = owner.registerSession('queued-task', guard, request) + expect(guard.isHalted).toBe(true) + expect(request.signal.aborted).toBe(true) + release() + + // Continue reuses the id: this run is not stopped. + const again = new VisionGuard({ taskId: 'queued-task', kind: 'web_use' }) + const next = new AbortController() + const releaseAgain = owner.registerSession('queued-task', again, next) + expect(again.isHalted).toBe(false) + expect(next.signal.aborted).toBe(false) + releaseAgain() + }) + + it('a Continue of a task stopped with no run waiting is not stopped by that Stop', () => { + // An orphaned task (from sync, or before a restart) stopped while nothing was queued for it. + owner.markStoppedBeforeStart('orphan-task') + // The user continues it: a new run under the same id. + owner.forgetStoppedBeforeStart('orphan-task') + const guard = new VisionGuard({ taskId: 'orphan-task', kind: 'web_use' }) + const request = new AbortController() + const release = owner.registerSession('orphan-task', guard, request) + expect(guard.isHalted).toBe(false) + expect(request.signal.aborted).toBe(false) + release() + }) + it('projects Web Use controls from the shared owner without creating Computer Use state', () => { const guard = new VisionGuard({ taskId: 'web-control-task', kind: 'web_use' }) const request = new AbortController() diff --git a/src/main/vision/grounder-loader.ts b/src/main/vision/grounder-loader.ts index f60a58611..f729b58dc 100644 --- a/src/main/vision/grounder-loader.ts +++ b/src/main/vision/grounder-loader.ts @@ -4,7 +4,7 @@ * resident llama-server, so grounding never replaces the reasoning model. The * exported swap runner remains as a pure lifecycle seam for existing replay tests. */ -import { getActiveModal } from '../active-models' +import { selectedGrounderModelId } from './grounder-selection' import { resolveGrounderPlan } from './grounder-plan' import { runRestoredModelSwap } from './grounder-swap' import { getComputerUseSettings } from '../computer-use-settings' @@ -16,16 +16,8 @@ import { } from '../actions/remote-screen-session' import { grounderRuntime } from './grounder-runtime' -const DEFAULT_GROUNDER_MODEL_ID = 'mradermacher/UI-TARS-1.5-7B-GGUF' - /** The saved Computer Use choice, or the current catalog default. */ -export function selectedGrounderModelId(): string { - return ( - getComputerUseSettings().groundingModelId ?? - getActiveModal('computer_use') ?? - DEFAULT_GROUNDER_MODEL_ID - ) -} +export { selectedGrounderModelId } export interface GrounderTiming { /** True when a grounder was already loaded, so no swap was paid. */ diff --git a/src/main/vision/grounder-runtime.ts b/src/main/vision/grounder-runtime.ts index d68e3213c..e5cd97adf 100644 --- a/src/main/vision/grounder-runtime.ts +++ b/src/main/vision/grounder-runtime.ts @@ -1,5 +1,6 @@ import { beginRuntimeBackend, parseNativeBackend } from '../runtime-backends' import { spawn, type ChildProcess } from 'node:child_process' +import fs from 'node:fs' import path from 'node:path' import { Mutex } from 'async-mutex' import { prepareModelMemory, registerModelEvictor } from '../model-memory' @@ -11,6 +12,9 @@ import { getBackendPreference } from '../backend-preferences' import { isPortFree, pickFreePort } from '../free-port' import { reapOrphanProcessesOnPort } from '../kill-orphan-port' import { resolveComputerUseModelArtifact } from '../models-manager' +import { llm } from '../llm' +import { readGgufMtpSupport } from '../models/gguf-metadata' +import { MTP_COMPANIONS } from '../models/catalog-logic' const GROUNDER_PORT = 8489 @@ -25,6 +29,7 @@ export class GrounderRuntime { private backend: string | undefined private backendState?: ReturnType private readonly mutex = new Mutex() + private speculativeSettingsKey = '' get running(): boolean { return this.process !== null && this.process.exitCode === null @@ -33,7 +38,10 @@ export class GrounderRuntime { async connection(modelId: string): Promise { await prepareModelMemory('grounding') await this.mutex.runExclusive(async () => { - if (!this.running || this.modelId !== modelId) await this.start(modelId) + const settings = llm.getSettings() + const speculativeSettingsKey = `${settings.speculativeDecoding}:${settings.speculativeDraftMax ?? 0}` + if (!this.running || this.modelId !== modelId || this.speculativeSettingsKey !== speculativeSettingsKey) + await this.start(modelId) this.backendState?.recordRequest() }) return { @@ -61,6 +69,15 @@ export class GrounderRuntime { const serverPath = await selectLocalEngine(getBackendPreference('grounding')) if (!serverPath) throw new Error('The bundled grounding engine is missing.') + const settings = llm.getSettings() + const companion = MTP_COMPANIONS['bartowski/tencent_UI-Mate-9B-GGUF+MTP'] + const companionPath = path.join(path.dirname(artifact.primaryPath), companion.name) + const matchingCompanion = /^tencent_UI-Mate-9B-(?:Q|IQ)[A-Z0-9_]+(?:\+MTP)?\.gguf$/i.test(path.basename(artifact.primaryPath)) && + readGgufMtpSupport(companionPath, fs, undefined, companion.sizeBytes) + const useMtp = settings.speculativeDecoding === 'mtp' && + (readGgufMtpSupport(artifact.primaryPath, fs) || matchingCompanion) + this.speculativeSettingsKey = `${settings.speculativeDecoding}:${settings.speculativeDraftMax ?? 0}` + this.port = port this.modelId = modelId this.stderr = '' @@ -78,7 +95,9 @@ export class GrounderRuntime { gpuLayers: getBackendPreference('grounding') === 'cpu' ? 0 : 99, flashAttn: true, kvCacheType: 'q8_0', - speculativeDecoding: 'off', + speculativeDecoding: useMtp ? 'mtp' : 'off', + speculativeDraftMax: settings.speculativeDraftMax, + draftModelPath: useMtp && matchingCompanion ? companionPath : undefined, threads: undefined, batchSize: 1_024, imageMinTokens: 1_024, diff --git a/src/main/vision/grounder-selection.ts b/src/main/vision/grounder-selection.ts new file mode 100644 index 000000000..34406eeea --- /dev/null +++ b/src/main/vision/grounder-selection.ts @@ -0,0 +1,16 @@ +// Which grounding specialist Web Use and Computer Use share: the Tasks choice, else the Computer +// Use pick from the Models screen, else the default. Kept apart from the grounder runtime so the +// model manager can read it without loading the runtime. + +import { getActiveModal } from '../active-models' +import { getComputerUseSettings } from '../computer-use-settings' + +export const DEFAULT_GROUNDER_MODEL_ID = 'mradermacher/UI-TARS-1.5-7B-GGUF' + +export function selectedGrounderModelId(): string { + return ( + getComputerUseSettings().groundingModelId ?? + getActiveModal('computer_use') ?? + DEFAULT_GROUNDER_MODEL_ID + ) +} diff --git a/src/main/vision/vision-controller.ts b/src/main/vision/vision-controller.ts index 9fa8e367c..47e5873d3 100644 --- a/src/main/vision/vision-controller.ts +++ b/src/main/vision/vision-controller.ts @@ -121,6 +121,10 @@ export class VisionController { session.deadline.unref() } this.sessions.set(taskId, session) + // Stopped while it waited to start: it ends here, through the same stop as a running task. + if (this.stoppedBeforeStart.delete(taskId)) { + this.stop(taskId, 'stopped before it started', 'Stopped before it started') + } return () => { if (this.sessions.get(taskId) !== session) return if (session.deadline) clearTimeout(session.deadline) @@ -197,6 +201,21 @@ export class VisionController { } } + /** Tasks stopped while queued, before any run registered: their run must not start. */ + private readonly stoppedBeforeStart = new Set() + + /** A Stop that found no run in progress but did stop the task: the run already on its way, if + * one comes, is stopped as it registers. A Continue clears it first (forgetStoppedBeforeStart), + * so only a run that was waiting when Stop came can be stopped by it. */ + markStoppedBeforeStart(taskId: string): void { + this.stoppedBeforeStart.add(taskId) + } + + /** A deliberate Continue starts a new run of the task: no earlier Stop carries over to it. */ + forgetStoppedBeforeStart(taskId: string): void { + this.stoppedBeforeStart.delete(taskId) + } + hasActiveSession(taskId: string): boolean { return this.sessions.has(taskId) } @@ -331,6 +350,17 @@ export function registerVisionSession( return controller.registerSession(taskId, guard, request, project, sessionLimitMs) } +/** Stop a task that has been accepted but has not started (queued, or waiting for approval): + * it is stopped the moment its run begins. */ +export function stopVisionTaskBeforeStart(taskId: string): void { + controller.markStoppedBeforeStart(taskId) +} + +/** Continue is starting a new run of `taskId`: it must not inherit a Stop meant for an earlier one. */ +export function forgetVisionStopBeforeStart(taskId: string): void { + controller.forgetStoppedBeforeStart(taskId) +} + export function emitVisionStep(taskId: string, note: string): void { controller.emitStep(taskId, note) } @@ -367,11 +397,16 @@ export function controlVisionTask( command: 'stop' | 'pause' | 'takeover' | 'resume', taskId: string ): boolean { + const running = controller.hasActiveSession(taskId) const controlled = controller.control(command, taskId) // A Web Use process cannot survive an app restart. Its durable row can arrive // after startup recovery through sync, so Stop must also close that local row // when no browser controller remains in memory. const stoppedOrphan = command === 'stop' ? stopOrphanedLocalWebTask(taskId) : false + // Stopped before its run began (still queued): that run must not start later. + if (command === 'stop' && !running && (controlled || stoppedOrphan)) { + controller.markStoppedBeforeStart(taskId) + } return controlled || stoppedOrphan } diff --git a/src/main/vision/vision-task-model-strategy.ts b/src/main/vision/vision-task-model-strategy.ts index 2cf7ab7c6..c869d6c2b 100644 --- a/src/main/vision/vision-task-model-strategy.ts +++ b/src/main/vision/vision-task-model-strategy.ts @@ -1,3 +1,4 @@ +import { taskRolesView, type TaskRolesView } from '../../shared/task-roles-view' import { llm } from '../llm' import { getComputerUseSettings } from '../computer-use-settings' import { getWebUseSettings } from '../web-use-settings' @@ -216,6 +217,15 @@ export function getWebUseActiveModelProjection(): Promise { + const [computerUse, webUse] = await Promise.all([ + getComputerUseActiveModelProjection(), + getWebUseActiveModelProjection() + ]) + return taskRolesView({ computerUse, webUse }) +} + function activeChatSelection( dependencies: VisionTaskModelStrategyDependencies ): VisionModelSelection { diff --git a/src/main/web-use-settings.ts b/src/main/web-use-settings.ts index 1b30b06ea..e3c5c008f 100644 --- a/src/main/web-use-settings.ts +++ b/src/main/web-use-settings.ts @@ -1,4 +1,5 @@ import { getSetting, saveSetting } from './database' +import { announceTaskSettings } from './task-settings-events' import { DEFAULT_WEB_USE_SETTINGS, WEB_USE_SETTINGS_KEY, @@ -19,6 +20,7 @@ export function setWebUseSettings( ): WebUseSettings { const normalized = normalizeWebUseSettings(value) saveSetting(WEB_USE_SETTINGS_KEY, normalized) + announceTaskSettings(WEB_USE_SETTINGS_KEY, normalized) if (options.emitSync !== false) { emitSyncMutation({ entity: CORE_SYNC_ENTITIES.modelSetting, diff --git a/src/preload/index.ts b/src/preload/index.ts index fa8489b8a..f7f0f34f1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -196,6 +196,15 @@ const offGridApi = { return unsubscribe(channel, sub) }, proOff: (channel: string) => ipcRenderer.removeAllListeners(channel), + /** Web Use or Computer Use settings changed, here or from a paired browser. */ + onTaskSettingsChanged: ( + callback: (change: { key: string; value: unknown }) => void + ): (() => void) => { + const listener = (_event: unknown, change: { key: string; value: unknown }): void => + callback(change) + ipcRenderer.on('task-settings:changed', listener) + return unsubscribe('task-settings:changed', listener) + }, // Loopback HTTP URL for seekable local media (meeting recordings) —