diff --git a/CHANGELOG.md b/CHANGELOG.md
index 37ae5fdf2..a134359f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,137 +2,63 @@
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.
-- TTS: read OPENAI_TTS_BASE_URL at runtime instead of module load to honor config.env. (#3341) Thanks @hclsys.
-- 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)
+- Telegram: preserve reasoning tags inside code blocks. (#3952) Thanks @vinaygit18.
+- 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:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/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/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 b11e9f539..db69e9114 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 9e7827726..785b37b8f 100644
--- a/src/agents/models-config.providers.ts
+++ b/src/agents/models-config.providers.ts
@@ -35,6 +35,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;
@@ -346,6 +357,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 {
@@ -415,6 +444,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/shared/text/reasoning-tags.test.ts b/src/shared/text/reasoning-tags.test.ts
new file mode 100644
index 000000000..d72d0cde2
--- /dev/null
+++ b/src/shared/text/reasoning-tags.test.ts
@@ -0,0 +1,218 @@
+import { describe, expect, it } from "vitest";
+import { stripReasoningTagsFromText } from "./reasoning-tags.js";
+
+describe("stripReasoningTagsFromText", () => {
+ describe("basic functionality", () => {
+ it("returns text unchanged when no reasoning tags present", () => {
+ const input = "Hello, this is a normal message.";
+ expect(stripReasoningTagsFromText(input)).toBe(input);
+ });
+
+ it("strips proper think tags", () => {
+ const input = "Hello internal reasoning world!";
+ expect(stripReasoningTagsFromText(input)).toBe("Hello world!");
+ });
+
+ it("strips thinking tags", () => {
+ const input = "Before some thought after";
+ expect(stripReasoningTagsFromText(input)).toBe("Before after");
+ });
+
+ it("strips thought tags", () => {
+ const input = "A hmm B";
+ expect(stripReasoningTagsFromText(input)).toBe("A B");
+ });
+
+ it("strips antthinking tags", () => {
+ const input = "X internal Y";
+ expect(stripReasoningTagsFromText(input)).toBe("X Y");
+ });
+
+ it("strips multiple reasoning blocks", () => {
+ const input = "firstAsecondB";
+ expect(stripReasoningTagsFromText(input)).toBe("AB");
+ });
+ });
+
+ describe("code block preservation (issue #3952)", () => {
+ it("preserves think tags inside fenced code blocks", () => {
+ const input = "Use the tag like this:\n```\nreasoning\n```\nThat's it!";
+ expect(stripReasoningTagsFromText(input)).toBe(input);
+ });
+
+ it("preserves think tags inside inline code", () => {
+ const input =
+ "The `` tag is used for reasoning. Don't forget the closing `` tag.";
+ expect(stripReasoningTagsFromText(input)).toBe(input);
+ });
+
+ it("preserves tags in fenced code blocks with language specifier", () => {
+ const input = "Example:\n```xml\n\n nested\n\n```\nDone!";
+ expect(stripReasoningTagsFromText(input)).toBe(input);
+ });
+
+ it("handles mixed real tags and code tags", () => {
+ const input = "hiddenVisible text with `` example.";
+ expect(stripReasoningTagsFromText(input)).toBe("Visible text with `` example.");
+ });
+
+ it("preserves both opening and closing tags in backticks", () => {
+ const input = "Use `` to open and `` to close.";
+ expect(stripReasoningTagsFromText(input)).toBe(input);
+ });
+
+ it("preserves think tags in code block at EOF without trailing newline", () => {
+ const input = "Example:\n```\nreasoning\n```";
+ expect(stripReasoningTagsFromText(input)).toBe(input);
+ });
+
+ it("preserves final tags inside code blocks", () => {
+ const input = "Use `` for final answers in code: ```\n42\n```";
+ expect(stripReasoningTagsFromText(input)).toBe(input);
+ });
+
+ it("handles code block followed by real tags", () => {
+ const input = "```\ncode\n```\nreal hiddenvisible";
+ expect(stripReasoningTagsFromText(input)).toBe("```\ncode\n```\nvisible");
+ });
+
+ it("handles multiple code blocks with tags", () => {
+ const input = "First `` then ```\nblock\n``` then ``";
+ expect(stripReasoningTagsFromText(input)).toBe(input);
+ });
+ });
+
+ describe("edge cases", () => {
+ it("preserves unclosed {
+ const input = "Here is how to use {
+ const input = "You can start with ";
+ expect(stripReasoningTagsFromText(input)).toBe(
+ "You can start with {
+ const input = "A < think >content< /think > B";
+ expect(stripReasoningTagsFromText(input)).toBe("A B");
+ });
+
+ it("handles empty input", () => {
+ expect(stripReasoningTagsFromText("")).toBe("");
+ });
+
+ it("handles null-ish input", () => {
+ expect(stripReasoningTagsFromText(null as unknown as string)).toBe(null);
+ });
+
+ it("preserves think tags inside tilde fenced code blocks", () => {
+ const input = "Example:\n~~~\nreasoning\n~~~\nDone!";
+ expect(stripReasoningTagsFromText(input)).toBe(input);
+ });
+
+ it("preserves tags in tilde block at EOF without trailing newline", () => {
+ const input = "Example:\n~~~js\ncode\n~~~";
+ expect(stripReasoningTagsFromText(input)).toBe(input);
+ });
+
+ it("handles nested think patterns (first close ends block)", () => {
+ const input = "outer inner still outervisible";
+ expect(stripReasoningTagsFromText(input)).toBe("still outervisible");
+ });
+
+ it("strips final tag markup but preserves content (by design)", () => {
+ const input = "A1B2C";
+ expect(stripReasoningTagsFromText(input)).toBe("A1B2C");
+ });
+
+ it("preserves final tags in inline code (markup only stripped outside)", () => {
+ const input = "`` in code, visible outside";
+ expect(stripReasoningTagsFromText(input)).toBe("`` in code, visible outside");
+ });
+
+ it("handles double backtick inline code with tags", () => {
+ const input = "Use ``code`` with hidden text";
+ expect(stripReasoningTagsFromText(input)).toBe("Use ``code`` with text");
+ });
+
+ it("handles fenced code blocks with content", () => {
+ const input = "Before\n```\ncode\n```\nAfter with hidden";
+ expect(stripReasoningTagsFromText(input)).toBe("Before\n```\ncode\n```\nAfter with");
+ });
+
+ it("does not match mismatched fence types (``` vs ~~~)", () => {
+ const input = "```\nnot protected\n~~~\ntext";
+ const result = stripReasoningTagsFromText(input);
+ expect(result).toBe(input);
+ });
+
+ it("handles unicode content inside and around tags", () => {
+ const input = "你好 思考 🤔 世界";
+ expect(stripReasoningTagsFromText(input)).toBe("你好 世界");
+ });
+
+ it("handles very long content between tags efficiently", () => {
+ const longContent = "x".repeat(10000);
+ const input = `${longContent}visible`;
+ expect(stripReasoningTagsFromText(input)).toBe("visible");
+ });
+
+ it("handles tags with attributes", () => {
+ const input = "A hidden B";
+ expect(stripReasoningTagsFromText(input)).toBe("A B");
+ });
+
+ it("is case-insensitive for tag names", () => {
+ const input = "A hidden also hidden B";
+ expect(stripReasoningTagsFromText(input)).toBe("A B");
+ });
+
+ it("handles pathological nested backtick patterns without hanging", () => {
+ const input = "`".repeat(100) + "test" + "`".repeat(100);
+ const start = Date.now();
+ stripReasoningTagsFromText(input);
+ const elapsed = Date.now() - start;
+ expect(elapsed).toBeLessThan(1000);
+ });
+
+ it("handles unclosed inline code gracefully", () => {
+ const input = "Start `unclosed hidden end";
+ const result = stripReasoningTagsFromText(input);
+ expect(result).toBe("Start `unclosed end");
+ });
+ });
+
+ describe("strict vs preserve mode", () => {
+ it("strict mode truncates on unclosed tag", () => {
+ const input = "Before unclosed content after";
+ expect(stripReasoningTagsFromText(input, { mode: "strict" })).toBe("Before");
+ });
+
+ it("preserve mode keeps content after unclosed tag", () => {
+ const input = "Before unclosed content after";
+ expect(stripReasoningTagsFromText(input, { mode: "preserve" })).toBe(
+ "Before unclosed content after",
+ );
+ });
+ });
+
+ describe("trim options", () => {
+ it("trims both sides by default", () => {
+ const input = " x result y ";
+ expect(stripReasoningTagsFromText(input)).toBe("result");
+ });
+
+ it("trim=none preserves whitespace", () => {
+ const input = " x result ";
+ expect(stripReasoningTagsFromText(input, { trim: "none" })).toBe(" result ");
+ });
+
+ it("trim=start only trims start", () => {
+ const input = " x result ";
+ expect(stripReasoningTagsFromText(input, { trim: "start" })).toBe("result ");
+ });
+ });
+});
diff --git a/src/shared/text/reasoning-tags.ts b/src/shared/text/reasoning-tags.ts
index 822138e55..afb8f891f 100644
--- a/src/shared/text/reasoning-tags.ts
+++ b/src/shared/text/reasoning-tags.ts
@@ -2,8 +2,40 @@ export type ReasoningTagMode = "strict" | "preserve";
export type ReasoningTagTrim = "none" | "start" | "both";
const QUICK_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking|final)\b/i;
-const FINAL_TAG_RE = /<\s*\/?\s*final\b[^>]*>/gi;
-const THINKING_TAG_RE = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\b[^>]*>/gi;
+const FINAL_TAG_RE = /<\s*\/?\s*final\b[^<>]*>/gi;
+const THINKING_TAG_RE = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>/gi;
+
+interface CodeRegion {
+ start: number;
+ end: number;
+}
+
+function findCodeRegions(text: string): CodeRegion[] {
+ const regions: CodeRegion[] = [];
+
+ const fencedRe = /(^|\n)(```|~~~)[^\n]*\n[\s\S]*?(?:\n\2(?:\n|$)|$)/g;
+ for (const match of text.matchAll(fencedRe)) {
+ const start = (match.index ?? 0) + match[1].length;
+ regions.push({ start, end: start + match[0].length - match[1].length });
+ }
+
+ const inlineRe = /`+[^`]+`+/g;
+ for (const match of text.matchAll(inlineRe)) {
+ const start = match.index ?? 0;
+ const end = start + match[0].length;
+ const insideFenced = regions.some((r) => start >= r.start && end <= r.end);
+ if (!insideFenced) {
+ regions.push({ start, end });
+ }
+ }
+
+ regions.sort((a, b) => a.start - b.start);
+ return regions;
+}
+
+function isInsideCode(pos: number, regions: CodeRegion[]): boolean {
+ return regions.some((r) => pos >= r.start && pos < r.end);
+}
function applyTrim(value: string, mode: ReasoningTagTrim): string {
if (mode === "none") return value;
@@ -27,11 +59,29 @@ export function stripReasoningTagsFromText(
let cleaned = text;
if (FINAL_TAG_RE.test(cleaned)) {
FINAL_TAG_RE.lastIndex = 0;
- cleaned = cleaned.replace(FINAL_TAG_RE, "");
+ const finalMatches: Array<{ start: number; length: number; inCode: boolean }> = [];
+ const preCodeRegions = findCodeRegions(cleaned);
+ for (const match of cleaned.matchAll(FINAL_TAG_RE)) {
+ const start = match.index ?? 0;
+ finalMatches.push({
+ start,
+ length: match[0].length,
+ inCode: isInsideCode(start, preCodeRegions),
+ });
+ }
+
+ for (let i = finalMatches.length - 1; i >= 0; i--) {
+ const m = finalMatches[i];
+ if (!m.inCode) {
+ cleaned = cleaned.slice(0, m.start) + cleaned.slice(m.start + m.length);
+ }
+ }
} else {
FINAL_TAG_RE.lastIndex = 0;
}
+ const codeRegions = findCodeRegions(cleaned);
+
THINKING_TAG_RE.lastIndex = 0;
let result = "";
let lastIndex = 0;
@@ -41,6 +91,10 @@ export function stripReasoningTagsFromText(
const idx = match.index ?? 0;
const isClose = match[1] === "/";
+ if (isInsideCode(idx, codeRegions)) {
+ continue;
+ }
+
if (!inThinking) {
result += cleaned.slice(lastIndex, idx);
if (!isClose) {
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/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.ts b/ui/src/ui/app.ts
index 26f4a5836..50ffcdf76 100644
--- a/ui/src/ui/app.ts
+++ b/ui/src/ui/app.ts
@@ -258,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(
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