diff --git a/CHANGELOG.md b/CHANGELOG.md index 5909c9899..208b64a8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,136 +2,62 @@ Docs: https://docs.molt.bot -## 2026.1.27-beta.1 +## 2026.1.29 Status: beta. +### Highlights +- Rebrand: rename the npm package/CLI to `moltbot`, keep a `moltbot` compatibility shim, move extensions to the `@moltbot/*` scope, and update bot.molt bundle IDs/labels/logging subsystems. Thanks @thewilloftheshadow. +- New channels/plugins: Twitch plugin; Google Chat (beta) with Workspace Add-on events + typing indicator. (#1612, #1635) Thanks @tyler6204, @iHildy. +- Security hardening: gateway auth defaults required, hook token query-param deprecation, Windows ACL audits, mDNS minimal discovery, and SSH target option injection fix. (#4001, #2016, #1957, #1882, #2200) +- WebChat: image paste + image-only sends; keep sub-agent announce replies visible. (#1925, #1977) +- Tooling: per-sender group tool policies + tools.alsoAllow additive allowlist. (#1757, #1762) +- Memory Search: allow extra paths for memory indexing. (#3600) Thanks @kira-ariaki. + ### Changes -- Rebrand: rename the npm package/CLI to `moltbot`, add a `moltbot` compatibility shim, and move extensions to the `@moltbot/*` scope. +- Providers: add Venice AI integration; update Moonshot Kimi references to kimi-k2.5; update MiniMax API endpoint/format. (#2762, #3064) +- Providers: add Xiaomi MiMo (mimo-v2-flash) support and onboarding flow. (#3454) Thanks @WqyJh. +- Telegram: quote replies, edit-message action, silent sends, sticker support + vision caching, linkPreview toggle, plugin sendPayload support. (#2900, #2394, #2382, #2548, #1700, #1917) +- Discord: configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro. +- Browser: route browser control via gateway/node; fallback URL matching for relay targets. (#1999) +- macOS: add direct gateway transport; preserve custom SSH usernames for remote control; bump Textual to 0.3.1. (#2033, #2046) +- Routing: add per-account DM session scope + guidance for multi-account setups. (#3095) Thanks @jarvis-sam. +- Hooks: make session-memory message count configurable. (#2681) +- Tools: honor tools.exec.safeBins in exec allowlist checks. (#2281) +- Security: add Control UI device auth bypass flag + audit warnings; warn on hook tokens via query params; add security audit CLI surface. (#2248, #2200) +- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. +- Config: apply config.env before ${VAR} substitution. (#1813) +- Web search: add Brave freshness filter parameter. (#1688) Thanks @JonUleis. +- Control UI: improve chat session dropdown refresh, URL confirmation flow, config-save guardrails, and chat composer sizing. (#3682, #3578, #1707, #2950) - Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev. -- macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk). -- macOS: finish Moltbot app rename for macOS sources, bundle identifiers, and shared kit paths. (#2844) Thanks @fal3. -- Branding: update launchd labels, mobile bundle IDs, and logging subsystems to bot.molt (legacy com.clawdbot migrations). Thanks @thewilloftheshadow. -- Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt. +- CLI: use Node's compile cache for faster startup; recognize versioned node binaries (e.g., node-22). (#2808, #2490) Thanks @pi0, @David-Marsh-Photo. - Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47. - Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204. -- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281) - Matrix: switch plugin SDK to @vector-im/matrix-bot-sdk. -- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido. -- Docs: add migration guide for moving to a new machine. (#2381) -- Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN. -- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos. -- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248) -- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. -- Config: auto-migrate legacy state/config paths and keep config resolution consistent across legacy filenames. -- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro. -- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng. -- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr. -- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger. -- Docs: add Render deployment guide. (#1975) Thanks @anurag. -- Docs: add Claude Max API Proxy guide. (#1875) Thanks @atalovesyou. -- Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto. -- Docs: add Oracle Cloud (OCI) platform guide + cross-links. (#2333) Thanks @hirefrank. -- Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto. -- Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev. -- Docs: add LINE channel guide. Thanks @thewilloftheshadow. -- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD. -- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub. -- Onboarding: strengthen security warning copy for beta + access control expectations. -- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a. -- Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst. -- Web UI: keep sub-agent announce replies visible in WebChat. (#1977) Thanks @andrescardonas7. -- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi. -- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn. -- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev. -- Browser: route browser control via gateway/node; remove standalone browser control command and control URL config. -- Browser: route `browser.request` via node proxies when available; honor proxy timeouts; derive browser ports from `gateway.port`. -- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg. -- Build: bundle A2UI assets during build and stop tracking generated bundles. (#2455) Thanks @0oAstro. -- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra. -- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon. -- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco. -- Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99. -- Docs: update exe.dev install instructions. (#https://github.com/moltbot/moltbot/pull/3047) Thanks @zackerthescar. -- Security: use Windows ACLs for permission audits and fixes on Windows. (#1957) -- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla. -- Routing: precompile session key regexes. (#1697) Thanks @Ray0907. -- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein. -- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc. -- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma. -- Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21. -- Telegram: support quote replies for message tool and inbound context. (#2900) Thanks @aduk059. -- Telegram: add sticker receive/send with vision caching. (#2629) Thanks @longjos. -- Telegram: send sticker pixels to vision models. (#2650) -- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918. -- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999. -- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal. -- CLI: use Node's module compile cache for faster startup. (#2808) Thanks @pi0. -- Routing: add per-account DM session scope and document multi-account isolation. (#3095) Thanks @jarvis-sam. +- Docs: new deployment guides (Northflank, Render, Oracle, Raspberry Pi, GCP, DigitalOcean), Claude Max API Proxy, Vercel AI Gateway, migration guide, formal verification updates, and Fly private hardening. (#2167, #1975, #2333, #1871, #1848, #1870, #1875, #1901, #2381, #2289) +- Onboarding: add Venice API key to non-interactive flow; strengthen security warning copy. ### Breaking - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### Fixes -- Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald. -- Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald. -- Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma. -- Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94. -- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355. -- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee. -- Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101. -- Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops. -- Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky. -- Discord: stop resolveDiscordTarget from passing directory params into messaging target parsers. Fixes #3167. Thanks @thewilloftheshadow. -- Discord: avoid resolving bare channel names to user DMs when a username matches. Thanks @thewilloftheshadow. -- Discord: fix directory config type import for target resolution. Thanks @thewilloftheshadow. -- Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb. -- Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent. -- Discord: resolve usernames to user IDs for outbound messages. (#2649) Thanks @nonggialiang. -- Providers: update Moonshot Kimi model references to kimi-k2.5. (#2762) Thanks @MarvinCui. -- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg. -- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg. -- Security: pin npm overrides to keep tar@7.5.4 for install toolchains. -- Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez. -- CLI: recognize versioned Node executables when parsing argv. (#2490) Thanks @David-Marsh-Photo. -- CLI: avoid prompting for gateway runtime under the spinner. (#2874) -- BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204. -- Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein. -- CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481. -- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. -- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai. -- Agents: skip cooldowned providers during model failover. (#2143) Thanks @YiWang24. -- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss. -- Telegram: ignore non-forum group message_thread_id while preserving DM thread sessions. (#2731) Thanks @dylanneve1. -- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. -- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne. -- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. -- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. -- Build: align memory-core peer dependency with lockfile. -- Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie. -- Security: harden URL fetches with DNS pinning to reduce rebinding risk. Thanks Chris Zheng. -- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93. -- Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0. -- Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed). -- Gateway: treat loopback + non-local Host connections as remote unless trusted proxy headers are present. -- Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags. +- Security: harden SSH tunnel target parsing to prevent option injection/DoS. (#4001) Thanks @YLChen-007. +- Security: prevent PATH injection in exec sandbox; harden file serving; pin DNS in URL fetches; verify Twilio webhooks; fix LINE webhook timing-attack edge case; validate Tailscale Serve identity; flag loopback Control UI with auth disabled as critical. (#1616, #1795) +- Gateway: prevent crashes on transient network errors, suppress AbortError/unhandled rejections, sanitize error responses, clean session locks on exit, and harden reverse proxy handling for unauthenticated proxied connects. (#2980, #2451, #2483, #1795) +- Config: auto-migrate legacy state/config paths; honor state dir overrides. +- Packaging: include missing dist/shared and dist/link-understanding outputs in npm tarball installs. +- Telegram: avoid silent empty replies, improve polling/network recovery, handle video notes, keep DM thread sessions, ignore non-forum message_thread_id, centralize API error logging, include AccountId in native command context. (#3796, #3013, #2905, #2731, #2492, #2942) +- Discord: restore username resolution, resolve outbound usernames to IDs, honor threadId replies, guard forum thread access. (#3131, #2649) +- BlueBubbles: coalesce URL link previews, improve reaction handling, preserve reply-tag GUIDs. (#1981, #1641) +- Voice Call: prevent TTS overlap, validate env-var config, return TwiML for conversation calls. (#1713, #1634) +- Media: fix text attachment MIME classification + XML escaping on Windows. (#3628, #3750) +- Models: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94. +- Web UI: auto-scroll on send; fix textarea sizing; improve chat session refresh. (#2471, #2950, #3682) +- CLI/TUI: resume sessions cleanly; guard width overflow; avoid spinner prompt race. (#1921, #1686, #2874) +- Slack: fix file downloads failing on redirects with missing auth header. (#1936) +- iMessage: normalize messaging targets. (#1708) +- Signal: fix reactions and add configurable startup timeout. (#1651, #1677) +- Matrix: decrypt E2EE media with size guard. (#1744) -## 2026.1.24-3 - -### Fixes -- Slack: fix image downloads failing due to missing Authorization header on cross-origin redirects. (#1936) Thanks @sanderhelgesen. -- Gateway: harden reverse proxy handling for local-client detection and unauthenticated proxied connects. (#1795) Thanks @orlyjamie. -- Security audit: flag loopback Control UI with auth disabled as critical. (#1795) Thanks @orlyjamie. -- CLI: resume claude-cli sessions and stream CLI replies to TUI clients. (#1921) Thanks @rmorse. - -## 2026.1.24-2 - -### Fixes -- Packaging: include dist/link-understanding output in npm tarball (fixes missing apply.js import on install). - -## 2026.1.24-1 - -### Fixes -- Packaging: include dist/shared output in npm tarball (fixes missing reasoning-tags import on install). ## 2026.1.24 diff --git a/README.md b/README.md index 7e884be33..ec970bb5b 100644 --- a/README.md +++ b/README.md @@ -479,36 +479,38 @@ Thanks to all clawtributors:

steipete plum-dawg bohdanpodvirnyi iHildy jaydenfyi joaohlisboa mneves75 MatthieuBizien MaudeBot Glucksberg - rahthakor vrknetha radek-paclt Tobias Bischoff joshp123 vignesh07 czekaj mukhtharcm sebslight maxsumrall - xadenryan rodrigouroz juanpablodlc hsrvc magimetal zerone0x meaningfool tyler6204 patelhiren NicholasSpisak - jonisjongithub abhisekbasu1 jamesgroat claude JustYannicc Hyaxia dantelex SocialNerd42069 daveonkels google-labs-jules[bot] - lc0rp mousberg adam91holt hougangdev gumadeiras mteam88 hirefrank joeynyc orlyjamie dbhurley - Mariano Belinky Eng. Juan Combetto TSavo julianengel bradleypriest benithors rohannagpal timolins f-trycua benostein - nachx639 shakkernerd pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b - cpojer scald thewilloftheshadow andranik-sahakyan davidguttman sleontenko denysvitali sircrumpet peschee rafaelreis-r - dominicnunez ratulsarna lutr0 danielz1z AdeboyeDN Alg0rix papago2355 emanuelst KristijanJovanovski rdev - rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams sheeek artuskg Takhoffman onutc - pauloportella neooriginal manuelhettich minghinmatthewlam myfunc travisirby buddyh connorshea kyleok obviyus - mcinteerj dependabot[bot] John-Rood timkrase uos-status gerardward2007 roshanasingh4 tosh-hamburg azade-c dlauer - JonUleis bjesuiter cheeeee robbyczgw-cla Josh Phillips YuriNachos pookNast Whoaa512 chriseidhof ngutman - ysqander aj47 kennyklee superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] damoahdominic - dan-dr HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse - dougvk erikpr1994 fal3 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr - neist sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 - manmal ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh svkozak VACInc wes-davis - zats 24601 ameno- Chris Taylor dguido Django Navarro evalexpr henrino3 humanwritten larlyssa - odysseus0 oswalpalash pcty-nextgen-service-account rmorse Syhids Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot - Clawd ClawdFx EnzeD erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira jayhickey - jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan mjrussell odnxe p6l-richard philipp-spiess Pocket Clawd - robaxelsen Sash Catanzarite T5-AndyML travisp VAC william arzt zknicker 0oAstro abhaymundhara alejandro maza - Alex-Alaniz alexstyl andrewting19 anpoirier arthyn Asleep123 bolismauro chenyuan99 Clawdbot Maintainers conhecendoia - dasilva333 David-Marsh-Photo Developer Dimitrios Ploutarchos Drake Thomsen Felix Krause foeken ganghyun kim grrowl gtsifrikas - HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jane Jarvis Jefferson Nunn jogi47 kentaro Kevin Lin - kitze Kiwitwitter levifig Lloyd longjos loukotal louzhixian martinpucik Matt mini mertcicekci0 - Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro ppamment prathamdby ptn1411 - reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai - siraht snopoke Suksham-sharma techboss testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin - Wimmie wolfred wstock yazinsai YiWang24 ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee - atalovesyou Azade carlulsoe ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder - Quentin Randy Torres rhjoh ronak-guliani William Stock + rahthakor vrknetha radek-paclt vignesh07 joshp123 Tobias Bischoff czekaj mukhtharcm sebslight maxsumrall + xadenryan rodrigouroz juanpablodlc tyler6204 hsrvc magimetal zerone0x meaningfool patelhiren NicholasSpisak + jonisjongithub abhisekbasu1 jamesgroat claude JustYannicc SocialNerd42069 Mariano Belinky Hyaxia dantelex daveonkels + google-labs-jules[bot] lc0rp adam91holt mousberg hougangdev shakkernerd gumadeiras mteam88 hirefrank joeynyc + orlyjamie Eng. Juan Combetto dbhurley TSavo julianengel bradleypriest benithors rohannagpal elliotsecops timolins + benostein f-trycua nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat + petter-b thewilloftheshadow cpojer scald andranik-sahakyan davidguttman sleontenko denysvitali sircrumpet peschee + rafaelreis-r nonggialiang dominicnunez lploc94 ratulsarna lutr0 kiranjd danielz1z AdeboyeDN Alg0rix + papago2355 emanuelst KristijanJovanovski CashWilliams rdev rhuanssauro osolmaz joshrad-dev adityashaw2 sheeek + ryancontent artuskg Takhoffman onutc pauloportella HirokiKobayashi-R neooriginal obviyus manuelhettich minghinmatthewlam + myfunc travisirby buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood timkrase uos-status + gerardward2007 roshanasingh4 tosh-hamburg azade-c dlauer JonUleis shivamraut101 bjesuiter cheeeee robbyczgw-cla + conroywhitney Josh Phillips YuriNachos pookNast Whoaa512 chriseidhof ngutman ysqander aj47 kennyklee + superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy imfing + jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse dougvk erikpr1994 fal3 + Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl chrisrodz + Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 manmal ogulcancelik pasogott + petradonka rubyrunsstuff siddhantjain suminhthanh svkozak VACInc wes-davis zats 24601 ameno- + Chris Taylor dguido Django Navarro evalexpr henrino3 humanwritten larlyssa Lukavyi odysseus0 oswalpalash + pcty-nextgen-service-account pi0 rmorse Roopak Nijhara Syhids Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot + Clawd ClawdFx EnzeD erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira Jarvis + jayhickey jeffersonwarrior jeffersonwarrior jverdi longmaba MarvinCui mickahouan mjrussell odnxe p6l-richard + philipp-spiess Pocket Clawd robaxelsen Sash Catanzarite Suksham-sharma T5-AndyML tewatia travisp VAC william arzt + zknicker 0oAstro abhaymundhara aduk059 alejandro maza Alex-Alaniz alexstyl andrewting19 anpoirier araa47 + arthyn Asleep123 bguidolim bolismauro chenyuan99 Chloe-VP Clawdbot Maintainers conhecendoia dasilva333 David-Marsh-Photo + Developer Dimitrios Ploutarchos Drake Thomsen dylanneve1 Felix Krause foeken frankekn ganghyun kim grrowl gtsifrikas + HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jane Jarvis Deploy Jefferson Nunn jogi47 kentaro Kevin Lin + kira-ariaki kitze Kiwitwitter levifig Lloyd longjos loukotal louzhixian martinpucik Matt mini + mertcicekci0 Miles mrdbstn MSch Mustafa Tag Eldeen mylukin ndraiman nexty5870 Noctivoro ppamment + prathamdby ptn1411 reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical + shiv19 shiyuanhai siraht snopoke techboss testingabc321 The Admiral thesash Ubuntu voidserf + Vultr-Clawd Admin Wimmie wolfred wstock YangHuang2280 yazinsai YiWang24 ymat19 Zach Knickerbocker zackerthescar + 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik latitudeki5223 Manuel Maly + Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock

diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 3ddcb3b81..97f9a250e 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "bot.molt.android" minSdk = 31 targetSdk = 36 - versionCode = 202601260 - versionName = "2026.1.27-beta.1" + versionCode = 202601290 + versionName = "2026.1.29" } buildTypes { diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index d3e398ab4..fe6a6154f 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.1.27-beta.1 + 2026.1.29 CFBundleVersion - 20260126 + 20260129 NSAppTransportSecurity NSAllowsArbitraryLoadsInWebContent diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index a5336c6ad..847ca3b01 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,8 +17,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.1.27-beta.1 + 2026.1.29 CFBundleVersion - 20260126 + 20260129 diff --git a/apps/ios/project.yml b/apps/ios/project.yml index a6728cd98..1d31171b9 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -81,8 +81,8 @@ targets: properties: CFBundleDisplayName: Moltbot CFBundleIconName: AppIcon - CFBundleShortVersionString: "2026.1.27-beta.1" - CFBundleVersion: "20260126" + CFBundleShortVersionString: "2026.1.29" + CFBundleVersion: "20260129" UILaunchScreen: {} UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false @@ -130,5 +130,5 @@ targets: path: Tests/Info.plist properties: CFBundleDisplayName: MoltbotTests - CFBundleShortVersionString: "2026.1.27-beta.1" - CFBundleVersion: "20260126" + CFBundleShortVersionString: "2026.1.29" + CFBundleVersion: "20260129" diff --git a/apps/macos/Sources/Moltbot/Resources/Info.plist b/apps/macos/Sources/Moltbot/Resources/Info.plist index 0c0de8b9e..623ae02b0 100644 --- a/apps/macos/Sources/Moltbot/Resources/Info.plist +++ b/apps/macos/Sources/Moltbot/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.1.27-beta.1 + 2026.1.29 CFBundleVersion - 202601260 + 202601290 CFBundleIconFile Moltbot CFBundleURLTypes diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 6fd30c751..b6ae260ce 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -125,7 +125,7 @@ the prefix (use `""` to remove it). - **DM policy**: `channels.whatsapp.dmPolicy` controls direct chat access (default: `pairing`). - Pairing: unknown senders get a pairing code (approve via `moltbot pairing approve whatsapp `; codes expire after 1 hour). - Open: requires `channels.whatsapp.allowFrom` to include `"*"`. - - Self messages are always allowed; “self-chat mode” still requires `channels.whatsapp.allowFrom` to include your own number. + - Your linked WhatsApp number is implicitly trusted, so self messages skip ⁠`channels.whatsapp.dmPolicy` and `channels.whatsapp.allowFrom` checks. ### Personal-number mode (fallback) If you run Moltbot on your **personal WhatsApp number**, enable `channels.whatsapp.selfChatMode` (see sample above). diff --git a/docs/cli/memory.md b/docs/cli/memory.md index 3dc79932f..513b7ef07 100644 --- a/docs/cli/memory.md +++ b/docs/cli/memory.md @@ -39,3 +39,4 @@ Notes: - `memory status --deep` probes vector + embedding availability. - `memory status --deep --index` runs a reindex if the store is dirty. - `memory index --verbose` prints per-phase details (provider, model, sources, batch activity). +- `memory status` includes any extra paths configured via `memorySearch.extraPaths`. diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index 8a386aba9..f2bca461a 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -75,8 +75,9 @@ For the full compaction lifecycle, see ## Vector memory search -Moltbot can build a small vector index over `MEMORY.md` and `memory/*.md` so -semantic queries can find related notes even when wording differs. +Moltbot can build a small vector index over `MEMORY.md` and `memory/*.md` (plus +any extra directories or files you opt in) so semantic queries can find related +notes even when wording differs. Defaults: - Enabled by default. @@ -96,6 +97,27 @@ embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or `models.providers.google.apiKey`. When using a custom OpenAI-compatible endpoint, set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`). +### Additional memory paths + +If you want to index Markdown files outside the default workspace layout, add +explicit paths: + +```json5 +agents: { + defaults: { + memorySearch: { + extraPaths: ["../team-docs", "/srv/shared-notes/overview.md"] + } + } +} +``` + +Notes: +- Paths can be absolute or workspace-relative. +- Directories are scanned recursively for `.md` files. +- Only Markdown files are indexed. +- Symlinks are ignored (files or directories). + ### Gemini embeddings (native) Set the provider to `gemini` to use the Gemini embeddings API directly: @@ -189,14 +211,14 @@ Local mode: ### How the memory tools work - `memory_search` semantically searches Markdown chunks (~400 token target, 80-token overlap) from `MEMORY.md` + `memory/**/*.md`. It returns snippet text (capped ~700 chars), file path, line range, score, provider/model, and whether we fell back from local → remote embeddings. No full file payload is returned. -- `memory_get` reads a specific memory Markdown file (workspace-relative), optionally from a starting line and for N lines. Paths outside `MEMORY.md` / `memory/` are rejected. +- `memory_get` reads a specific memory Markdown file (workspace-relative), optionally from a starting line and for N lines. Paths outside `MEMORY.md` / `memory/` are allowed only when explicitly listed in `memorySearch.extraPaths`. - Both tools are enabled only when `memorySearch.enabled` resolves true for the agent. ### What gets indexed (and when) -- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`). +- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`, plus any `.md` files under `memorySearch.extraPaths`). - Index storage: per-agent SQLite at `~/.clawdbot/memory/.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token). -- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync is scheduled on session start, on search, or on an interval and runs asynchronously. Session transcripts use delta thresholds to trigger background sync. +- Freshness: watcher on `MEMORY.md`, `memory/`, and `memorySearch.extraPaths` marks the index dirty (debounce 1.5s). Sync is scheduled on session start, on search, or on an interval and runs asynchronously. Session transcripts use delta thresholds to trigger background sync. - Reindex triggers: the index stores the embedding **provider/model + endpoint fingerprint + chunking params**. If any of those change, Moltbot automatically resets and reindexes the entire store. ### Hybrid search (BM25 + vector) diff --git a/docs/docs.json b/docs/docs.json index a463479aa..389adbe51 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -69,6 +69,14 @@ "source": "/minimax/", "destination": "/providers/minimax" }, + { + "source": "/xiaomi", + "destination": "/providers/xiaomi" + }, + { + "source": "/xiaomi/", + "destination": "/providers/xiaomi" + }, { "source": "/openai", "destination": "/providers/openai" diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index 11ac14337..470689673 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -267,7 +267,8 @@ Save to `~/.clawdbot/moltbot.json` and you can DM the bot from that number. model: "gemini-embedding-001", remote: { apiKey: "${GEMINI_API_KEY}" - } + }, + extraPaths: ["../team-docs", "/srv/shared-notes"] }, sandbox: { mode: "non-main", diff --git a/docs/platforms/fly.md b/docs/platforms/fly.md index d8db124ac..02d24e84a 100644 --- a/docs/platforms/fly.md +++ b/docs/platforms/fly.md @@ -185,7 +185,7 @@ cat > /data/moltbot.json << 'EOF' "bind": "auto" }, "meta": { - "lastTouchedVersion": "2026.1.27-beta.1" + "lastTouchedVersion": "2026.1.29" } } EOF diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 237eac616..5be621bba 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -30,17 +30,17 @@ Notes: # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.1.27-beta.1 \ +APP_VERSION=2026.1.29 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-app.sh # Zip for distribution (includes resource forks for Sparkle delta support) -ditto -c -k --sequesterRsrc --keepParent dist/Moltbot.app dist/Moltbot-2026.1.27-beta.1.zip +ditto -c -k --sequesterRsrc --keepParent dist/Moltbot.app dist/Moltbot-2026.1.29.zip # Optional: also build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/Moltbot.app dist/Moltbot-2026.1.27-beta.1.dmg +scripts/create-dmg.sh dist/Moltbot.app dist/Moltbot-2026.1.29.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: @@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Moltbot.app dist/Moltbot-2026.1.27-beta.1.dmg # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=moltbot-notary \ BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.1.27-beta.1 \ +APP_VERSION=2026.1.29 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-dist.sh # Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/Moltbot.app.dSYM dist/Moltbot-2026.1.27-beta.1.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/Moltbot.app.dSYM dist/Moltbot-2026.1.29.dSYM.zip ``` ## Appcast entry Use the release note generator so Sparkle renders formatted HTML notes: ```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Moltbot-2026.1.27-beta.1.zip https://raw.githubusercontent.com/moltbot/moltbot/main/appcast.xml +SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Moltbot-2026.1.29.zip https://raw.githubusercontent.com/moltbot/moltbot/main/appcast.xml ``` Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/moltbot/moltbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing. ## Publish & verify -- Upload `Moltbot-2026.1.27-beta.1.zip` (and `Moltbot-2026.1.27-beta.1.dSYM.zip`) to the GitHub release for tag `v2026.1.27-beta.1`. +- Upload `Moltbot-2026.1.29.zip` (and `Moltbot-2026.1.29.dSYM.zip`) to the GitHub release for tag `v2026.1.29`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/moltbot/moltbot/main/appcast.xml`. - Sanity checks: - `curl -I https://raw.githubusercontent.com/moltbot/moltbot/main/appcast.xml` returns 200. diff --git a/docs/providers/index.md b/docs/providers/index.md index c18ad70fb..a63a642cc 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -42,6 +42,7 @@ See [Venice AI](/providers/venice). - [OpenCode Zen](/providers/opencode) - [Amazon Bedrock](/bedrock) - [Z.AI](/providers/zai) +- [Xiaomi](/providers/xiaomi) - [GLM models](/providers/glm) - [MiniMax](/providers/minimax) - [Venius (Venice AI, privacy-focused)](/providers/venice) diff --git a/docs/providers/venice.md b/docs/providers/venice.md index f6b535a68..140aa9ae0 100644 --- a/docs/providers/venice.md +++ b/docs/providers/venice.md @@ -4,9 +4,9 @@ read_when: - You want privacy-focused inference in Moltbot - You want Venice AI setup guidance --- -# Venice AI (Venius highlight) +# Venice AI (Venice highlight) -**Venius** is our highlight Venice setup for privacy-first inference with optional anonymized access to proprietary models. +**Venice** is our highlight Venice setup for privacy-first inference with optional anonymized access to proprietary models. Venice AI provides privacy-focused AI inference with support for uncensored models and access to major proprietary models through their anonymized proxy. All inference is private by default—no training on your data, no logging. diff --git a/docs/providers/xiaomi.md b/docs/providers/xiaomi.md new file mode 100644 index 000000000..008c42105 --- /dev/null +++ b/docs/providers/xiaomi.md @@ -0,0 +1,62 @@ +--- +summary: "Use Xiaomi MiMo (mimo-v2-flash) with Moltbot" +read_when: + - You want Xiaomi MiMo models in Moltbot + - You need XIAOMI_API_KEY setup +--- +# Xiaomi MiMo + +Xiaomi MiMo is the API platform for **MiMo** models. It provides REST APIs compatible with +OpenAI and Anthropic formats and uses API keys for authentication. Create your API key in +the [Xiaomi MiMo console](https://platform.xiaomimimo.com/#/console/api-keys). Moltbot uses +the `xiaomi` provider with a Xiaomi MiMo API key. + +## Model overview + +- **mimo-v2-flash**: 262144-token context window, Anthropic Messages API compatible. +- Base URL: `https://api.xiaomimimo.com/anthropic` +- Authorization: `Bearer $XIAOMI_API_KEY` + +## CLI setup + +```bash +moltbot onboard --auth-choice xiaomi-api-key +# or non-interactive +moltbot onboard --auth-choice xiaomi-api-key --xiaomi-api-key "$XIAOMI_API_KEY" +``` + +## Config snippet + +```json5 +{ + env: { XIAOMI_API_KEY: "your-key" }, + agents: { defaults: { model: { primary: "xiaomi/mimo-v2-flash" } } }, + models: { + mode: "merge", + providers: { + xiaomi: { + baseUrl: "https://api.xiaomimimo.com/anthropic", + api: "anthropic-messages", + apiKey: "XIAOMI_API_KEY", + models: [ + { + id: "mimo-v2-flash", + name: "Xiaomi MiMo V2 Flash", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 8192 + } + ] + } + } + } +} +``` + +## Notes + +- Model ref: `xiaomi/mimo-v2-flash`. +- The provider is injected automatically when `XIAOMI_API_KEY` is set (or an auth profile exists). +- See [/concepts/model-providers](/concepts/model-providers) for provider rules. diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index e648fb33c..77a59df29 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -17,7 +17,7 @@ When the operator says “release”, immediately do this preflight (no extra qu - Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed. 1) **Version & metadata** -- [ ] Bump `package.json` version (e.g., `2026.1.27-beta.1`). +- [ ] Bump `package.json` version (e.g., `2026.1.29`). - [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs. - [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/moltbot/moltbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/moltbot/moltbot/blob/main/src/provider-web.ts). - [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`moltbot.mjs`](https://github.com/moltbot/moltbot/blob/main/moltbot.mjs) for `moltbot`. diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index fc1ac34ae..7ffa39845 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/bluebubbles", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot BlueBubbles channel plugin", "moltbot": { diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 2d4753446..fd8cbcbbb 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/copilot-proxy", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot Copilot Proxy provider plugin", "moltbot": { diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index f6560702b..3c6fc084b 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/diagnostics-otel", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot diagnostics OpenTelemetry exporter", "moltbot": { diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 9921468b4..9e068ee36 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/discord", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot Discord channel plugin", "moltbot": { diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json index 8b13861ec..8c828cab0 100644 --- a/extensions/google-antigravity-auth/package.json +++ b/extensions/google-antigravity-auth/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/google-antigravity-auth", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot Google Antigravity OAuth provider plugin", "moltbot": { diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index 59cbd52a9..452118707 100644 --- a/extensions/google-gemini-cli-auth/package.json +++ b/extensions/google-gemini-cli-auth/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/google-gemini-cli-auth", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot Gemini CLI OAuth provider plugin", "moltbot": { diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 0a01621e6..3893d452a 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/googlechat", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot Google Chat channel plugin", "moltbot": { diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 29ceb0631..e103bbcd7 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/imessage", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot iMessage channel plugin", "moltbot": { diff --git a/extensions/line/package.json b/extensions/line/package.json index bd336b158..a45438252 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/line", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot LINE channel plugin", "moltbot": { diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index 247d126a9..94b6e72b0 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/llm-task", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot JSON-only LLM task plugin", "moltbot": { diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index c95d7021a..ee9b21ac7 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/lobster", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "moltbot": { diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md index 8b7dcb62c..6972449d9 100644 --- a/extensions/matrix/CHANGELOG.md +++ b/extensions/matrix/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 2026.1.27-beta.1 +## 2026.1.29 ### Changes - Version alignment with core Moltbot release numbers. diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index abc608b5b..49c6d9236 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/matrix", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot Matrix channel plugin", "moltbot": { diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index 6e7d3f1fc..0063726eb 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/mattermost", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot Mattermost channel plugin", "moltbot": { diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index e863adbd2..b97fd2c68 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/memory-core", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot core memory search plugin", "moltbot": { diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index 0e79ce83a..e0c16ca44 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/memory-lancedb", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot LanceDB-backed long-term memory plugin with auto-recall/capture", "dependencies": { diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md index 09a9e92bd..3a56e1a1b 100644 --- a/extensions/msteams/CHANGELOG.md +++ b/extensions/msteams/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 2026.1.27-beta.1 +## 2026.1.29 ### Changes - Version alignment with core Moltbot release numbers. diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 29e615862..4b070d3e2 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/msteams", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot Microsoft Teams channel plugin", "moltbot": { diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index 5e98956da..0f28a5279 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/nextcloud-talk", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot Nextcloud Talk channel plugin", "moltbot": { diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md index 65ac7f56e..a1a3fdf63 100644 --- a/extensions/nostr/CHANGELOG.md +++ b/extensions/nostr/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 2026.1.27-beta.1 +## 2026.1.29 ### Changes - Version alignment with core Moltbot release numbers. diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 8ba9a48d0..ccc4346cf 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/nostr", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot Nostr channel plugin for NIP-04 encrypted DMs", "moltbot": { diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index 89904fcca..a71bbd0d0 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/open-prose", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "OpenProse VM skill pack plugin (slash command + telemetry).", "moltbot": { diff --git a/extensions/signal/package.json b/extensions/signal/package.json index 105a4fee8..986215e15 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/signal", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot Signal channel plugin", "moltbot": { diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 8ada7de5f..9dc9c62a8 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/slack", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot Slack channel plugin", "moltbot": { diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 0f485d029..a709e269f 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/telegram", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot Telegram channel plugin", "moltbot": { diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index 2df375b55..a8af17c9c 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/tlon", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot Tlon/Urbit channel plugin", "moltbot": { diff --git a/extensions/twitch/CHANGELOG.md b/extensions/twitch/CHANGELOG.md index 95b5ff2c7..25ff5fb6d 100644 --- a/extensions/twitch/CHANGELOG.md +++ b/extensions/twitch/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 2026.1.27-beta.1 +## 2026.1.29 ### Changes - Version alignment with core Moltbot release numbers. diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 6654f9bb7..d6a6b4474 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/twitch", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "description": "Moltbot Twitch channel plugin", "type": "module", "dependencies": { diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index 312e95917..f3ec738ed 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 2026.1.27-beta.1 +## 2026.1.29 ### Changes - Version alignment with core Moltbot release numbers. diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 72bfba03d..b99074aeb 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/voice-call", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot voice-call plugin", "dependencies": { diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index d5139e18f..81bb39b71 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/whatsapp", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot WhatsApp channel plugin", "moltbot": { diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md index 55766ea8e..6134b5275 100644 --- a/extensions/zalo/CHANGELOG.md +++ b/extensions/zalo/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 2026.1.27-beta.1 +## 2026.1.29 ### Changes - Version alignment with core Moltbot release numbers. diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 2a6cf9a5f..61350d86b 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/zalo", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot Zalo channel plugin", "moltbot": { diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md index e189e2e45..079990a79 100644 --- a/extensions/zalouser/CHANGELOG.md +++ b/extensions/zalouser/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 2026.1.27-beta.1 +## 2026.1.29 ### Changes - Version alignment with core Moltbot release numbers. diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 6bace36e8..eb5f19d25 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@moltbot/zalouser", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "type": "module", "description": "Moltbot Zalo Personal Account plugin via zca-cli", "dependencies": { diff --git a/package.json b/package.json index 04322f3af..4d38edf18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moltbot", - "version": "2026.1.27-beta.1", + "version": "2026.1.29", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "type": "module", "main": "dist/index.js", diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index 6990d3a76..6747aadc8 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -8,6 +9,24 @@ import { buildDockerExecArgs } from "./bash-tools.shared.js"; import { sanitizeBinaryOutput } from "./shell-utils.js"; const isWin = process.platform === "win32"; +const resolveShellFromPath = (name: string) => { + const envPath = process.env.PATH ?? ""; + if (!envPath) return undefined; + const entries = envPath.split(path.delimiter).filter(Boolean); + for (const entry of entries) { + const candidate = path.join(entry, name); + try { + fs.accessSync(candidate, fs.constants.X_OK); + return candidate; + } catch { + // ignore missing or non-executable entries + } + } + return undefined; +}; +const defaultShell = isWin + ? undefined + : process.env.CLAWDBOT_TEST_SHELL || resolveShellFromPath("bash") || process.env.SHELL || "sh"; // PowerShell: Start-Sleep for delays, ; for command separation, $null for null device const shortDelayCmd = isWin ? "Start-Sleep -Milliseconds 50" : "sleep 0.05"; const yieldDelayCmd = isWin ? "Start-Sleep -Milliseconds 200" : "sleep 0.2"; @@ -52,7 +71,7 @@ describe("exec tool backgrounding", () => { const originalShell = process.env.SHELL; beforeEach(() => { - if (!isWin) process.env.SHELL = "/bin/bash"; + if (!isWin && defaultShell) process.env.SHELL = defaultShell; }); afterEach(() => { @@ -282,7 +301,7 @@ describe("exec PATH handling", () => { const originalShell = process.env.SHELL; beforeEach(() => { - if (!isWin) process.env.SHELL = "/bin/bash"; + if (!isWin && defaultShell) process.env.SHELL = defaultShell; }); afterEach(() => { diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index e6b86ea3d..c3165815f 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -82,6 +82,29 @@ describe("memory search config", () => { expect(resolved?.store.vector.extensionPath).toBe("/opt/sqlite-vec.dylib"); }); + it("merges extra memory paths from defaults and overrides", () => { + const cfg = { + agents: { + defaults: { + memorySearch: { + extraPaths: ["/shared/notes", " docs "], + }, + }, + list: [ + { + id: "main", + default: true, + memorySearch: { + extraPaths: ["/shared/notes", "../team-notes"], + }, + }, + ], + }, + }; + const resolved = resolveMemorySearchConfig(cfg, "main"); + expect(resolved?.extraPaths).toEqual(["/shared/notes", "docs", "../team-notes"]); + }); + it("includes batch defaults for openai without remote overrides", () => { const cfg = { agents: { diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index c08161d4f..25aeb7cac 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -9,6 +9,7 @@ import { resolveAgentConfig } from "./agent-scope.js"; export type ResolvedMemorySearchConfig = { enabled: boolean; sources: Array<"memory" | "sessions">; + extraPaths: string[]; provider: "openai" | "local" | "gemini" | "auto"; remote?: { baseUrl?: string; @@ -162,6 +163,10 @@ function mergeConfig( modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir, }; const sources = normalizeSources(overrides?.sources ?? defaults?.sources, sessionMemory); + const rawPaths = [...(defaults?.extraPaths ?? []), ...(overrides?.extraPaths ?? [])] + .map((value) => value.trim()) + .filter(Boolean); + const extraPaths = Array.from(new Set(rawPaths)); const vector = { enabled: overrides?.store?.vector?.enabled ?? defaults?.store?.vector?.enabled ?? true, extensionPath: @@ -236,6 +241,7 @@ function mergeConfig( return { enabled, sources, + extraPaths, provider, remote, experimental: { diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 96e4e4ae6..5d1c095d2 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -281,6 +281,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { moonshot: "MOONSHOT_API_KEY", "kimi-code": "KIMICODE_API_KEY", minimax: "MINIMAX_API_KEY", + xiaomi: "XIAOMI_API_KEY", synthetic: "SYNTHETIC_API_KEY", venice: "VENICE_API_KEY", mistral: "MISTRAL_API_KEY", diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index a176dac8a..f38ad46c7 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -30,6 +30,17 @@ const MINIMAX_API_COST = { cacheWrite: 10, }; +const XIAOMI_BASE_URL = "https://api.xiaomimimo.com/anthropic"; +export const XIAOMI_DEFAULT_MODEL_ID = "mimo-v2-flash"; +const XIAOMI_DEFAULT_CONTEXT_WINDOW = 262144; +const XIAOMI_DEFAULT_MAX_TOKENS = 8192; +const XIAOMI_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5"; const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000; @@ -341,6 +352,24 @@ function buildSyntheticProvider(): ProviderConfig { }; } +export function buildXiaomiProvider(): ProviderConfig { + return { + baseUrl: XIAOMI_BASE_URL, + api: "anthropic-messages", + models: [ + { + id: XIAOMI_DEFAULT_MODEL_ID, + name: "Xiaomi MiMo V2 Flash", + reasoning: false, + input: ["text"], + cost: XIAOMI_DEFAULT_COST, + contextWindow: XIAOMI_DEFAULT_CONTEXT_WINDOW, + maxTokens: XIAOMI_DEFAULT_MAX_TOKENS, + }, + ], + }; +} + async function buildVeniceProvider(): Promise { const models = await discoverVeniceModels(); return { @@ -410,6 +439,13 @@ export async function resolveImplicitProviders(params: { }; } + const xiaomiKey = + resolveEnvApiKeyVarName("xiaomi") ?? + resolveApiKeyFromProfiles({ provider: "xiaomi", store: authStore }); + if (xiaomiKey) { + providers.xiaomi = { ...buildXiaomiProvider(), apiKey: xiaomiKey }; + } + // Ollama provider - only add if explicitly configured const ollamaKey = resolveEnvApiKeyVarName("ollama") ?? diff --git a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts index fef8fa6a4..08a66469f 100644 --- a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts +++ b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts @@ -53,6 +53,7 @@ describe("models-config", () => { const previousMoonshot = process.env.MOONSHOT_API_KEY; const previousSynthetic = process.env.SYNTHETIC_API_KEY; const previousVenice = process.env.VENICE_API_KEY; + const previousXiaomi = process.env.XIAOMI_API_KEY; delete process.env.COPILOT_GITHUB_TOKEN; delete process.env.GH_TOKEN; delete process.env.GITHUB_TOKEN; @@ -61,6 +62,7 @@ describe("models-config", () => { delete process.env.MOONSHOT_API_KEY; delete process.env.SYNTHETIC_API_KEY; delete process.env.VENICE_API_KEY; + delete process.env.XIAOMI_API_KEY; try { vi.resetModules(); @@ -93,6 +95,8 @@ describe("models-config", () => { else process.env.SYNTHETIC_API_KEY = previousSynthetic; if (previousVenice === undefined) delete process.env.VENICE_API_KEY; else process.env.VENICE_API_KEY = previousVenice; + if (previousXiaomi === undefined) delete process.env.XIAOMI_API_KEY; + else process.env.XIAOMI_API_KEY = previousXiaomi; } }); }); diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts index 832d368a6..82a2428da 100644 --- a/src/agents/session-write-lock.ts +++ b/src/agents/session-write-lock.ts @@ -35,8 +35,8 @@ function isAlive(pid: number): boolean { function releaseAllLocksSync(): void { for (const [sessionFile, held] of HELD_LOCKS) { try { - if (typeof held.handle.fd === "number") { - fsSync.closeSync(held.handle.fd); + if (typeof held.handle.close === "function") { + void held.handle.close().catch(() => {}); } } catch { // Ignore errors during cleanup - best effort diff --git a/src/agents/tools/memory-tool.ts b/src/agents/tools/memory-tool.ts index b7b619af3..274af4c02 100644 --- a/src/agents/tools/memory-tool.ts +++ b/src/agents/tools/memory-tool.ts @@ -83,7 +83,7 @@ export function createMemoryGetTool(options: { label: "Memory Get", name: "memory_get", description: - "Safe snippet read from MEMORY.md or memory/*.md with optional from/lines; use after memory_search to pull only the needed lines and keep context small.", + "Safe snippet read from MEMORY.md, memory/*.md, or configured memorySearch.extraPaths with optional from/lines; use after memory_search to pull only the needed lines and keep context small.", parameters: MemoryGetSchema, execute: async (_toolCallId, params) => { const relPath = readStringParam(params, "path", { required: true }); diff --git a/src/agents/tools/web-fetch.ssrf.test.ts b/src/agents/tools/web-fetch.ssrf.test.ts index 24e4dfe41..b5c1936b1 100644 --- a/src/agents/tools/web-fetch.ssrf.test.ts +++ b/src/agents/tools/web-fetch.ssrf.test.ts @@ -1,10 +1,9 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import * as ssrf from "../../infra/net/ssrf.js"; const lookupMock = vi.fn(); - -vi.mock("node:dns/promises", () => ({ - lookup: lookupMock, -})); +const resolvePinnedHostname = ssrf.resolvePinnedHostname; function makeHeaders(map: Record): { get: (key: string) => string | null } { return { @@ -33,6 +32,12 @@ function textResponse(body: string): Response { describe("web_fetch SSRF protection", () => { const priorFetch = global.fetch; + beforeEach(() => { + vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) => + resolvePinnedHostname(hostname, lookupMock), + ); + }); + afterEach(() => { // @ts-expect-error restore global.fetch = priorFetch; diff --git a/src/agents/tools/web-tools.fetch.test.ts b/src/agents/tools/web-tools.fetch.test.ts index 04923b607..86bdeb7a2 100644 --- a/src/agents/tools/web-tools.fetch.test.ts +++ b/src/agents/tools/web-tools.fetch.test.ts @@ -1,5 +1,6 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as ssrf from "../../infra/net/ssrf.js"; import { createWebFetchTool } from "./web-tools.js"; type MockResponse = { @@ -73,6 +74,18 @@ function requestUrl(input: RequestInfo): string { describe("web_fetch extraction fallbacks", () => { const priorFetch = global.fetch; + beforeEach(() => { + vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(async (hostname) => { + const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); + const addresses = ["93.184.216.34", "93.184.216.35"]; + return { + hostname: normalized, + addresses, + lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }), + }; + }); + }); + afterEach(() => { // @ts-expect-error restore global.fetch = priorFetch; diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index c080ef55f..2604038ec 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -138,7 +138,7 @@ describe("dispatchReplyFromConfig", () => { ); }); - it("does not provide onToolResult when routing cross-provider", async () => { + it("provides onToolResult in DM sessions", async () => { mocks.tryFastAbortFromMessage.mockResolvedValue({ handled: false, aborted: false, @@ -147,9 +147,34 @@ describe("dispatchReplyFromConfig", () => { const cfg = {} as MoltbotConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ - Provider: "slack", - OriginatingChannel: "telegram", - OriginatingTo: "telegram:999", + Provider: "telegram", + ChatType: "direct", + }); + + const replyResolver = async ( + _ctx: MsgContext, + opts: GetReplyOptions | undefined, + _cfg: ClawdbotConfig, + ) => { + expect(opts?.onToolResult).toBeDefined(); + expect(typeof opts?.onToolResult).toBe("function"); + return { text: "hi" } satisfies ReplyPayload; + }; + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); + }); + + it("does not provide onToolResult in group sessions", async () => { + mocks.tryFastAbortFromMessage.mockResolvedValue({ + handled: false, + aborted: false, + }); + const cfg = {} as ClawdbotConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "telegram", + ChatType: "group", }); const replyResolver = async ( @@ -162,12 +187,62 @@ describe("dispatchReplyFromConfig", () => { }; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); + }); - expect(mocks.routeReply).toHaveBeenCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ text: "hi" }), - }), + it("sends tool results via dispatcher in DM sessions", async () => { + mocks.tryFastAbortFromMessage.mockResolvedValue({ + handled: false, + aborted: false, + }); + const cfg = {} as ClawdbotConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "telegram", + ChatType: "direct", + }); + + const replyResolver = async ( + _ctx: MsgContext, + opts: GetReplyOptions | undefined, + _cfg: ClawdbotConfig, + ) => { + // Simulate tool result emission + await opts?.onToolResult?.({ text: "🔧 exec: ls" }); + return { text: "done" } satisfies ReplyPayload; + }; + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + expect(dispatcher.sendToolResult).toHaveBeenCalledWith( + expect.objectContaining({ text: "🔧 exec: ls" }), ); + expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); + }); + + it("does not provide onToolResult for native slash commands", async () => { + mocks.tryFastAbortFromMessage.mockResolvedValue({ + handled: false, + aborted: false, + }); + const cfg = {} as ClawdbotConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "telegram", + ChatType: "direct", + CommandSource: "native", + }); + + const replyResolver = async ( + _ctx: MsgContext, + opts: GetReplyOptions | undefined, + _cfg: ClawdbotConfig, + ) => { + expect(opts?.onToolResult).toBeUndefined(); + return { text: "hi" } satisfies ReplyPayload; + }; + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); it("fast-aborts without calling the reply resolver", async () => { diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 58d5d71b5..c85e654de 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -276,6 +276,27 @@ export async function dispatchReplyFromConfig(params: { ctx, { ...params.replyOptions, + onToolResult: + ctx.ChatType !== "group" && ctx.CommandSource !== "native" + ? (payload: ReplyPayload) => { + const run = async () => { + const ttsPayload = await maybeApplyTtsToPayload({ + payload, + cfg, + channel: ttsChannel, + kind: "tool", + inboundAudio, + ttsAuto: sessionTtsAuto, + }); + if (shouldRouteToOriginating) { + await sendPayloadAsync(ttsPayload, undefined, false); + } else { + dispatcher.sendToolResult(ttsPayload); + } + }; + return run(); + } + : undefined, onBlockReply: (payload: ReplyPayload, context) => { const run = async () => { // Accumulate block text for TTS generation after streaming diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts index 7742b4f30..07cff23a9 100644 --- a/src/auto-reply/reply/mentions.test.ts +++ b/src/auto-reply/reply/mentions.test.ts @@ -4,7 +4,7 @@ import { matchesMentionWithExplicit } from "./mentions.js"; describe("matchesMentionWithExplicit", () => { const mentionRegexes = [/\bclawd\b/i]; - it("prefers explicit mentions when other mentions are present", () => { + it("checks mentionPatterns even when explicit mention is available", () => { const result = matchesMentionWithExplicit({ text: "@clawd hello", mentionRegexes, @@ -14,6 +14,19 @@ describe("matchesMentionWithExplicit", () => { canResolveExplicit: true, }, }); + expect(result).toBe(true); + }); + + it("returns false when explicit is false and no regex match", () => { + const result = matchesMentionWithExplicit({ + text: "<@999999> hello", + mentionRegexes, + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: false, + canResolveExplicit: true, + }, + }); expect(result).toBe(false); }); diff --git a/src/auto-reply/reply/mentions.ts b/src/auto-reply/reply/mentions.ts index 71964ac5f..9554a3c7b 100644 --- a/src/auto-reply/reply/mentions.ts +++ b/src/auto-reply/reply/mentions.ts @@ -90,7 +90,9 @@ export function matchesMentionWithExplicit(params: { const explicit = params.explicit?.isExplicitlyMentioned === true; const explicitAvailable = params.explicit?.canResolveExplicit === true; const hasAnyMention = params.explicit?.hasAnyMention === true; - if (hasAnyMention && explicitAvailable) return explicit; + if (hasAnyMention && explicitAvailable) { + return explicit || params.mentionRegexes.some((re) => re.test(cleaned)); + } if (!cleaned) return explicit; return explicit || params.mentionRegexes.some((re) => re.test(cleaned)); } diff --git a/src/auto-reply/reply/normalize-reply.test.ts b/src/auto-reply/reply/normalize-reply.test.ts index 30fb5e3f5..b9547c2b1 100644 --- a/src/auto-reply/reply/normalize-reply.test.ts +++ b/src/auto-reply/reply/normalize-reply.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { SILENT_REPLY_TOKEN } from "../tokens.js"; import { normalizeReplyPayload } from "./normalize-reply.js"; // Keep channelData-only payloads so channel-specific replies survive normalization. @@ -19,4 +20,30 @@ describe("normalizeReplyPayload", () => { expect(normalized?.text).toBeUndefined(); expect(normalized?.channelData).toEqual(payload.channelData); }); + + it("records silent skips", () => { + const reasons: string[] = []; + const normalized = normalizeReplyPayload( + { text: SILENT_REPLY_TOKEN }, + { + onSkip: (reason) => reasons.push(reason), + }, + ); + + expect(normalized).toBeNull(); + expect(reasons).toEqual(["silent"]); + }); + + it("records empty skips", () => { + const reasons: string[] = []; + const normalized = normalizeReplyPayload( + { text: " " }, + { + onSkip: (reason) => reasons.push(reason), + }, + ); + + expect(normalized).toBeNull(); + expect(reasons).toEqual(["empty"]); + }); }); diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index 7968088bd..9a58bebde 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -8,6 +8,8 @@ import { } from "./response-prefix-template.js"; import { hasLineDirectives, parseLineDirectives } from "./line-directives.js"; +export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat"; + export type NormalizeReplyOptions = { responsePrefix?: string; /** Context for template variable interpolation in responsePrefix */ @@ -15,6 +17,7 @@ export type NormalizeReplyOptions = { onHeartbeatStrip?: () => void; stripHeartbeat?: boolean; silentToken?: string; + onSkip?: (reason: NormalizeReplySkipReason) => void; }; export function normalizeReplyPayload( @@ -26,12 +29,18 @@ export function normalizeReplyPayload( payload.channelData && Object.keys(payload.channelData).length > 0, ); const trimmed = payload.text?.trim() ?? ""; - if (!trimmed && !hasMedia && !hasChannelData) return null; + if (!trimmed && !hasMedia && !hasChannelData) { + opts.onSkip?.("empty"); + return null; + } const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN; let text = payload.text ?? undefined; if (text && isSilentReplyText(text, silentToken)) { - if (!hasMedia && !hasChannelData) return null; + if (!hasMedia && !hasChannelData) { + opts.onSkip?.("silent"); + return null; + } text = ""; } if (text && !trimmed) { @@ -43,14 +52,20 @@ export function normalizeReplyPayload( if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) { const stripped = stripHeartbeatToken(text, { mode: "message" }); if (stripped.didStrip) opts.onHeartbeatStrip?.(); - if (stripped.shouldSkip && !hasMedia && !hasChannelData) return null; + if (stripped.shouldSkip && !hasMedia && !hasChannelData) { + opts.onSkip?.("heartbeat"); + return null; + } text = stripped.text; } if (text) { text = sanitizeUserFacingText(text); } - if (!text?.trim() && !hasMedia && !hasChannelData) return null; + if (!text?.trim() && !hasMedia && !hasChannelData) { + opts.onSkip?.("empty"); + return null; + } // Parse LINE-specific directives from text (quick_replies, location, confirm, buttons) let enrichedPayload: ReplyPayload = { ...payload, text }; diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index f41667802..fd7fb5493 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -1,6 +1,6 @@ import type { HumanDelayConfig } from "../../config/types.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; -import { normalizeReplyPayload } from "./normalize-reply.js"; +import { normalizeReplyPayload, type NormalizeReplySkipReason } from "./normalize-reply.js"; import type { ResponsePrefixContext } from "./response-prefix-template.js"; import type { TypingController } from "./typing.js"; @@ -8,6 +8,11 @@ export type ReplyDispatchKind = "tool" | "block" | "final"; type ReplyDispatchErrorHandler = (err: unknown, info: { kind: ReplyDispatchKind }) => void; +type ReplyDispatchSkipHandler = ( + payload: ReplyPayload, + info: { kind: ReplyDispatchKind; reason: NormalizeReplySkipReason }, +) => void; + type ReplyDispatchDeliverer = ( payload: ReplyPayload, info: { kind: ReplyDispatchKind }, @@ -42,6 +47,8 @@ export type ReplyDispatcherOptions = { onHeartbeatStrip?: () => void; onIdle?: () => void; onError?: ReplyDispatchErrorHandler; + // AIDEV-NOTE: onSkip lets channels detect silent/empty drops (e.g. Telegram empty-response fallback). + onSkip?: ReplyDispatchSkipHandler; /** Human-like delay between block replies for natural rhythm. */ humanDelay?: HumanDelayConfig; }; @@ -65,15 +72,16 @@ export type ReplyDispatcher = { getQueuedCounts: () => Record; }; +type NormalizeReplyPayloadInternalOptions = Pick< + ReplyDispatcherOptions, + "responsePrefix" | "responsePrefixContext" | "responsePrefixContextProvider" | "onHeartbeatStrip" +> & { + onSkip?: (reason: NormalizeReplySkipReason) => void; +}; + function normalizeReplyPayloadInternal( payload: ReplyPayload, - opts: Pick< - ReplyDispatcherOptions, - | "responsePrefix" - | "responsePrefixContext" - | "responsePrefixContextProvider" - | "onHeartbeatStrip" - >, + opts: NormalizeReplyPayloadInternalOptions, ): ReplyPayload | null { // Prefer dynamic context provider over static context const prefixContext = opts.responsePrefixContextProvider?.() ?? opts.responsePrefixContext; @@ -82,6 +90,7 @@ function normalizeReplyPayloadInternal( responsePrefix: opts.responsePrefix, responsePrefixContext: prefixContext, onHeartbeatStrip: opts.onHeartbeatStrip, + onSkip: opts.onSkip, }); } @@ -99,7 +108,13 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis }; const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => { - const normalized = normalizeReplyPayloadInternal(payload, options); + const normalized = normalizeReplyPayloadInternal(payload, { + responsePrefix: options.responsePrefix, + responsePrefixContext: options.responsePrefixContext, + responsePrefixContextProvider: options.responsePrefixContextProvider, + onHeartbeatStrip: options.onHeartbeatStrip, + onSkip: (reason) => options.onSkip?.(payload, { kind, reason }), + }); if (!normalized) return false; queuedCounts[kind] += 1; pending += 1; diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index e460b2630..4577a16ea 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -202,6 +202,16 @@ describe("canvas host", () => { it("serves the gateway-hosted A2UI scaffold", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-canvas-")); + const a2uiRoot = path.resolve(process.cwd(), "src/canvas-host/a2ui"); + const bundlePath = path.join(a2uiRoot, "a2ui.bundle.js"); + let createdBundle = false; + + try { + await fs.stat(bundlePath); + } catch { + await fs.writeFile(bundlePath, "window.moltbotA2UI = {};", "utf8"); + createdBundle = true; + } const server = await startCanvasHost({ runtime: defaultRuntime, @@ -226,6 +236,9 @@ describe("canvas host", () => { expect(js).toContain("moltbotA2UI"); } finally { await server.close(); + if (createdBundle) { + await fs.rm(bundlePath, { force: true }); + } await fs.rm(dir, { recursive: true, force: true }); } }); diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts index 68894adf5..b72267a2a 100644 --- a/src/cli/memory-cli.ts +++ b/src/cli/memory-cli.ts @@ -12,7 +12,7 @@ import { setVerbose } from "../globals.js"; import { withProgress, withProgressTotals } from "./progress.js"; import { formatErrorMessage, withManager } from "./cli-utils.js"; import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js"; -import { listMemoryFiles } from "../memory/internal.js"; +import { listMemoryFiles, normalizeExtraMemoryPaths } from "../memory/internal.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; @@ -74,6 +74,10 @@ function resolveAgentIds(cfg: ReturnType, agent?: string): st return [resolveDefaultAgentId(cfg)]; } +function formatExtraPaths(workspaceDir: string, extraPaths: string[]): string[] { + return normalizeExtraMemoryPaths(workspaceDir, extraPaths).map((entry) => shortenHomePath(entry)); +} + async function checkReadableFile(pathname: string): Promise<{ exists: boolean; issue?: string }> { try { await fs.access(pathname, fsSync.constants.R_OK); @@ -110,7 +114,10 @@ async function scanSessionFiles(agentId: string): Promise { } } -async function scanMemoryFiles(workspaceDir: string): Promise { +async function scanMemoryFiles( + workspaceDir: string, + extraPaths: string[] = [], +): Promise { const issues: string[] = []; const memoryFile = path.join(workspaceDir, "MEMORY.md"); const altMemoryFile = path.join(workspaceDir, "memory.md"); @@ -121,6 +128,25 @@ async function scanMemoryFiles(workspaceDir: string): Promise { if (primary.issue) issues.push(primary.issue); if (alt.issue) issues.push(alt.issue); + const resolvedExtraPaths = normalizeExtraMemoryPaths(workspaceDir, extraPaths); + for (const extraPath of resolvedExtraPaths) { + try { + const stat = await fs.lstat(extraPath); + if (stat.isSymbolicLink()) continue; + const extraCheck = await checkReadableFile(extraPath); + if (extraCheck.issue) issues.push(extraCheck.issue); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + issues.push(`additional memory path missing (${shortenHomePath(extraPath)})`); + } else { + issues.push( + `additional memory path not accessible (${shortenHomePath(extraPath)}): ${code ?? "error"}`, + ); + } + } + } + let dirReadable: boolean | null = null; try { await fs.access(memoryDir, fsSync.constants.R_OK); @@ -141,7 +167,7 @@ async function scanMemoryFiles(workspaceDir: string): Promise { let listed: string[] = []; let listedOk = false; try { - listed = await listMemoryFiles(workspaceDir); + listed = await listMemoryFiles(workspaceDir, resolvedExtraPaths); listedOk = true; } catch (err) { const code = (err as NodeJS.ErrnoException).code; @@ -176,11 +202,13 @@ async function scanMemorySources(params: { workspaceDir: string; agentId: string; sources: MemorySourceName[]; + extraPaths?: string[]; }): Promise { const scans: SourceScan[] = []; + const extraPaths = params.extraPaths ?? []; for (const source of params.sources) { if (source === "memory") { - scans.push(await scanMemoryFiles(params.workspaceDir)); + scans.push(await scanMemoryFiles(params.workspaceDir, extraPaths)); } if (source === "sessions") { scans.push(await scanSessionFiles(params.agentId)); @@ -268,6 +296,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) { workspaceDir: status.workspaceDir, agentId, sources, + extraPaths: status.extraPaths, }); allResults.push({ agentId, status, embeddingProbe, indexError, scan }); }, @@ -299,6 +328,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) { const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete."; defaultRuntime.log(line); } + const extraPaths = formatExtraPaths(status.workspaceDir, status.extraPaths ?? []); const lines = [ `${heading("Memory Search")} ${muted(`(${agentId})`)}`, `${label("Provider")} ${info(status.provider)} ${muted( @@ -306,6 +336,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) { )}`, `${label("Model")} ${info(status.model)}`, status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null, + extraPaths.length ? `${label("Extra paths")} ${info(extraPaths.join(", "))}` : null, `${label("Indexed")} ${success(indexedLabel)}`, `${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`, `${label("Store")} ${info(shortenHomePath(status.dbPath))}`, @@ -469,6 +500,7 @@ export function registerMemoryCli(program: Command) { const sourceLabels = status.sources.map((source) => formatSourceLabel(source, status.workspaceDir, agentId), ); + const extraPaths = formatExtraPaths(status.workspaceDir, status.extraPaths ?? []); const lines = [ `${heading("Memory Index")} ${muted(`(${agentId})`)}`, `${label("Provider")} ${info(status.provider)} ${muted( @@ -478,6 +510,9 @@ export function registerMemoryCli(program: Command) { sourceLabels.length ? `${label("Sources")} ${info(sourceLabels.join(", "))}` : null, + extraPaths.length + ? `${label("Extra paths")} ${info(extraPaths.join(", "))}` + : null, ].filter(Boolean) as string[]; if (status.fallback) { lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`); diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 8f31635f0..de7080103 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) { .option("--mode ", "Wizard mode: local|remote") .option( "--auth-choice ", - "Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", + "Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|xiaomi-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", ) .option( "--token-provider ", @@ -72,6 +72,7 @@ export function registerOnboardCommand(program: Command) { .option("--kimi-code-api-key ", "Kimi Code API key") .option("--gemini-api-key ", "Gemini API key") .option("--zai-api-key ", "Z.AI API key") + .option("--xiaomi-api-key ", "Xiaomi API key") .option("--minimax-api-key ", "MiniMax API key") .option("--synthetic-api-key ", "Synthetic API key") .option("--venice-api-key ", "Venice API key") @@ -122,6 +123,7 @@ export function registerOnboardCommand(program: Command) { kimiCodeApiKey: opts.kimiCodeApiKey as string | undefined, geminiApiKey: opts.geminiApiKey as string | undefined, zaiApiKey: opts.zaiApiKey as string | undefined, + xiaomiApiKey: opts.xiaomiApiKey as string | undefined, minimaxApiKey: opts.minimaxApiKey as string | undefined, syntheticApiKey: opts.syntheticApiKey as string | undefined, veniceApiKey: opts.veniceApiKey as string | undefined, diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index 7bf917a27..c85cc0b4d 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -33,6 +33,16 @@ describe("buildAuthChoiceOptions", () => { expect(options.some((opt) => opt.value === "zai-api-key")).toBe(true); }); + it("includes Xiaomi auth choice", () => { + const store: AuthProfileStore = { version: 1, profiles: {} }; + const options = buildAuthChoiceOptions({ + store, + includeSkip: false, + }); + + expect(options.some((opt) => opt.value === "xiaomi-api-key")).toBe(true); + }); + it("includes MiniMax auth choice", () => { const store: AuthProfileStore = { version: 1, profiles: {} }; const options = buildAuthChoiceOptions({ diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 6b49ff17b..5acddf4e3 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -16,6 +16,7 @@ export type AuthChoiceGroupId = | "ai-gateway" | "moonshot" | "zai" + | "xiaomi" | "opencode-zen" | "minimax" | "synthetic" @@ -107,6 +108,12 @@ const AUTH_CHOICE_GROUP_DEFS: { hint: "API key", choices: ["zai-api-key"], }, + { + value: "xiaomi", + label: "Xiaomi", + hint: "API key", + choices: ["xiaomi-api-key"], + }, { value: "opencode-zen", label: "OpenCode Zen", @@ -164,6 +171,10 @@ export function buildAuthChoiceOptions(params: { hint: "Uses the bundled Gemini CLI auth plugin", }); options.push({ value: "zai-api-key", label: "Z.AI (GLM 4.7) API key" }); + options.push({ + value: "xiaomi-api-key", + label: "Xiaomi API key", + }); options.push({ value: "qwen-portal", label: "Qwen OAuth" }); options.push({ value: "copilot-proxy", diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index 8be02008b..fa4fc77e7 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -27,6 +27,8 @@ import { applyVeniceProviderConfig, applyVercelAiGatewayConfig, applyVercelAiGatewayProviderConfig, + applyXiaomiConfig, + applyXiaomiProviderConfig, applyZaiConfig, KIMI_CODE_MODEL_REF, MOONSHOT_DEFAULT_MODEL_REF, @@ -34,6 +36,7 @@ import { SYNTHETIC_DEFAULT_MODEL_REF, VENICE_DEFAULT_MODEL_REF, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, + XIAOMI_DEFAULT_MODEL_REF, setGeminiApiKey, setKimiCodeApiKey, setMoonshotApiKey, @@ -42,6 +45,7 @@ import { setSyntheticApiKey, setVeniceApiKey, setVercelAiGatewayApiKey, + setXiaomiApiKey, setZaiApiKey, ZAI_DEFAULT_MODEL_REF, } from "./onboard-auth.js"; @@ -79,6 +83,8 @@ export async function applyAuthChoiceApiProviders( authChoice = "gemini-api-key"; } else if (params.opts.tokenProvider === "zai") { authChoice = "zai-api-key"; + } else if (params.opts.tokenProvider === "xiaomi") { + authChoice = "xiaomi-api-key"; } else if (params.opts.tokenProvider === "synthetic") { authChoice = "synthetic-api-key"; } else if (params.opts.tokenProvider === "venice") { @@ -431,6 +437,54 @@ export async function applyAuthChoiceApiProviders( return { config: nextConfig, agentModelOverride }; } + if (authChoice === "xiaomi-api-key") { + let hasCredential = false; + + if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "xiaomi") { + await setXiaomiApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); + hasCredential = true; + } + + const envKey = resolveEnvApiKey("xiaomi"); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing XIAOMI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await setXiaomiApiKey(envKey.apiKey, params.agentDir); + hasCredential = true; + } + } + if (!hasCredential) { + const key = await params.prompter.text({ + message: "Enter Xiaomi API key", + validate: validateApiKeyInput, + }); + await setXiaomiApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + } + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "xiaomi:default", + provider: "xiaomi", + mode: "api_key", + }); + { + const applied = await applyDefaultModelChoice({ + config: nextConfig, + setDefaultModel: params.setDefaultModel, + defaultModel: XIAOMI_DEFAULT_MODEL_REF, + applyDefaultConfig: applyXiaomiConfig, + applyProviderConfig: applyXiaomiProviderConfig, + noteDefault: XIAOMI_DEFAULT_MODEL_REF, + noteAgentModel, + prompter: params.prompter, + }); + nextConfig = applied.config; + agentModelOverride = applied.agentModelOverride ?? agentModelOverride; + } + return { config: nextConfig, agentModelOverride }; + } + if (authChoice === "synthetic-api-key") { if (params.opts?.token && params.opts?.tokenProvider === "synthetic") { await setSyntheticApiKey(String(params.opts.token).trim(), params.agentDir); diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index 6fe26b59a..a4d831c92 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -18,6 +18,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { "google-antigravity": "google-antigravity", "google-gemini-cli": "google-gemini-cli", "zai-api-key": "zai", + "xiaomi-api-key": "xiaomi", "synthetic-api-key": "synthetic", "venice-api-key": "venice", "github-copilot": "github-copilot", diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 09547a83d..5e816f581 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -192,6 +192,45 @@ describe("gateway-status command", () => { expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true); }); + it("skips invalid ssh-auto discovery targets", async () => { + const runtimeLogs: string[] = []; + const runtime = { + log: (msg: string) => runtimeLogs.push(msg), + error: (_msg: string) => {}, + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, + }; + + const originalUser = process.env.USER; + try { + process.env.USER = "steipete"; + loadConfig.mockReturnValueOnce({ + gateway: { + mode: "remote", + remote: {}, + }, + }); + discoverGatewayBeacons.mockResolvedValueOnce([ + { tailnetDns: "-V" }, + { tailnetDns: "goodhost" }, + ]); + + startSshPortForward.mockClear(); + const { gatewayStatusCommand } = await import("./gateway-status.js"); + await gatewayStatusCommand( + { timeout: "1000", json: true, sshAuto: true }, + runtime as unknown as import("../runtime.js").RuntimeEnv, + ); + + expect(startSshPortForward).toHaveBeenCalledTimes(1); + const call = startSshPortForward.mock.calls[0]?.[0] as { target: string }; + expect(call.target).toBe("steipete@goodhost"); + } finally { + process.env.USER = originalUser; + } + }); + it("infers SSH target from gateway.remote.url and ssh config", async () => { const runtimeLogs: string[] = []; const runtime = { diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index 3a51a7886..a5a34d6e4 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -107,7 +107,9 @@ export async function gatewayStatusCommand( const base = user ? `${user}@${host.trim()}` : host.trim(); return sshPort !== 22 ? `${base}:${sshPort}` : base; }) - .filter((x): x is string => Boolean(x)); + .filter((candidate): candidate is string => + Boolean(candidate && parseSshTarget(candidate)), + ); if (candidates.length > 0) sshTarget = candidates[0] ?? null; } diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index 921ee01d1..222f0a5c6 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -1,3 +1,4 @@ +import { buildXiaomiProvider, XIAOMI_DEFAULT_MODEL_ID } from "../agents/models-config.providers.js"; import { buildSyntheticModelDefinition, SYNTHETIC_BASE_URL, @@ -14,6 +15,7 @@ import type { MoltbotConfig } from "../config/config.js"; import { OPENROUTER_DEFAULT_MODEL_REF, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, + XIAOMI_DEFAULT_MODEL_REF, ZAI_DEFAULT_MODEL_REF, } from "./onboard-auth.credentials.js"; import { @@ -336,6 +338,77 @@ export function applySyntheticConfig(cfg: MoltbotConfig): MoltbotConfig { }; } +export function applyXiaomiProviderConfig(cfg: MoltbotConfig): MoltbotConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[XIAOMI_DEFAULT_MODEL_REF] = { + ...models[XIAOMI_DEFAULT_MODEL_REF], + alias: models[XIAOMI_DEFAULT_MODEL_REF]?.alias ?? "Xiaomi", + }; + + const providers = { ...cfg.models?.providers }; + const existingProvider = providers.xiaomi; + const defaultProvider = buildXiaomiProvider(); + const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : []; + const defaultModels = defaultProvider.models ?? []; + const hasDefaultModel = existingModels.some((model) => model.id === XIAOMI_DEFAULT_MODEL_ID); + const mergedModels = + existingModels.length > 0 + ? hasDefaultModel + ? existingModels + : [...existingModels, ...defaultModels] + : defaultModels; + const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< + string, + unknown + > as { apiKey?: string }; + const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined; + const normalizedApiKey = resolvedApiKey?.trim(); + providers.xiaomi = { + ...existingProviderRest, + baseUrl: defaultProvider.baseUrl, + api: defaultProvider.api, + ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + models: mergedModels.length > 0 ? mergedModels : defaultProvider.models, + }; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + models: { + mode: cfg.models?.mode ?? "merge", + providers, + }, + }; +} + +export function applyXiaomiConfig(cfg: MoltbotConfig): MoltbotConfig { + const next = applyXiaomiProviderConfig(cfg); + const existingModel = next.agents?.defaults?.model; + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + model: { + ...(existingModel && "fallbacks" in (existingModel as Record) + ? { + fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, + } + : undefined), + primary: XIAOMI_DEFAULT_MODEL_REF, + }, + }, + }, + }; +} + /** * Apply Venice provider configuration without changing the default model. * Registers Venice models and sets up the provider, but preserves existing model selection. diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index b2fb58542..053026162 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -113,6 +113,7 @@ export async function setVeniceApiKey(key: string, agentDir?: string) { } export const ZAI_DEFAULT_MODEL_REF = "zai/glm-4.7"; +export const XIAOMI_DEFAULT_MODEL_REF = "xiaomi/mimo-v2-flash"; export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto"; export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF = "vercel-ai-gateway/anthropic/claude-opus-4.5"; @@ -129,6 +130,18 @@ export async function setZaiApiKey(key: string, agentDir?: string) { }); } +export async function setXiaomiApiKey(key: string, agentDir?: string) { + upsertAuthProfile({ + profileId: "xiaomi:default", + credential: { + type: "api_key", + provider: "xiaomi", + key, + }, + agentDir: resolveAuthAgentDir(agentDir), + }); +} + export async function setOpenrouterApiKey(key: string, agentDir?: string) { upsertAuthProfile({ profileId: "openrouter:default", diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 0a2c67f94..80d71852c 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -15,6 +15,8 @@ import { applyOpenrouterProviderConfig, applySyntheticConfig, applySyntheticProviderConfig, + applyXiaomiConfig, + applyXiaomiProviderConfig, OPENROUTER_DEFAULT_MODEL_REF, SYNTHETIC_DEFAULT_MODEL_ID, SYNTHETIC_DEFAULT_MODEL_REF, @@ -343,6 +345,50 @@ describe("applySyntheticConfig", () => { }); }); +describe("applyXiaomiConfig", () => { + it("adds Xiaomi provider with correct settings", () => { + const cfg = applyXiaomiConfig({}); + expect(cfg.models?.providers?.xiaomi).toMatchObject({ + baseUrl: "https://api.xiaomimimo.com/anthropic", + api: "anthropic-messages", + }); + expect(cfg.agents?.defaults?.model?.primary).toBe("xiaomi/mimo-v2-flash"); + }); + + it("merges Xiaomi models and keeps existing provider overrides", () => { + const cfg = applyXiaomiProviderConfig({ + models: { + providers: { + xiaomi: { + baseUrl: "https://old.example.com", + apiKey: "old-key", + api: "openai-completions", + models: [ + { + id: "custom-model", + name: "Custom", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, + }, + ], + }, + }, + }, + }); + + expect(cfg.models?.providers?.xiaomi?.baseUrl).toBe("https://api.xiaomimimo.com/anthropic"); + expect(cfg.models?.providers?.xiaomi?.api).toBe("anthropic-messages"); + expect(cfg.models?.providers?.xiaomi?.apiKey).toBe("old-key"); + expect(cfg.models?.providers?.xiaomi?.models.map((m) => m.id)).toEqual([ + "custom-model", + "mimo-v2-flash", + ]); + }); +}); + describe("applyOpencodeZenProviderConfig", () => { it("adds allowlist entry for the default model", () => { const cfg = applyOpencodeZenProviderConfig({}); diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index b122d89cf..612b24865 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -17,6 +17,8 @@ export { applyVeniceProviderConfig, applyVercelAiGatewayConfig, applyVercelAiGatewayProviderConfig, + applyXiaomiConfig, + applyXiaomiProviderConfig, applyZaiConfig, } from "./onboard-auth.config-core.js"; export { @@ -44,9 +46,11 @@ export { setSyntheticApiKey, setVeniceApiKey, setVercelAiGatewayApiKey, + setXiaomiApiKey, setZaiApiKey, writeOAuthCredentials, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, + XIAOMI_DEFAULT_MODEL_REF, ZAI_DEFAULT_MODEL_REF, } from "./onboard-auth.credentials.js"; export { diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 7d952730c..46085acb5 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -17,6 +17,7 @@ import { applySyntheticConfig, applyVeniceConfig, applyVercelAiGatewayConfig, + applyXiaomiConfig, applyZaiConfig, setAnthropicApiKey, setGeminiApiKey, @@ -28,6 +29,7 @@ import { setSyntheticApiKey, setVeniceApiKey, setVercelAiGatewayApiKey, + setXiaomiApiKey, setZaiApiKey, } from "../../onboard-auth.js"; import type { AuthChoice, OnboardOptions } from "../../onboard-types.js"; @@ -177,6 +179,25 @@ export async function applyNonInteractiveAuthChoice(params: { return applyZaiConfig(nextConfig); } + if (authChoice === "xiaomi-api-key") { + const resolved = await resolveNonInteractiveApiKey({ + provider: "xiaomi", + cfg: baseConfig, + flagValue: opts.xiaomiApiKey, + flagName: "--xiaomi-api-key", + envVar: "XIAOMI_API_KEY", + runtime, + }); + if (!resolved) return null; + if (resolved.source !== "profile") await setXiaomiApiKey(resolved.key); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "xiaomi:default", + provider: "xiaomi", + mode: "api_key", + }); + return applyXiaomiConfig(nextConfig); + } + if (authChoice === "openai-api-key") { const resolved = await resolveNonInteractiveApiKey({ provider: "openai", diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index aa1d9afe0..f4154bc6d 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -23,6 +23,7 @@ export type AuthChoice = | "google-antigravity" | "google-gemini-cli" | "zai-api-key" + | "xiaomi-api-key" | "minimax-cloud" | "minimax" | "minimax-api" @@ -67,6 +68,7 @@ export type OnboardOptions = { kimiCodeApiKey?: string; geminiApiKey?: string; zaiApiKey?: string; + xiaomiApiKey?: string; minimaxApiKey?: string; syntheticApiKey?: string; veniceApiKey?: string; diff --git a/src/config/schema.ts b/src/config/schema.ts index b4ec8723b..28c994f3d 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -222,6 +222,7 @@ const FIELD_LABELS: Record = { "agents.defaults.memorySearch": "Memory Search", "agents.defaults.memorySearch.enabled": "Enable Memory Search", "agents.defaults.memorySearch.sources": "Memory Search Sources", + "agents.defaults.memorySearch.extraPaths": "Extra Memory Paths", "agents.defaults.memorySearch.experimental.sessionMemory": "Memory Search Session Index (Experimental)", "agents.defaults.memorySearch.provider": "Memory Search Provider", @@ -499,6 +500,8 @@ const FIELD_HELP: Record = { "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", "agents.defaults.memorySearch.sources": 'Sources to index for memory search (default: ["memory"]; add "sessions" to include session transcripts).', + "agents.defaults.memorySearch.extraPaths": + "Extra paths to include in memory search (directories or .md files; relative paths resolved from workspace).", "agents.defaults.memorySearch.experimental.sessionMemory": "Enable experimental session transcript indexing for memory search (default: false).", "agents.defaults.memorySearch.provider": 'Embedding provider ("openai", "gemini", or "local").', diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index bb1d45bf0..db32cb59d 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -226,6 +226,8 @@ export type MemorySearchConfig = { enabled?: boolean; /** Sources to index and search (default: ["memory"]). */ sources?: Array<"memory" | "sessions">; + /** Extra paths to include in memory search (directories or .md files). */ + extraPaths?: string[]; /** Experimental memory search settings. */ experimental?: { /** Enable session transcript indexing (experimental, default: false). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 7a63e307d..7e95c3538 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -304,6 +304,7 @@ export const MemorySearchSchema = z .object({ enabled: z.boolean().optional(), sources: z.array(z.union([z.literal("memory"), z.literal("sessions")])).optional(), + extraPaths: z.array(z.string()).optional(), experimental: z .object({ sessionMemory: z.boolean().optional(), diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts index 80bb5ff8f..bd4ec38ca 100644 --- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts +++ b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts @@ -135,7 +135,7 @@ describe("discord tool result dispatch", () => { expect(sendMock).toHaveBeenCalledTimes(1); }, 20_000); - it("skips guild messages when another user is explicitly mentioned", async () => { + it("accepts guild messages when mentionPatterns match even if another user is mentioned", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); const cfg = { agents: { @@ -211,8 +211,8 @@ describe("discord tool result dispatch", () => { client, ); - expect(dispatchMock).not.toHaveBeenCalled(); - expect(sendMock).not.toHaveBeenCalled(); + expect(dispatchMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledTimes(1); }, 20_000); it("accepts guild reply-to-bot messages as implicit mentions", async () => { diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index fa45bf3dc..bf05e2822 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -18,6 +18,7 @@ import { import { loadConfig } from "../config/config.js"; import { resolveMainSessionKey } from "../config/sessions.js"; import { logWarn } from "../logger.js"; +import { isTestDefaultMemorySlotDisabled } from "../plugins/config-state.js"; import { getPluginToolMeta } from "../plugins/tools.js"; import { isSubagentSessionKey } from "../routing/session-key.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; @@ -33,6 +34,7 @@ import { } from "./http-common.js"; const DEFAULT_BODY_BYTES = 2 * 1024 * 1024; +const MEMORY_TOOL_NAMES = new Set(["memory_search", "memory_get"]); type ToolsInvokeBody = { tool?: unknown; @@ -47,6 +49,26 @@ function resolveSessionKeyFromBody(body: ToolsInvokeBody): string | undefined { return undefined; } +function resolveMemoryToolDisableReasons(cfg: ReturnType): string[] { + if (!process.env.VITEST) return []; + const reasons: string[] = []; + const plugins = cfg.plugins; + const slotRaw = plugins?.slots?.memory; + const slotDisabled = + slotRaw === null || (typeof slotRaw === "string" && slotRaw.trim().toLowerCase() === "none"); + const pluginsDisabled = plugins?.enabled === false; + const defaultDisabled = isTestDefaultMemorySlotDisabled(cfg); + + if (pluginsDisabled) reasons.push("plugins.enabled=false"); + if (slotDisabled) { + reasons.push(slotRaw === null ? "plugins.slots.memory=null" : 'plugins.slots.memory="none"'); + } + if (!pluginsDisabled && !slotDisabled && defaultDisabled) { + reasons.push("memory plugin disabled by test default"); + } + return reasons; +} + function mergeActionIntoArgsIfSupported(params: { toolSchema: unknown; action: string | undefined; @@ -103,6 +125,23 @@ export async function handleToolsInvokeHttpRequest( return true; } + if (process.env.VITEST && MEMORY_TOOL_NAMES.has(toolName)) { + const reasons = resolveMemoryToolDisableReasons(cfg); + if (reasons.length > 0) { + const suffix = reasons.length > 0 ? ` (${reasons.join(", ")})` : ""; + sendJson(res, 400, { + ok: false, + error: { + type: "invalid_request", + message: + `memory tools are disabled in tests${suffix}. ` + + 'Enable by setting plugins.slots.memory="memory-core" (and ensure plugins.enabled is not false).', + }, + }); + return true; + } + } + const action = typeof body.action === "string" ? body.action.trim() : undefined; const argsRaw = body.args; diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index 90d73bb59..e0d9a6ef9 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -96,6 +96,33 @@ function resolveMinimaxApiKey(): string | undefined { return undefined; } +function resolveXiaomiApiKey(): string | undefined { + const envDirect = process.env.XIAOMI_API_KEY?.trim(); + if (envDirect) return envDirect; + + const envResolved = resolveEnvApiKey("xiaomi"); + if (envResolved?.apiKey) return envResolved.apiKey; + + const cfg = loadConfig(); + const key = getCustomProviderApiKey(cfg, "xiaomi"); + if (key) return key; + + const store = ensureAuthProfileStore(); + const apiProfile = listProfilesForProvider(store, "xiaomi").find((id) => { + const cred = store.profiles[id]; + return cred?.type === "api_key" || cred?.type === "token"; + }); + if (!apiProfile) return undefined; + const cred = store.profiles[apiProfile]; + if (cred?.type === "api_key" && cred.key?.trim()) { + return cred.key.trim(); + } + if (cred?.type === "token" && cred.token?.trim()) { + return cred.token.trim(); + } + return undefined; +} + async function resolveOAuthToken(params: { provider: UsageProviderId; agentDir?: string; @@ -199,6 +226,11 @@ export async function resolveProviderAuths(params: { if (apiKey) auths.push({ provider, token: apiKey }); continue; } + if (provider === "xiaomi") { + const apiKey = resolveXiaomiApiKey(); + if (apiKey) auths.push({ provider, token: apiKey }); + continue; + } if (!oauthProviders.includes(provider)) continue; const auth = await resolveOAuthToken({ diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index 39a97a86c..5eb101d85 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -66,6 +66,12 @@ export async function loadProviderUsageSummary( return await fetchCodexUsage(auth.token, auth.accountId, timeoutMs, fetchFn); case "minimax": return await fetchMinimaxUsage(auth.token, timeoutMs, fetchFn); + case "xiaomi": + return { + provider: "xiaomi", + displayName: PROVIDER_LABELS.xiaomi, + windows: [], + }; case "zai": return await fetchZaiUsage(auth.token, timeoutMs, fetchFn); default: diff --git a/src/infra/provider-usage.shared.ts b/src/infra/provider-usage.shared.ts index 6c8c1d9bb..55eca4757 100644 --- a/src/infra/provider-usage.shared.ts +++ b/src/infra/provider-usage.shared.ts @@ -10,6 +10,7 @@ export const PROVIDER_LABELS: Record = { "google-antigravity": "Antigravity", minimax: "MiniMax", "openai-codex": "Codex", + xiaomi: "Xiaomi", zai: "z.ai", }; @@ -20,6 +21,7 @@ export const usageProviders: UsageProviderId[] = [ "google-antigravity", "minimax", "openai-codex", + "xiaomi", "zai", ]; diff --git a/src/infra/provider-usage.types.ts b/src/infra/provider-usage.types.ts index cef446ceb..0a4637a7d 100644 --- a/src/infra/provider-usage.types.ts +++ b/src/infra/provider-usage.types.ts @@ -24,4 +24,5 @@ export type UsageProviderId = | "google-antigravity" | "minimax" | "openai-codex" + | "xiaomi" | "zai"; diff --git a/src/infra/ssh-config.test.ts b/src/infra/ssh-config.test.ts index 8f3248e0c..48a8bf310 100644 --- a/src/infra/ssh-config.test.ts +++ b/src/infra/ssh-config.test.ts @@ -54,6 +54,8 @@ describe("ssh-config", () => { expect(config?.host).toBe("peters-mac-studio-1.sheep-coho.ts.net"); expect(config?.port).toBe(2222); expect(config?.identityFiles).toEqual(["/tmp/id_ed25519"]); + const args = spawnMock.mock.calls[0]?.[1] as string[] | undefined; + expect(args?.slice(-2)).toEqual(["--", "me@alias"]); }); it("returns null when ssh -G fails", async () => { diff --git a/src/infra/ssh-config.ts b/src/infra/ssh-config.ts index 037405e8c..0b0e95015 100644 --- a/src/infra/ssh-config.ts +++ b/src/infra/ssh-config.ts @@ -58,7 +58,8 @@ export async function resolveSshConfig( args.push("-i", opts.identity.trim()); } const userHost = target.user ? `${target.user}@${target.host}` : target.host; - args.push(userHost); + // Use "--" so userHost can't be parsed as an ssh option. + args.push("--", userHost); return await new Promise((resolve) => { const child = spawn(sshPath, args, { diff --git a/src/infra/ssh-tunnel.test.ts b/src/infra/ssh-tunnel.test.ts new file mode 100644 index 000000000..d31f25d1a --- /dev/null +++ b/src/infra/ssh-tunnel.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { parseSshTarget } from "./ssh-tunnel.js"; + +describe("parseSshTarget", () => { + it("parses user@host:port targets", () => { + expect(parseSshTarget("me@example.com:2222")).toEqual({ + user: "me", + host: "example.com", + port: 2222, + }); + }); + + it("parses host-only targets with default port", () => { + expect(parseSshTarget("example.com")).toEqual({ + user: undefined, + host: "example.com", + port: 22, + }); + }); + + it("rejects hostnames that start with '-'", () => { + expect(parseSshTarget("-V")).toBeNull(); + expect(parseSshTarget("me@-badhost")).toBeNull(); + expect(parseSshTarget("-oProxyCommand=echo")).toBeNull(); + }); +}); diff --git a/src/infra/ssh-tunnel.ts b/src/infra/ssh-tunnel.ts index 8b3c7693b..399dc22e3 100644 --- a/src/infra/ssh-tunnel.ts +++ b/src/infra/ssh-tunnel.ts @@ -41,10 +41,14 @@ export function parseSshTarget(raw: string): SshParsedTarget | null { const portRaw = hostPart.slice(colonIdx + 1).trim(); const port = Number.parseInt(portRaw, 10); if (!host || !Number.isFinite(port) || port <= 0) return null; + // Security: Reject hostnames starting with '-' to prevent argument injection + if (host.startsWith("-")) return null; return { user: userPart, host, port }; } if (!hostPart) return null; + // Security: Reject hostnames starting with '-' to prevent argument injection + if (hostPart.startsWith("-")) return null; return { user: userPart, host: hostPart, port: 22 }; } @@ -134,7 +138,8 @@ export async function startSshPortForward(opts: { if (opts.identity?.trim()) { args.push("-i", opts.identity.trim()); } - args.push(userHost); + // Security: Use '--' to prevent userHost from being interpreted as an option + args.push("--", userHost); const stderr: string[] = []; const child = spawn("/usr/bin/ssh", args, { diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index 8bebb8e20..7a4d68136 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -41,7 +41,7 @@ describe("applyMediaUnderstanding", () => { mockedResolveApiKey.mockClear(); mockedFetchRemoteMedia.mockReset(); mockedFetchRemoteMedia.mockResolvedValue({ - buffer: Buffer.from("audio-bytes"), + buffer: Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), contentType: "audio/ogg", fileName: "note.ogg", }); @@ -51,7 +51,7 @@ describe("applyMediaUnderstanding", () => { const { applyMediaUnderstanding } = await loadApply(); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); const audioPath = path.join(dir, "note.ogg"); - await fs.writeFile(audioPath, "hello"); + await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8])); const ctx: MsgContext = { Body: "", @@ -94,7 +94,7 @@ describe("applyMediaUnderstanding", () => { const { applyMediaUnderstanding } = await loadApply(); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); const audioPath = path.join(dir, "note.ogg"); - await fs.writeFile(audioPath, "hello"); + await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8])); const ctx: MsgContext = { Body: " /capture status", @@ -176,7 +176,7 @@ describe("applyMediaUnderstanding", () => { const { applyMediaUnderstanding } = await loadApply(); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); const audioPath = path.join(dir, "large.wav"); - await fs.writeFile(audioPath, "0123456789"); + await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])); const ctx: MsgContext = { Body: "", @@ -211,7 +211,7 @@ describe("applyMediaUnderstanding", () => { const { applyMediaUnderstanding } = await loadApply(); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); const audioPath = path.join(dir, "note.ogg"); - await fs.writeFile(audioPath, "hello"); + await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8])); const ctx: MsgContext = { Body: "", @@ -352,7 +352,7 @@ describe("applyMediaUnderstanding", () => { const { applyMediaUnderstanding } = await loadApply(); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); const audioPath = path.join(dir, "fallback.ogg"); - await fs.writeFile(audioPath, "hello"); + await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6])); const ctx: MsgContext = { Body: "", @@ -390,8 +390,8 @@ describe("applyMediaUnderstanding", () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); const audioPathA = path.join(dir, "note-a.ogg"); const audioPathB = path.join(dir, "note-b.ogg"); - await fs.writeFile(audioPathA, "hello"); - await fs.writeFile(audioPathB, "world"); + await fs.writeFile(audioPathA, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208])); + await fs.writeFile(audioPathB, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208])); const ctx: MsgContext = { Body: "", @@ -435,7 +435,7 @@ describe("applyMediaUnderstanding", () => { const audioPath = path.join(dir, "note.ogg"); const videoPath = path.join(dir, "clip.mp4"); await fs.writeFile(imagePath, "image-bytes"); - await fs.writeFile(audioPath, "audio-bytes"); + await fs.writeFile(audioPath, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208])); await fs.writeFile(videoPath, "video-bytes"); const ctx: MsgContext = { @@ -487,4 +487,187 @@ describe("applyMediaUnderstanding", () => { expect(ctx.CommandBody).toBe("audio ok"); expect(ctx.BodyForCommands).toBe("audio ok"); }); + + it("treats text-like audio attachments as CSV (comma wins over tabs)", async () => { + const { applyMediaUnderstanding } = await loadApply(); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); + const csvPath = path.join(dir, "data.mp3"); + const csvText = '"a","b"\t"c"\n"1","2"\t"3"'; + const csvBuffer = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(csvText, "utf16le")]); + await fs.writeFile(csvPath, csvBuffer); + + const ctx: MsgContext = { + Body: "", + MediaPath: csvPath, + MediaType: "audio/mpeg", + }; + const cfg: MoltbotConfig = { + tools: { + media: { + audio: { enabled: false }, + image: { enabled: false }, + video: { enabled: false }, + }, + }, + }; + + const result = await applyMediaUnderstanding({ ctx, cfg }); + + expect(result.appliedFile).toBe(true); + expect(ctx.Body).toContain(''); + expect(ctx.Body).toContain('"a","b"\t"c"'); + }); + + it("infers TSV when tabs are present without commas", async () => { + const { applyMediaUnderstanding } = await loadApply(); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); + const tsvPath = path.join(dir, "report.mp3"); + const tsvText = "a\tb\tc\n1\t2\t3"; + await fs.writeFile(tsvPath, tsvText); + + const ctx: MsgContext = { + Body: "", + MediaPath: tsvPath, + MediaType: "audio/mpeg", + }; + const cfg: MoltbotConfig = { + tools: { + media: { + audio: { enabled: false }, + image: { enabled: false }, + video: { enabled: false }, + }, + }, + }; + + const result = await applyMediaUnderstanding({ ctx, cfg }); + + expect(result.appliedFile).toBe(true); + expect(ctx.Body).toContain(''); + expect(ctx.Body).toContain("a\tb\tc"); + }); + + it("escapes XML special characters in filenames to prevent injection", async () => { + const { applyMediaUnderstanding } = await loadApply(); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); + // Use & in filename — valid on all platforms (including Windows, which + // forbids < and > in NTFS filenames) and still requires XML escaping. + // Note: The sanitizeFilename in store.ts would strip most dangerous chars, + // but we test that even if some slip through, they get escaped in output + const filePath = path.join(dir, "file&test.txt"); + await fs.writeFile(filePath, "safe content"); + + const ctx: MsgContext = { + Body: "", + MediaPath: filePath, + MediaType: "text/plain", + }; + const cfg: MoltbotConfig = { + tools: { + media: { + audio: { enabled: false }, + image: { enabled: false }, + video: { enabled: false }, + }, + }, + }; + + const result = await applyMediaUnderstanding({ ctx, cfg }); + + expect(result.appliedFile).toBe(true); + // Verify XML special chars are escaped in the output + expect(ctx.Body).toContain("&"); + // The name attribute should contain the escaped form, not a raw unescaped & + expect(ctx.Body).toMatch(/name="file&test\.txt"/); + }); + + it("normalizes MIME types to prevent attribute injection", async () => { + const { applyMediaUnderstanding } = await loadApply(); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); + const filePath = path.join(dir, "data.txt"); + await fs.writeFile(filePath, "test content"); + + const ctx: MsgContext = { + Body: "", + MediaPath: filePath, + // Attempt to inject via MIME type with quotes - normalization should strip this + MediaType: 'text/plain" onclick="alert(1)', + }; + const cfg: MoltbotConfig = { + tools: { + media: { + audio: { enabled: false }, + image: { enabled: false }, + video: { enabled: false }, + }, + }, + }; + + const result = await applyMediaUnderstanding({ ctx, cfg }); + + expect(result.appliedFile).toBe(true); + // MIME normalization strips everything after first ; or " - verify injection is blocked + expect(ctx.Body).not.toContain("onclick="); + expect(ctx.Body).not.toContain("alert(1)"); + // Verify the MIME type is normalized to just "text/plain" + expect(ctx.Body).toContain('mime="text/plain"'); + }); + + it("handles path traversal attempts in filenames safely", async () => { + const { applyMediaUnderstanding } = await loadApply(); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); + // Even if a file somehow got a path-like name, it should be handled safely + const filePath = path.join(dir, "normal.txt"); + await fs.writeFile(filePath, "legitimate content"); + + const ctx: MsgContext = { + Body: "", + MediaPath: filePath, + MediaType: "text/plain", + }; + const cfg: MoltbotConfig = { + tools: { + media: { + audio: { enabled: false }, + image: { enabled: false }, + video: { enabled: false }, + }, + }, + }; + + const result = await applyMediaUnderstanding({ ctx, cfg }); + + expect(result.appliedFile).toBe(true); + // Verify the file was processed and output contains expected structure + expect(ctx.Body).toContain(' { + const { applyMediaUnderstanding } = await loadApply(); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); + const filePath = path.join(dir, "文档.txt"); + await fs.writeFile(filePath, "中文内容"); + + const ctx: MsgContext = { + Body: "", + MediaPath: filePath, + MediaType: "text/plain", + }; + const cfg: MoltbotConfig = { + tools: { + media: { + audio: { enabled: false }, + image: { enabled: false }, + video: { enabled: false }, + }, + }, + }; + + const result = await applyMediaUnderstanding({ ctx, cfg }); + + expect(result.appliedFile).toBe(true); + expect(ctx.Body).toContain("中文内容"); + }); }); diff --git a/src/media-understanding/apply.ts b/src/media-understanding/apply.ts index dab640789..7c2a18006 100644 --- a/src/media-understanding/apply.ts +++ b/src/media-understanding/apply.ts @@ -1,6 +1,22 @@ +import path from "node:path"; + import type { MoltbotConfig } from "../config/config.js"; import type { MsgContext } from "../auto-reply/templating.js"; import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; +import { logVerbose, shouldLogVerbose } from "../globals.js"; +import { + DEFAULT_INPUT_FILE_MAX_BYTES, + DEFAULT_INPUT_FILE_MAX_CHARS, + DEFAULT_INPUT_FILE_MIMES, + DEFAULT_INPUT_MAX_REDIRECTS, + DEFAULT_INPUT_PDF_MAX_PAGES, + DEFAULT_INPUT_PDF_MAX_PIXELS, + DEFAULT_INPUT_PDF_MIN_TEXT_CHARS, + DEFAULT_INPUT_TIMEOUT_MS, + extractFileContentFromSource, + normalizeMimeList, + normalizeMimeType, +} from "../media/input-files.js"; import { extractMediaUserText, formatAudioTranscripts, @@ -14,6 +30,7 @@ import type { } from "./types.js"; import { runWithConcurrency } from "./concurrency.js"; import { resolveConcurrency } from "./resolve.js"; +import { resolveAttachmentKind } from "./attachments.js"; import { type ActiveMediaModel, buildProviderRegistry, @@ -28,9 +45,279 @@ export type ApplyMediaUnderstandingResult = { appliedImage: boolean; appliedAudio: boolean; appliedVideo: boolean; + appliedFile: boolean; }; const CAPABILITY_ORDER: MediaUnderstandingCapability[] = ["image", "audio", "video"]; +const EXTRA_TEXT_MIMES = [ + "application/xml", + "text/xml", + "application/x-yaml", + "text/yaml", + "application/yaml", + "application/javascript", + "text/javascript", + "text/tab-separated-values", +]; +const TEXT_EXT_MIME = new Map([ + [".csv", "text/csv"], + [".tsv", "text/tab-separated-values"], + [".txt", "text/plain"], + [".md", "text/markdown"], + [".log", "text/plain"], + [".ini", "text/plain"], + [".cfg", "text/plain"], + [".conf", "text/plain"], + [".env", "text/plain"], + [".json", "application/json"], + [".yaml", "text/yaml"], + [".yml", "text/yaml"], + [".xml", "application/xml"], +]); + +const XML_ESCAPE_MAP: Record = { + "<": "<", + ">": ">", + "&": "&", + '"': """, + "'": "'", +}; + +/** + * Escapes special XML characters in attribute values to prevent injection. + */ +function xmlEscapeAttr(value: string): string { + return value.replace(/[<>&"']/g, (char) => XML_ESCAPE_MAP[char] ?? char); +} + +function resolveFileLimits(cfg: MoltbotConfig) { + const files = cfg.gateway?.http?.endpoints?.responses?.files; + return { + allowUrl: files?.allowUrl ?? true, + allowedMimes: normalizeMimeList(files?.allowedMimes, DEFAULT_INPUT_FILE_MIMES), + maxBytes: files?.maxBytes ?? DEFAULT_INPUT_FILE_MAX_BYTES, + maxChars: files?.maxChars ?? DEFAULT_INPUT_FILE_MAX_CHARS, + maxRedirects: files?.maxRedirects ?? DEFAULT_INPUT_MAX_REDIRECTS, + timeoutMs: files?.timeoutMs ?? DEFAULT_INPUT_TIMEOUT_MS, + pdf: { + maxPages: files?.pdf?.maxPages ?? DEFAULT_INPUT_PDF_MAX_PAGES, + maxPixels: files?.pdf?.maxPixels ?? DEFAULT_INPUT_PDF_MAX_PIXELS, + minTextChars: files?.pdf?.minTextChars ?? DEFAULT_INPUT_PDF_MIN_TEXT_CHARS, + }, + }; +} + +function appendFileBlocks(body: string | undefined, blocks: string[]): string { + if (!blocks || blocks.length === 0) { + return body ?? ""; + } + const base = typeof body === "string" ? body.trim() : ""; + const suffix = blocks.join("\n\n").trim(); + if (!base) { + return suffix; + } + return `${base}\n\n${suffix}`.trim(); +} + +function resolveUtf16Charset(buffer?: Buffer): "utf-16le" | "utf-16be" | undefined { + if (!buffer || buffer.length < 2) return undefined; + const b0 = buffer[0]; + const b1 = buffer[1]; + if (b0 === 0xff && b1 === 0xfe) { + return "utf-16le"; + } + if (b0 === 0xfe && b1 === 0xff) { + return "utf-16be"; + } + const sampleLen = Math.min(buffer.length, 2048); + let zeroCount = 0; + for (let i = 0; i < sampleLen; i += 1) { + if (buffer[i] === 0) zeroCount += 1; + } + if (zeroCount / sampleLen > 0.2) { + return "utf-16le"; + } + return undefined; +} + +function looksLikeUtf8Text(buffer?: Buffer): boolean { + if (!buffer || buffer.length === 0) return false; + const sampleLen = Math.min(buffer.length, 4096); + let printable = 0; + let other = 0; + for (let i = 0; i < sampleLen; i += 1) { + const byte = buffer[i]; + if (byte === 0) { + other += 1; + continue; + } + if (byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte <= 126)) { + printable += 1; + } else { + other += 1; + } + } + const total = printable + other; + if (total === 0) return false; + return printable / total > 0.85; +} + +function decodeTextSample(buffer?: Buffer): string { + if (!buffer || buffer.length === 0) return ""; + const sample = buffer.subarray(0, Math.min(buffer.length, 8192)); + const utf16Charset = resolveUtf16Charset(sample); + if (utf16Charset === "utf-16be") { + const swapped = Buffer.alloc(sample.length); + for (let i = 0; i + 1 < sample.length; i += 2) { + swapped[i] = sample[i + 1]; + swapped[i + 1] = sample[i]; + } + return new TextDecoder("utf-16le").decode(swapped); + } + if (utf16Charset === "utf-16le") { + return new TextDecoder("utf-16le").decode(sample); + } + return new TextDecoder("utf-8").decode(sample); +} + +function guessDelimitedMime(text: string): string | undefined { + if (!text) return undefined; + const line = text.split(/\r?\n/)[0] ?? ""; + const tabs = (line.match(/\t/g) ?? []).length; + const commas = (line.match(/,/g) ?? []).length; + if (commas > 0) { + return "text/csv"; + } + if (tabs > 0) { + return "text/tab-separated-values"; + } + return undefined; +} + +function resolveTextMimeFromName(name?: string): string | undefined { + if (!name) return undefined; + const ext = path.extname(name).toLowerCase(); + return TEXT_EXT_MIME.get(ext); +} + +async function extractFileBlocks(params: { + attachments: ReturnType; + cache: ReturnType; + limits: ReturnType; +}): Promise { + const { attachments, cache, limits } = params; + if (!attachments || attachments.length === 0) { + return []; + } + const blocks: string[] = []; + for (const attachment of attachments) { + if (!attachment) { + continue; + } + const forcedTextMime = resolveTextMimeFromName(attachment.path ?? attachment.url ?? ""); + const kind = forcedTextMime ? "document" : resolveAttachmentKind(attachment); + if (!forcedTextMime && (kind === "image" || kind === "video")) { + continue; + } + if (!limits.allowUrl && attachment.url && !attachment.path) { + if (shouldLogVerbose()) { + logVerbose(`media: file attachment skipped (url disabled) index=${attachment.index}`); + } + continue; + } + let bufferResult: Awaited>; + try { + bufferResult = await cache.getBuffer({ + attachmentIndex: attachment.index, + maxBytes: limits.maxBytes, + timeoutMs: limits.timeoutMs, + }); + } catch (err) { + if (shouldLogVerbose()) { + logVerbose(`media: file attachment skipped (buffer): ${String(err)}`); + } + continue; + } + const nameHint = bufferResult?.fileName ?? attachment.path ?? attachment.url; + const forcedTextMimeResolved = forcedTextMime ?? resolveTextMimeFromName(nameHint ?? ""); + const utf16Charset = resolveUtf16Charset(bufferResult?.buffer); + const textSample = decodeTextSample(bufferResult?.buffer); + const textLike = Boolean(utf16Charset) || looksLikeUtf8Text(bufferResult?.buffer); + if (!forcedTextMimeResolved && kind === "audio" && !textLike) { + continue; + } + const guessedDelimited = textLike ? guessDelimitedMime(textSample) : undefined; + const textHint = + forcedTextMimeResolved ?? guessedDelimited ?? (textLike ? "text/plain" : undefined); + const rawMime = bufferResult?.mime ?? attachment.mime; + const mimeType = textHint ?? normalizeMimeType(rawMime); + // Log when MIME type is overridden from non-text to text for auditability + if (textHint && rawMime && !rawMime.startsWith("text/")) { + logVerbose( + `media: MIME override from "${rawMime}" to "${textHint}" for index=${attachment.index}`, + ); + } + if (!mimeType) { + if (shouldLogVerbose()) { + logVerbose(`media: file attachment skipped (unknown mime) index=${attachment.index}`); + } + continue; + } + const allowedMimes = new Set(limits.allowedMimes); + for (const extra of EXTRA_TEXT_MIMES) { + allowedMimes.add(extra); + } + if (mimeType.startsWith("text/")) { + allowedMimes.add(mimeType); + } + if (!allowedMimes.has(mimeType)) { + if (shouldLogVerbose()) { + logVerbose( + `media: file attachment skipped (unsupported mime ${mimeType}) index=${attachment.index}`, + ); + } + continue; + } + let extracted: Awaited>; + try { + const mediaType = utf16Charset ? `${mimeType}; charset=${utf16Charset}` : mimeType; + extracted = await extractFileContentFromSource({ + source: { + type: "base64", + data: bufferResult.buffer.toString("base64"), + mediaType, + filename: bufferResult.fileName, + }, + limits: { + ...limits, + allowedMimes, + }, + }); + } catch (err) { + if (shouldLogVerbose()) { + logVerbose(`media: file attachment skipped (extract): ${String(err)}`); + } + continue; + } + const text = extracted?.text?.trim() ?? ""; + let blockText = text; + if (!blockText) { + if (extracted?.images && extracted.images.length > 0) { + blockText = "[PDF content rendered to images; images not forwarded to model]"; + } else { + blockText = "[No extractable text]"; + } + } + const safeName = (bufferResult.fileName ?? `file-${attachment.index + 1}`) + .replace(/[\r\n\t]+/g, " ") + .trim(); + // Escape XML special characters in attributes to prevent injection + blocks.push( + `\n${blockText}\n`, + ); + } + return blocks; +} export async function applyMediaUnderstanding(params: { ctx: MsgContext; @@ -51,6 +338,12 @@ export async function applyMediaUnderstanding(params: { const cache = createMediaAttachmentCache(attachments); try { + const fileBlocks = await extractFileBlocks({ + attachments, + cache, + limits: resolveFileLimits(cfg), + }); + const tasks = CAPABILITY_ORDER.map((capability) => async () => { const config = cfg.tools?.media?.[capability]; return await runCapability({ @@ -99,7 +392,15 @@ export async function applyMediaUnderstanding(params: { ctx.RawBody = originalUserText; } ctx.MediaUnderstanding = [...(ctx.MediaUnderstanding ?? []), ...outputs]; - finalizeInboundContext(ctx, { forceBodyForAgent: true, forceBodyForCommands: true }); + } + if (fileBlocks.length > 0) { + ctx.Body = appendFileBlocks(ctx.Body, fileBlocks); + } + if (outputs.length > 0 || fileBlocks.length > 0) { + finalizeInboundContext(ctx, { + forceBodyForAgent: true, + forceBodyForCommands: outputs.length > 0, + }); } return { @@ -108,6 +409,7 @@ export async function applyMediaUnderstanding(params: { appliedImage: outputs.some((output) => output.kind === "image.description"), appliedAudio: outputs.some((output) => output.kind === "audio.transcription"), appliedVideo: outputs.some((output) => output.kind === "video.description"), + appliedFile: fileBlocks.length > 0, }; } finally { await cache.cleanup(); diff --git a/src/memory/index.test.ts b/src/memory/index.test.ts index 58a98e580..cccd1fa49 100644 --- a/src/memory/index.test.ts +++ b/src/memory/index.test.ts @@ -412,4 +412,52 @@ describe("memory index", () => { manager = result.manager; await expect(result.manager.readFile({ relPath: "NOTES.md" })).rejects.toThrow("path required"); }); + + it("allows reading from additional memory paths and blocks symlinks", async () => { + const extraDir = path.join(workspaceDir, "extra"); + await fs.mkdir(extraDir, { recursive: true }); + await fs.writeFile(path.join(extraDir, "extra.md"), "Extra content."); + + const cfg = { + agents: { + defaults: { + workspace: workspaceDir, + memorySearch: { + provider: "openai", + model: "mock-embed", + store: { path: indexPath }, + sync: { watch: false, onSessionStart: false, onSearch: true }, + extraPaths: [extraDir], + }, + }, + list: [{ id: "main", default: true }], + }, + }; + const result = await getMemorySearchManager({ cfg, agentId: "main" }); + expect(result.manager).not.toBeNull(); + if (!result.manager) throw new Error("manager missing"); + manager = result.manager; + await expect(result.manager.readFile({ relPath: "extra/extra.md" })).resolves.toEqual({ + path: "extra/extra.md", + text: "Extra content.", + }); + + const linkPath = path.join(extraDir, "linked.md"); + let symlinkOk = true; + try { + await fs.symlink(path.join(extraDir, "extra.md"), linkPath, "file"); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "EPERM" || code === "EACCES") { + symlinkOk = false; + } else { + throw err; + } + } + if (symlinkOk) { + await expect(result.manager.readFile({ relPath: "extra/linked.md" })).rejects.toThrow( + "path required", + ); + } + }); }); diff --git a/src/memory/internal.test.ts b/src/memory/internal.test.ts index 29c698779..7530d8e44 100644 --- a/src/memory/internal.test.ts +++ b/src/memory/internal.test.ts @@ -1,6 +1,117 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; -import { chunkMarkdown } from "./internal.js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { chunkMarkdown, listMemoryFiles, normalizeExtraMemoryPaths } from "./internal.js"; + +describe("normalizeExtraMemoryPaths", () => { + it("trims, resolves, and dedupes paths", () => { + const workspaceDir = path.join(os.tmpdir(), "memory-test-workspace"); + const absPath = path.resolve(path.sep, "shared-notes"); + const result = normalizeExtraMemoryPaths(workspaceDir, [ + " notes ", + "./notes", + absPath, + absPath, + "", + ]); + expect(result).toEqual([path.resolve(workspaceDir, "notes"), absPath]); + }); +}); + +describe("listMemoryFiles", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-test-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("includes files from additional paths (directory)", async () => { + await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); + const extraDir = path.join(tmpDir, "extra-notes"); + await fs.mkdir(extraDir, { recursive: true }); + await fs.writeFile(path.join(extraDir, "note1.md"), "# Note 1"); + await fs.writeFile(path.join(extraDir, "note2.md"), "# Note 2"); + await fs.writeFile(path.join(extraDir, "ignore.txt"), "Not a markdown file"); + + const files = await listMemoryFiles(tmpDir, [extraDir]); + expect(files).toHaveLength(3); + expect(files.some((file) => file.endsWith("MEMORY.md"))).toBe(true); + expect(files.some((file) => file.endsWith("note1.md"))).toBe(true); + expect(files.some((file) => file.endsWith("note2.md"))).toBe(true); + expect(files.some((file) => file.endsWith("ignore.txt"))).toBe(false); + }); + + it("includes files from additional paths (single file)", async () => { + await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); + const singleFile = path.join(tmpDir, "standalone.md"); + await fs.writeFile(singleFile, "# Standalone"); + + const files = await listMemoryFiles(tmpDir, [singleFile]); + expect(files).toHaveLength(2); + expect(files.some((file) => file.endsWith("standalone.md"))).toBe(true); + }); + + it("handles relative paths in additional paths", async () => { + await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); + const extraDir = path.join(tmpDir, "subdir"); + await fs.mkdir(extraDir, { recursive: true }); + await fs.writeFile(path.join(extraDir, "nested.md"), "# Nested"); + + const files = await listMemoryFiles(tmpDir, ["subdir"]); + expect(files).toHaveLength(2); + expect(files.some((file) => file.endsWith("nested.md"))).toBe(true); + }); + + it("ignores non-existent additional paths", async () => { + await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); + + const files = await listMemoryFiles(tmpDir, ["/does/not/exist"]); + expect(files).toHaveLength(1); + }); + + it("ignores symlinked files and directories", async () => { + await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); + const extraDir = path.join(tmpDir, "extra"); + await fs.mkdir(extraDir, { recursive: true }); + await fs.writeFile(path.join(extraDir, "note.md"), "# Note"); + + const targetFile = path.join(tmpDir, "target.md"); + await fs.writeFile(targetFile, "# Target"); + const linkFile = path.join(extraDir, "linked.md"); + + const targetDir = path.join(tmpDir, "target-dir"); + await fs.mkdir(targetDir, { recursive: true }); + await fs.writeFile(path.join(targetDir, "nested.md"), "# Nested"); + const linkDir = path.join(tmpDir, "linked-dir"); + + let symlinksOk = true; + try { + await fs.symlink(targetFile, linkFile, "file"); + await fs.symlink(targetDir, linkDir, "dir"); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "EPERM" || code === "EACCES") { + symlinksOk = false; + } else { + throw err; + } + } + + const files = await listMemoryFiles(tmpDir, [extraDir, linkDir]); + expect(files.some((file) => file.endsWith("note.md"))).toBe(true); + if (symlinksOk) { + expect(files.some((file) => file.endsWith("linked.md"))).toBe(false); + expect(files.some((file) => file.endsWith("nested.md"))).toBe(false); + } + }); +}); describe("chunkMarkdown", () => { it("splits overly long lines into max-sized chunks", () => { diff --git a/src/memory/internal.ts b/src/memory/internal.ts index b68570c35..b2ab8c0a4 100644 --- a/src/memory/internal.ts +++ b/src/memory/internal.ts @@ -30,6 +30,17 @@ export function normalizeRelPath(value: string): string { return trimmed.replace(/\\/g, "/"); } +export function normalizeExtraMemoryPaths(workspaceDir: string, extraPaths?: string[]): string[] { + if (!extraPaths?.length) return []; + const resolved = extraPaths + .map((value) => value.trim()) + .filter(Boolean) + .map((value) => + path.isAbsolute(value) ? path.resolve(value) : path.resolve(workspaceDir, value), + ); + return Array.from(new Set(resolved)); +} + export function isMemoryPath(relPath: string): boolean { const normalized = normalizeRelPath(relPath); if (!normalized) return false; @@ -37,19 +48,11 @@ export function isMemoryPath(relPath: string): boolean { return normalized.startsWith("memory/"); } -async function exists(filePath: string): Promise { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } -} - async function walkDir(dir: string, files: string[]) { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const full = path.join(dir, entry.name); + if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) { await walkDir(full, files); continue; @@ -60,15 +63,48 @@ async function walkDir(dir: string, files: string[]) { } } -export async function listMemoryFiles(workspaceDir: string): Promise { +export async function listMemoryFiles( + workspaceDir: string, + extraPaths?: string[], +): Promise { const result: string[] = []; const memoryFile = path.join(workspaceDir, "MEMORY.md"); const altMemoryFile = path.join(workspaceDir, "memory.md"); - if (await exists(memoryFile)) result.push(memoryFile); - if (await exists(altMemoryFile)) result.push(altMemoryFile); const memoryDir = path.join(workspaceDir, "memory"); - if (await exists(memoryDir)) { - await walkDir(memoryDir, result); + + const addMarkdownFile = async (absPath: string) => { + try { + const stat = await fs.lstat(absPath); + if (stat.isSymbolicLink() || !stat.isFile()) return; + if (!absPath.endsWith(".md")) return; + result.push(absPath); + } catch {} + }; + + await addMarkdownFile(memoryFile); + await addMarkdownFile(altMemoryFile); + try { + const dirStat = await fs.lstat(memoryDir); + if (!dirStat.isSymbolicLink() && dirStat.isDirectory()) { + await walkDir(memoryDir, result); + } + } catch {} + + const normalizedExtraPaths = normalizeExtraMemoryPaths(workspaceDir, extraPaths); + if (normalizedExtraPaths.length > 0) { + for (const inputPath of normalizedExtraPaths) { + try { + const stat = await fs.lstat(inputPath); + if (stat.isSymbolicLink()) continue; + if (stat.isDirectory()) { + await walkDir(inputPath, result); + continue; + } + if (stat.isFile() && inputPath.endsWith(".md")) { + result.push(inputPath); + } + } catch {} + } } if (result.length <= 1) return result; const seen = new Set(); diff --git a/src/memory/manager-cache-key.ts b/src/memory/manager-cache-key.ts index 9fbe3e436..d143a9057 100644 --- a/src/memory/manager-cache-key.ts +++ b/src/memory/manager-cache-key.ts @@ -13,6 +13,7 @@ export function computeMemoryManagerCacheKey(params: { JSON.stringify({ enabled: settings.enabled, sources: [...settings.sources].sort((a, b) => a.localeCompare(b)), + extraPaths: [...settings.extraPaths].sort((a, b) => a.localeCompare(b)), provider: settings.provider, model: settings.model, fallback: settings.fallback, diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 9a9991d10..a799a5e0f 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; @@ -35,9 +36,9 @@ import { hashText, isMemoryPath, listMemoryFiles, + normalizeExtraMemoryPaths, type MemoryChunk, type MemoryFileEntry, - normalizeRelPath, parseEmbedding, } from "./internal.js"; import { bm25RankToScore, buildFtsQuery, mergeHybridResults } from "./hybrid.js"; @@ -396,13 +397,52 @@ export class MemoryIndexManager { from?: number; lines?: number; }): Promise<{ text: string; path: string }> { - const relPath = normalizeRelPath(params.relPath); - if (!relPath || !isMemoryPath(relPath)) { + const rawPath = params.relPath.trim(); + if (!rawPath) { throw new Error("path required"); } - const absPath = path.resolve(this.workspaceDir, relPath); - if (!absPath.startsWith(this.workspaceDir)) { - throw new Error("path escapes workspace"); + const absPath = path.isAbsolute(rawPath) + ? path.resolve(rawPath) + : path.resolve(this.workspaceDir, rawPath); + const relPath = path.relative(this.workspaceDir, absPath).replace(/\\/g, "/"); + const inWorkspace = + relPath.length > 0 && !relPath.startsWith("..") && !path.isAbsolute(relPath); + const allowedWorkspace = inWorkspace && isMemoryPath(relPath); + let allowedAdditional = false; + if (!allowedWorkspace && this.settings.extraPaths.length > 0) { + const additionalPaths = normalizeExtraMemoryPaths( + this.workspaceDir, + this.settings.extraPaths, + ); + for (const additionalPath of additionalPaths) { + try { + const stat = await fs.lstat(additionalPath); + if (stat.isSymbolicLink()) continue; + if (stat.isDirectory()) { + if (absPath === additionalPath || absPath.startsWith(`${additionalPath}${path.sep}`)) { + allowedAdditional = true; + break; + } + continue; + } + if (stat.isFile()) { + if (absPath === additionalPath && absPath.endsWith(".md")) { + allowedAdditional = true; + break; + } + } + } catch {} + } + } + if (!allowedWorkspace && !allowedAdditional) { + throw new Error("path required"); + } + if (!absPath.endsWith(".md")) { + throw new Error("path required"); + } + const stat = await fs.lstat(absPath); + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error("path required"); } const content = await fs.readFile(absPath, "utf-8"); if (!params.from && !params.lines) { @@ -425,6 +465,7 @@ export class MemoryIndexManager { model: string; requestedProvider: string; sources: MemorySource[]; + extraPaths: string[]; sourceCounts: Array<{ source: MemorySource; files: number; chunks: number }>; cache?: { enabled: boolean; entries?: number; maxEntries?: number }; fts?: { enabled: boolean; available: boolean; error?: string }; @@ -498,6 +539,7 @@ export class MemoryIndexManager { model: this.provider.model, requestedProvider: this.requestedProvider, sources: Array.from(this.sources), + extraPaths: this.settings.extraPaths, sourceCounts, cache: this.cache.enabled ? { @@ -769,11 +811,23 @@ export class MemoryIndexManager { private ensureWatcher() { if (!this.sources.has("memory") || !this.settings.sync.watch || this.watcher) return; - const watchPaths = [ + const additionalPaths = normalizeExtraMemoryPaths(this.workspaceDir, this.settings.extraPaths) + .map((entry) => { + try { + const stat = fsSync.lstatSync(entry); + return stat.isSymbolicLink() ? null : entry; + } catch { + return null; + } + }) + .filter((entry): entry is string => Boolean(entry)); + const watchPaths = new Set([ path.join(this.workspaceDir, "MEMORY.md"), + path.join(this.workspaceDir, "memory.md"), path.join(this.workspaceDir, "memory"), - ]; - this.watcher = chokidar.watch(watchPaths, { + ...additionalPaths, + ]); + this.watcher = chokidar.watch(Array.from(watchPaths), { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: this.settings.sync.watchDebounceMs, @@ -975,7 +1029,7 @@ export class MemoryIndexManager { needsFullReindex: boolean; progress?: MemorySyncProgressState; }) { - const files = await listMemoryFiles(this.workspaceDir); + const files = await listMemoryFiles(this.workspaceDir, this.settings.extraPaths); const fileEntries = await Promise.all( files.map(async (file) => buildFileEntry(file, this.workspaceDir)), ); diff --git a/src/memory/sync-memory-files.ts b/src/memory/sync-memory-files.ts index 53fed7ebe..c5073dc50 100644 --- a/src/memory/sync-memory-files.ts +++ b/src/memory/sync-memory-files.ts @@ -14,6 +14,7 @@ type ProgressState = { export async function syncMemoryFiles(params: { workspaceDir: string; + extraPaths?: string[]; db: DatabaseSync; needsFullReindex: boolean; progress?: ProgressState; @@ -27,7 +28,7 @@ export async function syncMemoryFiles(params: { ftsAvailable: boolean; model: string; }) { - const files = await listMemoryFiles(params.workspaceDir); + const files = await listMemoryFiles(params.workspaceDir, params.extraPaths); const fileEntries = await Promise.all( files.map(async (file) => buildFileEntry(file, params.workspaceDir)), ); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index bf44b5fe4..5bfff7dbc 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -64,6 +64,72 @@ export const normalizePluginsConfig = ( }; }; +const hasExplicitMemorySlot = (plugins?: MoltbotConfig["plugins"]) => + Boolean(plugins?.slots && Object.prototype.hasOwnProperty.call(plugins.slots, "memory")); + +const hasExplicitMemoryEntry = (plugins?: MoltbotConfig["plugins"]) => + Boolean(plugins?.entries && Object.prototype.hasOwnProperty.call(plugins.entries, "memory-core")); + +const hasExplicitPluginConfig = (plugins?: MoltbotConfig["plugins"]) => { + if (!plugins) return false; + if (typeof plugins.enabled === "boolean") return true; + if (Array.isArray(plugins.allow) && plugins.allow.length > 0) return true; + if (Array.isArray(plugins.deny) && plugins.deny.length > 0) return true; + if (plugins.load?.paths && Array.isArray(plugins.load.paths) && plugins.load.paths.length > 0) + return true; + if (plugins.slots && Object.keys(plugins.slots).length > 0) return true; + if (plugins.entries && Object.keys(plugins.entries).length > 0) return true; + return false; +}; + +export function applyTestPluginDefaults( + cfg: MoltbotConfig, + env: NodeJS.ProcessEnv = process.env, +): MoltbotConfig { + if (!env.VITEST) return cfg; + const plugins = cfg.plugins; + const explicitConfig = hasExplicitPluginConfig(plugins); + if (explicitConfig) { + if (hasExplicitMemorySlot(plugins) || hasExplicitMemoryEntry(plugins)) { + return cfg; + } + return { + ...cfg, + plugins: { + ...plugins, + slots: { + ...plugins?.slots, + memory: "none", + }, + }, + }; + } + + return { + ...cfg, + plugins: { + ...plugins, + enabled: false, + slots: { + ...plugins?.slots, + memory: "none", + }, + }, + }; +} + +export function isTestDefaultMemorySlotDisabled( + cfg: MoltbotConfig, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (!env.VITEST) return false; + const plugins = cfg.plugins; + if (hasExplicitMemorySlot(plugins) || hasExplicitMemoryEntry(plugins)) { + return false; + } + return true; +} + export function resolveEnableState( id: string, origin: PluginRecord["origin"], diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 174441bfc..79c785f27 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -10,6 +10,7 @@ import { resolveUserPath } from "../utils.js"; import { discoverMoltbotPlugins } from "./discovery.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; import { + applyTestPluginDefaults, normalizePluginsConfig, resolveEnableState, resolveMemorySlotDecision, @@ -162,7 +163,7 @@ function pushDiagnostics(diagnostics: PluginDiagnostic[], append: PluginDiagnost } export function loadMoltbotPlugins(options: PluginLoadOptions = {}): PluginRegistry { - const cfg = options.config ?? {}; + const cfg = applyTestPluginDefaults(options.config ?? {}); const logger = options.logger ?? defaultLogger(); const validateOnly = options.mode === "validate"; const normalized = normalizePluginsConfig(cfg.plugins); diff --git a/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index ce7015399..4481d7589 100644 --- a/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -392,7 +392,7 @@ describe("monitorSlackProvider tool results", () => { expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); }); - it("skips channel messages when another user is explicitly mentioned", async () => { + it("accepts channel messages when mentionPatterns match even if another user is mentioned", async () => { slackTestState.config = { messages: { responsePrefix: "PFX", @@ -433,8 +433,8 @@ describe("monitorSlackProvider tool results", () => { controller.abort(); await run; - expect(replyMock).not.toHaveBeenCalled(); - expect(sendMock).not.toHaveBeenCalled(); + expect(replyMock).toHaveBeenCalledTimes(1); + expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); }); it("treats replies to bot threads as implicit mentions", async () => { diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 832a4413d..abd06cdef 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -335,6 +335,7 @@ export const buildTelegramMessageContext = async ({ let placeholder = ""; if (msg.photo) placeholder = ""; else if (msg.video) placeholder = ""; + else if (msg.video_note) placeholder = ""; else if (msg.audio || msg.voice) placeholder = ""; else if (msg.document) placeholder = ""; else if (msg.sticker) placeholder = ""; diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index cead0628a..ea006e316 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -21,6 +21,8 @@ import { createTelegramDraftStream } from "./draft-stream.js"; import { cacheSticker, describeStickerImage } from "./sticker-cache.js"; import { resolveAgentDir } from "../agents/agent-scope.js"; +const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; + async function resolveStickerVisionSupport(cfg, agentId) { try { const catalog = await loadModelCatalog({ config: cfg }); @@ -198,6 +200,15 @@ export const dispatchTelegramMessage = async ({ } } + const replyQuoteText = + ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody + ? ctxPayload.ReplyToBody.trim() || undefined + : undefined; + const deliveryState = { + delivered: false, + skippedNonSilent: 0, + }; + const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg, @@ -209,12 +220,7 @@ export const dispatchTelegramMessage = async ({ await flushDraft(); draftStream?.stop(); } - - const replyQuoteText = - ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody - ? ctxPayload.ReplyToBody.trim() || undefined - : undefined; - await deliverReplies({ + const result = await deliverReplies({ replies: [payload], chatId: String(chatId), token: opts.token, @@ -229,6 +235,12 @@ export const dispatchTelegramMessage = async ({ linkPreview: telegramCfg.linkPreview, replyQuoteText, }); + if (result.delivered) { + deliveryState.delivered = true; + } + }, + onSkip: (_payload, info) => { + if (info.reason !== "silent") deliveryState.skippedNonSilent += 1; }, onError: (err, info) => { runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`)); @@ -260,7 +272,27 @@ export const dispatchTelegramMessage = async ({ }, }); draftStream?.stop(); - if (!queuedFinal) { + let sentFallback = false; + if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) { + const result = await deliverReplies({ + replies: [{ text: EMPTY_RESPONSE_FALLBACK }], + chatId: String(chatId), + token: opts.token, + runtime, + bot, + replyToMode, + textLimit, + messageThreadId: resolvedThreadId, + tableMode, + chunkMode, + linkPreview: telegramCfg.linkPreview, + replyQuoteText, + }); + sentFallback = result.delivered; + } + + const hasFinalResponse = queuedFinal || sentFallback; + if (!hasFinalResponse) { if (isGroup && historyKey) { clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit }); } diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 3415ea927..59f109a1f 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -50,6 +50,8 @@ import { import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js"; import { readTelegramAllowFromStore } from "./pairing-store.js"; +const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; + type TelegramNativeCommandContext = Context & { match?: string }; type TelegramCommandAuthResult = { @@ -468,6 +470,7 @@ export const registerTelegramNativeCommands = ({ CommandAuthorized: commandAuthorized, CommandSource: "native" as const, SessionKey: `telegram:slash:${senderId || chatId}`, + AccountId: route.accountId, CommandTargetSessionKey: sessionKey, MessageThreadId: threadIdForSend, IsForum: isForum, @@ -482,13 +485,18 @@ export const registerTelegramNativeCommands = ({ : undefined; const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId); + const deliveryState = { + delivered: false, + skippedNonSilent: 0, + }; + await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg, dispatcherOptions: { responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, - deliver: async (payload) => { - await deliverReplies({ + deliver: async (payload, _info) => { + const result = await deliverReplies({ replies: [payload], chatId: String(chatId), token: opts.token, @@ -501,6 +509,12 @@ export const registerTelegramNativeCommands = ({ chunkMode, linkPreview: telegramCfg.linkPreview, }); + if (result.delivered) { + deliveryState.delivered = true; + } + }, + onSkip: (_payload, info) => { + if (info.reason !== "silent") deliveryState.skippedNonSilent += 1; }, onError: (err, info) => { runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`)); @@ -511,6 +525,21 @@ export const registerTelegramNativeCommands = ({ disableBlockStreaming, }, }); + if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) { + await deliverReplies({ + replies: [{ text: EMPTY_RESPONSE_FALLBACK }], + chatId: String(chatId), + token: opts.token, + runtime, + bot, + replyToMode, + textLimit, + messageThreadId: threadIdForSend, + tableMode, + chunkMode, + linkPreview: telegramCfg.linkPreview, + }); + } }); } diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index 8bfe1fdd3..03aaeebd7 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -212,7 +212,7 @@ describe("createTelegramBot", () => { ); }); - it("skips group messages when another user is explicitly mentioned", async () => { + it("accepts group messages when mentionPatterns match even if another user is mentioned", async () => { onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); @@ -249,7 +249,8 @@ describe("createTelegramBot", () => { getFile: async () => ({ download: async () => new Uint8Array() }), }); - expect(replySpy).not.toHaveBeenCalled(); + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy.mock.calls[0][0].WasMentioned).toBe(true); }); it("keeps group envelope headers stable (sender identity is separate)", async () => { diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 4f45f9997..669340b20 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -44,7 +44,7 @@ export async function deliverReplies(params: { linkPreview?: boolean; /** Optional quote text for Telegram reply_parameters. */ replyQuoteText?: string; -}) { +}): Promise<{ delivered: boolean }> { const { replies, chatId, @@ -58,6 +58,10 @@ export async function deliverReplies(params: { } = params; const chunkMode = params.chunkMode ?? "length"; let hasReplied = false; + let hasDelivered = false; + const markDelivered = () => { + hasDelivered = true; + }; const chunkText = (markdown: string) => { const markdownChunks = chunkMode === "newline" @@ -114,6 +118,7 @@ export async function deliverReplies(params: { linkPreview, replyMarkup: shouldAttachButtons ? replyMarkup : undefined, }); + markDelivered(); if (replyToId && !hasReplied) { hasReplied = true; } @@ -165,18 +170,21 @@ export async function deliverReplies(params: { runtime, fn: () => bot.api.sendAnimation(chatId, file, { ...mediaParams }), }); + markDelivered(); } else if (kind === "image") { await withTelegramApiErrorLogging({ operation: "sendPhoto", runtime, fn: () => bot.api.sendPhoto(chatId, file, { ...mediaParams }), }); + markDelivered(); } else if (kind === "video") { await withTelegramApiErrorLogging({ operation: "sendVideo", runtime, fn: () => bot.api.sendVideo(chatId, file, { ...mediaParams }), }); + markDelivered(); } else if (kind === "audio") { const { useVoice } = resolveTelegramVoiceSend({ wantsVoice: reply.audioAsVoice === true, // default false (backward compatible) @@ -195,6 +203,7 @@ export async function deliverReplies(params: { shouldLog: (err) => !isVoiceMessagesForbidden(err), fn: () => bot.api.sendVoice(chatId, file, { ...mediaParams }), }); + markDelivered(); } catch (voiceErr) { // Fall back to text if voice messages are forbidden in this chat. // This happens when the recipient has Telegram Premium privacy settings @@ -221,6 +230,7 @@ export async function deliverReplies(params: { replyMarkup, replyQuoteText, }); + markDelivered(); // Skip this media item; continue with next. continue; } @@ -233,6 +243,7 @@ export async function deliverReplies(params: { runtime, fn: () => bot.api.sendAudio(chatId, file, { ...mediaParams }), }); + markDelivered(); } } else { await withTelegramApiErrorLogging({ @@ -240,6 +251,7 @@ export async function deliverReplies(params: { runtime, fn: () => bot.api.sendDocument(chatId, file, { ...mediaParams }), }); + markDelivered(); } if (replyToId && !hasReplied) { hasReplied = true; @@ -260,6 +272,7 @@ export async function deliverReplies(params: { linkPreview, replyMarkup: i === 0 ? replyMarkup : undefined, }); + markDelivered(); if (replyToId && !hasReplied) { hasReplied = true; } @@ -268,6 +281,8 @@ export async function deliverReplies(params: { } } } + + return { delivered: hasDelivered }; } export async function resolveMedia( @@ -310,7 +325,14 @@ export async function resolveMedia( fetchImpl, filePathHint: file.file_path, }); - const saved = await saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes); + const originalName = fetched.fileName ?? file.file_path; + const saved = await saveMediaBuffer( + fetched.buffer, + fetched.contentType, + "inbound", + maxBytes, + originalName, + ); // Check sticker cache for existing description const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null; @@ -361,7 +383,12 @@ export async function resolveMedia( } const m = - msg.photo?.[msg.photo.length - 1] ?? msg.video ?? msg.document ?? msg.audio ?? msg.voice; + msg.photo?.[msg.photo.length - 1] ?? + msg.video ?? + msg.video_note ?? + msg.document ?? + msg.audio ?? + msg.voice; if (!m?.file_id) return null; const file = await ctx.getFile(); if (!file.file_path) { @@ -377,10 +404,18 @@ export async function resolveMedia( fetchImpl, filePathHint: file.file_path, }); - const saved = await saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes); + const originalName = fetched.fileName ?? file.file_path; + const saved = await saveMediaBuffer( + fetched.buffer, + fetched.contentType, + "inbound", + maxBytes, + originalName, + ); let placeholder = ""; if (msg.photo) placeholder = ""; else if (msg.video) placeholder = ""; + else if (msg.video_note) placeholder = ""; else if (msg.audio || msg.voice) placeholder = ""; return { path: saved.path, contentType: saved.contentType, placeholder }; } diff --git a/src/telegram/download.ts b/src/telegram/download.ts index 1b3c61e22..31f431db0 100644 --- a/src/telegram/download.ts +++ b/src/telegram/download.ts @@ -40,7 +40,7 @@ export async function downloadTelegramFile( filePath: info.file_path, }); // save with inbound subdir - const saved = await saveMediaBuffer(array, mime, "inbound", maxBytes); + const saved = await saveMediaBuffer(array, mime, "inbound", maxBytes, info.file_path); // Ensure extension matches mime if possible if (!saved.contentType && mime) saved.contentType = mime; return saved; diff --git a/src/tts/tts.ts b/src/tts/tts.ts index af3d7fda5..faa83d3a6 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -757,11 +757,19 @@ export const OPENAI_TTS_MODELS = ["gpt-4o-mini-tts", "tts-1", "tts-1-hd"] as con * Custom OpenAI-compatible TTS endpoint. * When set, model/voice validation is relaxed to allow non-OpenAI models. * Example: OPENAI_TTS_BASE_URL=http://localhost:8880/v1 + * + * Note: Read at runtime (not module load) to support config.env loading. */ -const OPENAI_TTS_BASE_URL = ( - process.env.OPENAI_TTS_BASE_URL?.trim() || "https://api.openai.com/v1" -).replace(/\/+$/, ""); -const isCustomOpenAIEndpoint = OPENAI_TTS_BASE_URL !== "https://api.openai.com/v1"; +function getOpenAITtsBaseUrl(): string { + return (process.env.OPENAI_TTS_BASE_URL?.trim() || "https://api.openai.com/v1").replace( + /\/+$/, + "", + ); +} + +function isCustomOpenAIEndpoint(): boolean { + return getOpenAITtsBaseUrl() !== "https://api.openai.com/v1"; +} export const OPENAI_TTS_VOICES = [ "alloy", "ash", @@ -778,13 +786,13 @@ type OpenAiTtsVoice = (typeof OPENAI_TTS_VOICES)[number]; function isValidOpenAIModel(model: string): boolean { // Allow any model when using custom endpoint (e.g., Kokoro, LocalAI) - if (isCustomOpenAIEndpoint) return true; + if (isCustomOpenAIEndpoint()) return true; return OPENAI_TTS_MODELS.includes(model as (typeof OPENAI_TTS_MODELS)[number]); } function isValidOpenAIVoice(voice: string): voice is OpenAiTtsVoice { // Allow any voice when using custom endpoint (e.g., Kokoro Chinese voices) - if (isCustomOpenAIEndpoint) return true; + if (isCustomOpenAIEndpoint()) return true; return OPENAI_TTS_VOICES.includes(voice as OpenAiTtsVoice); } @@ -1011,7 +1019,7 @@ async function openaiTTS(params: { const timeout = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetch(`${OPENAI_TTS_BASE_URL}/audio/speech`, { + const response = await fetch(`${getOpenAITtsBaseUrl()}/audio/speech`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 77149f9ad..0b35fb445 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -21,6 +21,7 @@ type ChatHost = { basePath: string; hello: GatewayHelloOk | null; chatAvatarUrl: string | null; + refreshSessionsAfterChat: boolean; }; export function isChatBusy(host: ChatHost) { @@ -41,6 +42,14 @@ export function isChatStopCommand(text: string) { ); } +function isChatResetCommand(text: string) { + const trimmed = text.trim(); + if (!trimmed) return false; + const normalized = trimmed.toLowerCase(); + if (normalized === "/new" || normalized === "/reset") return true; + return normalized.startsWith("/new ") || normalized.startsWith("/reset "); +} + export async function handleAbortChat(host: ChatHost) { if (!host.connected) return; host.chatMessage = ""; @@ -71,6 +80,7 @@ async function sendChatMessageNow( attachments?: ChatAttachment[]; previousAttachments?: ChatAttachment[]; restoreAttachments?: boolean; + refreshSessions?: boolean; }, ) { resetToolStream(host as unknown as Parameters[0]); @@ -94,6 +104,9 @@ async function sendChatMessageNow( if (ok && !host.chatRunId) { void flushChatQueue(host); } + if (ok && opts?.refreshSessions) { + host.refreshSessionsAfterChat = true; + } return ok; } @@ -132,6 +145,7 @@ export async function handleSendChat( return; } + const refreshSessions = isChatResetCommand(message); if (messageOverride == null) { host.chatMessage = ""; // Clear attachments when sending @@ -149,13 +163,14 @@ export async function handleSendChat( attachments: hasAttachments ? attachmentsToSend : undefined, previousAttachments: messageOverride == null ? attachments : undefined, restoreAttachments: Boolean(messageOverride && opts?.restoreDraft), + refreshSessions, }); } export async function refreshChat(host: ChatHost) { await Promise.all([ loadChatHistory(host as unknown as MoltbotApp), - loadSessions(host as unknown as MoltbotApp), + loadSessions(host as unknown as MoltbotApp, { activeMinutes: 0 }), refreshChatAvatar(host), ]); scheduleChatScroll(host as unknown as Parameters[0], true); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index b2355709c..ba1df61e1 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -26,6 +26,7 @@ import { import type { MoltbotApp } from "./app"; import type { ExecApprovalRequest } from "./controllers/exec-approval"; import { loadAssistantIdentity } from "./controllers/assistant-identity"; +import { loadSessions } from "./controllers/sessions"; type GatewayHost = { settings: UiSettings; @@ -50,6 +51,7 @@ type GatewayHost = { assistantAgentId: string | null; sessionKey: string; chatRunId: string | null; + refreshSessionsAfterChat: boolean; execApprovalQueue: ExecApprovalRequest[]; execApprovalError: string | null; }; @@ -194,6 +196,12 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { void flushChatQueueForEvent( host as unknown as Parameters[0], ); + if (host.refreshSessionsAfterChat) { + host.refreshSessionsAfterChat = false; + if (state === "final") { + void loadSessions(host as unknown as MoltbotApp, { activeMinutes: 0 }); + } + } } if (state === "final") void loadChatHistory(host as unknown as MoltbotApp); return; diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 71af9d202..cf5214250 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -35,6 +35,9 @@ type LifecycleHost = { export function handleConnected(host: LifecycleHost) { host.basePath = inferBasePath(); + applySettingsFromUrl( + host as unknown as Parameters[0], + ); syncTabWithLocation( host as unknown as Parameters[0], true, @@ -46,9 +49,6 @@ export function handleConnected(host: LifecycleHost) { host as unknown as Parameters[0], ); window.addEventListener("popstate", host.popStateHandler); - applySettingsFromUrl( - host as unknown as Parameters[0], - ); connectGateway(host as unknown as Parameters[0]); startNodesPolling(host as unknown as Parameters[0]); if (host.tab === "logs") { diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 22f8d90db..c2190e1c9 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -5,6 +5,7 @@ import type { AppViewState } from "./app-view-state"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation"; import { icons } from "./icons"; import { loadChatHistory } from "./controllers/chat"; +import { refreshChat } from "./app-chat"; import { syncUrlWithSessionKey } from "./app-settings"; import type { SessionsListResult } from "./types"; import type { ThemeMode } from "./theme"; @@ -39,7 +40,12 @@ export function renderTab(state: AppViewState, tab: Tab) { } export function renderChatControls(state: AppViewState) { - const sessionOptions = resolveSessionOptions(state.sessionKey, state.sessionsResult); + const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult); + const sessionOptions = resolveSessionOptions( + state.sessionKey, + state.sessionsResult, + mainSessionKey, + ); const disableThinkingToggle = state.onboarding; const disableFocusToggle = state.onboarding; const showThinking = state.onboarding ? false : state.settings.chatShowThinking; @@ -87,9 +93,9 @@ export function renderChatControls(state: AppViewState) { ?disabled=${state.chatLoading || !state.connected} @click=${() => { state.resetToolStream(); - void loadChatHistory(state); + void refreshChat(state as unknown as Parameters[0]); }} - title="Refresh chat history" + title="Refresh chat data" > ${refreshIcon} @@ -132,15 +138,47 @@ export function renderChatControls(state: AppViewState) { `; } -function resolveSessionOptions(sessionKey: string, sessions: SessionsListResult | null) { +type SessionDefaultsSnapshot = { + mainSessionKey?: string; + mainKey?: string; +}; + +function resolveMainSessionKey( + hello: AppViewState["hello"], + sessions: SessionsListResult | null, +): string | null { + const snapshot = hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined; + const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim(); + if (mainSessionKey) return mainSessionKey; + const mainKey = snapshot?.sessionDefaults?.mainKey?.trim(); + if (mainKey) return mainKey; + if (sessions?.sessions?.some((row) => row.key === "main")) return "main"; + return null; +} + +function resolveSessionOptions( + sessionKey: string, + sessions: SessionsListResult | null, + mainSessionKey?: string | null, +) { const seen = new Set(); const options: Array<{ key: string; displayName?: string }> = []; + const resolvedMain = + mainSessionKey && sessions?.sessions?.find((s) => s.key === mainSessionKey); const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey); - // Add current session key first - seen.add(sessionKey); - options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName }); + // Add main session key first + if (mainSessionKey) { + seen.add(mainSessionKey); + options.push({ key: mainSessionKey, displayName: resolvedMain?.displayName }); + } + + // Add current session key next + if (!seen.has(sessionKey)) { + seen.add(sessionKey); + options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName }); + } // Add sessions from the result if (sessions?.sessions) { diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index a088c33ff..422af6863 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -42,6 +42,7 @@ import { renderNodes } from "./views/nodes"; import { renderOverview } from "./views/overview"; import { renderSessions } from "./views/sessions"; import { renderExecApprovalPrompt } from "./views/exec-approval"; +import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation"; import { approveDevicePairing, loadDevices, @@ -578,6 +579,7 @@ export function renderApp(state: AppViewState) { : nothing} ${renderExecApprovalPrompt(state)} + ${renderGatewayUrlConfirmation(state)} `; } diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index e269742b2..7e3ab29cf 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -33,6 +33,7 @@ type SettingsHost = { basePath: string; themeMedia: MediaQueryList | null; themeMediaHandler: ((event: MediaQueryListEvent) => void) | null; + pendingGatewayUrl?: string | null; }; export function applySettings(host: SettingsHost, next: UiSettings) { @@ -98,7 +99,7 @@ export function applySettingsFromUrl(host: SettingsHost) { if (gatewayUrlRaw != null) { const gatewayUrl = gatewayUrlRaw.trim(); if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) { - applySettings(host, { ...host.settings, gatewayUrl }); + host.pendingGatewayUrl = gatewayUrl; } params.delete("gatewayUrl"); shouldCleanUrl = true; diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 069465e32..f58656bfb 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -73,6 +73,7 @@ export type AppViewState = { execApprovalQueue: ExecApprovalRequest[]; execApprovalBusy: boolean; execApprovalError: string | null; + pendingGatewayUrl: string | null; configLoading: boolean; configRaw: string; configRawOriginal: string; @@ -165,6 +166,8 @@ export type AppViewState = { handleNostrProfileImport: () => Promise; handleNostrProfileToggleAdvanced: () => void; handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise; + handleGatewayUrlConfirm: () => void; + handleGatewayUrlCancel: () => void; handleConfigLoad: () => Promise; handleConfigSave: () => Promise; handleConfigApply: () => Promise; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index d23e543cd..50ffcdf76 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -152,6 +152,7 @@ export class MoltbotApp extends LitElement { @state() execApprovalQueue: ExecApprovalRequest[] = []; @state() execApprovalBusy = false; @state() execApprovalError: string | null = null; + @state() pendingGatewayUrl: string | null = null; @state() configLoading = false; @state() configRaw = "{\n}\n"; @@ -257,6 +258,7 @@ export class MoltbotApp extends LitElement { private logsScrollFrame: number | null = null; private toolStreamById = new Map(); private toolStreamOrder: string[] = []; + refreshSessionsAfterChat = false; basePath = ""; private popStateHandler = () => onPopStateInternal( @@ -448,6 +450,21 @@ export class MoltbotApp extends LitElement { } } + handleGatewayUrlConfirm() { + const nextGatewayUrl = this.pendingGatewayUrl; + if (!nextGatewayUrl) return; + this.pendingGatewayUrl = null; + applySettingsInternal( + this as unknown as Parameters[0], + { ...this.settings, gatewayUrl: nextGatewayUrl }, + ); + this.connect(); + } + + handleGatewayUrlCancel() { + this.pendingGatewayUrl = null; + } + // Sidebar handlers for tool output viewing handleOpenSidebar(content: string) { if (this.sidebarCloseTimer != null) { diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index 5c5077037..7e87f1911 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -14,18 +14,29 @@ export type SessionsState = { sessionsIncludeUnknown: boolean; }; -export async function loadSessions(state: SessionsState) { +export async function loadSessions( + state: SessionsState, + overrides?: { + activeMinutes?: number; + limit?: number; + includeGlobal?: boolean; + includeUnknown?: boolean; + }, +) { if (!state.client || !state.connected) return; if (state.sessionsLoading) return; state.sessionsLoading = true; state.sessionsError = null; try { + const includeGlobal = overrides?.includeGlobal ?? state.sessionsIncludeGlobal; + const includeUnknown = overrides?.includeUnknown ?? state.sessionsIncludeUnknown; + const activeMinutes = + overrides?.activeMinutes ?? toNumber(state.sessionsFilterActive, 0); + const limit = overrides?.limit ?? toNumber(state.sessionsFilterLimit, 0); const params: Record = { - includeGlobal: state.sessionsIncludeGlobal, - includeUnknown: state.sessionsIncludeUnknown, + includeGlobal, + includeUnknown, }; - const activeMinutes = toNumber(state.sessionsFilterActive, 0); - const limit = toNumber(state.sessionsFilterLimit, 0); if (activeMinutes > 0) params.activeMinutes = activeMinutes; if (limit > 0) params.limit = limit; const res = (await state.client.request("sessions.list", params)) as diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index 9d121d7f1..17a182281 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -260,6 +260,11 @@ function renderTextInput(params: { } onPatch(path, raw); }} + @change=${(e: Event) => { + if (inputType === "number") return; + const raw = (e.target as HTMLInputElement).value; + onPatch(path, raw.trim()); + }} /> ${schema.default !== undefined ? html` + + + + + `; +}