Merge branch 'main' into main
This commit is contained in:
commit
4c82a8f104
15
.github/workflows/auto-response.yml
vendored
15
.github/workflows/auto-response.yml
vendored
@ -24,13 +24,26 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
// Labels prefixed with "r:" are auto-response triggers.
|
||||
const rules = [
|
||||
{
|
||||
label: "skill-clawdhub",
|
||||
label: "r: skill",
|
||||
close: true,
|
||||
message:
|
||||
"Thanks for the contribution! New skills should be published to Clawdhub for everyone to use. We’re keeping the core lean on skills, so I’m closing this out.",
|
||||
},
|
||||
{
|
||||
label: "r: support",
|
||||
close: true,
|
||||
message:
|
||||
"Please use our support server https://molt.bot/discord and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.molt.bot/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.",
|
||||
},
|
||||
{
|
||||
label: "r: third-party-extension",
|
||||
close: true,
|
||||
message:
|
||||
"This would be better made as a third-party extension with our SDK that you maintain yourself. Docs: https://docs.molt.bot/plugin.",
|
||||
},
|
||||
];
|
||||
|
||||
const labelName = context.payload.label?.name;
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@ -2,8 +2,8 @@
|
||||
|
||||
Docs: https://docs.molt.bot
|
||||
|
||||
## 2026.1.26
|
||||
Status: unreleased.
|
||||
## 2026.1.27-beta.1
|
||||
Status: beta.
|
||||
|
||||
### Changes
|
||||
- Rebrand: rename the npm package/CLI to `moltbot`, add a `moltbot` compatibility shim, and move extensions to the `@moltbot/*` scope.
|
||||
@ -51,6 +51,7 @@ Status: unreleased.
|
||||
- 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.
|
||||
@ -65,18 +66,28 @@ Status: unreleased.
|
||||
- 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.
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||
|
||||
<<<<<<< HEAD
|
||||
### Fixes
|
||||
- Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald.
|
||||
- Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald.
|
||||
- Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma.
|
||||
- Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94.
|
||||
- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355.
|
||||
- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.
|
||||
- Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101.
|
||||
- Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops.
|
||||
- Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky.
|
||||
- Discord: stop resolveDiscordTarget from passing directory params into messaging target parsers. Fixes #3167. Thanks @thewilloftheshadow.
|
||||
- Discord: avoid resolving bare channel names to user DMs when a username matches. Thanks @thewilloftheshadow.
|
||||
- Discord: fix directory config type import for target resolution. Thanks @thewilloftheshadow.
|
||||
- Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb.
|
||||
- Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent.
|
||||
- Discord: resolve usernames to user IDs for outbound messages. (#2649) Thanks @nonggialiang.
|
||||
- Providers: update Moonshot Kimi model references to kimi-k2.5. (#2762) Thanks @MarvinCui.
|
||||
- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg.
|
||||
- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg.
|
||||
- Security: pin npm overrides to keep tar@7.5.4 for install toolchains.
|
||||
@ -90,6 +101,7 @@ Status: unreleased.
|
||||
- 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.
|
||||
|
||||
@ -22,7 +22,7 @@ android {
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202601260
|
||||
versionName = "2026.1.26"
|
||||
versionName = "2026.1.27-beta.1"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.26</string>
|
||||
<string>2026.1.27-beta.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260126</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.26</string>
|
||||
<string>2026.1.27-beta.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260126</string>
|
||||
</dict>
|
||||
|
||||
@ -81,7 +81,7 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: Moltbot
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleShortVersionString: "2026.1.26"
|
||||
CFBundleShortVersionString: "2026.1.27-beta.1"
|
||||
CFBundleVersion: "20260126"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
@ -130,5 +130,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: MoltbotTests
|
||||
CFBundleShortVersionString: "2026.1.26"
|
||||
CFBundleShortVersionString: "2026.1.27-beta.1"
|
||||
CFBundleVersion: "20260126"
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.26</string>
|
||||
<string>2026.1.27-beta.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202601260</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
|
||||
@ -20,5 +20,5 @@ moltbot security audit --deep
|
||||
moltbot security audit --fix
|
||||
```
|
||||
|
||||
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
|
||||
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes.
|
||||
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
|
||||
|
||||
@ -130,9 +130,10 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
|
||||
|
||||
- Provider: `moonshot`
|
||||
- Auth: `MOONSHOT_API_KEY`
|
||||
- Example model: `moonshot/kimi-k2-0905-preview`
|
||||
- Example model: `moonshot/kimi-k2.5`
|
||||
- Kimi K2 model IDs:
|
||||
{/* moonshot-kimi-k2-model-refs:start */}
|
||||
- `moonshot/kimi-k2.5`
|
||||
- `moonshot/kimi-k2-0905-preview`
|
||||
- `moonshot/kimi-k2-turbo-preview`
|
||||
- `moonshot/kimi-k2-thinking`
|
||||
@ -141,7 +142,7 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: { model: { primary: "moonshot/kimi-k2-0905-preview" } }
|
||||
defaults: { model: { primary: "moonshot/kimi-k2.5" } }
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
@ -150,7 +151,7 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
apiKey: "${MOONSHOT_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "kimi-k2-0905-preview", name: "Kimi K2 0905 Preview" }]
|
||||
models: [{ id: "kimi-k2.5", name: "Kimi K2.5" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,8 @@ Use `session.dmScope` to control how **direct messages** are grouped:
|
||||
- `main` (default): all DMs share the main session for continuity.
|
||||
- `per-peer`: isolate by sender id across channels.
|
||||
- `per-channel-peer`: isolate by channel + sender (recommended for multi-user inboxes).
|
||||
Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
|
||||
- `per-account-channel-peer`: isolate by account + channel + sender (recommended for multi-account inboxes).
|
||||
Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer`, `per-channel-peer`, or `per-account-channel-peer`.
|
||||
|
||||
## Gateway is the source of truth
|
||||
All session state is **owned by the gateway** (the “master” Moltbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.
|
||||
@ -44,6 +45,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
||||
- Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation.
|
||||
- `per-peer`: `agent:<agentId>:dm:<peerId>`.
|
||||
- `per-channel-peer`: `agent:<agentId>:<channel>:dm:<peerId>`.
|
||||
- `per-account-channel-peer`: `agent:<agentId>:<channel>:<accountId>:dm:<peerId>` (accountId defaults to `default`).
|
||||
- If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `<peerId>` so the same person shares a session across channels.
|
||||
- Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`).
|
||||
- Telegram forum topics append `:topic:<threadId>` to the group id for isolation.
|
||||
@ -94,7 +96,7 @@ Send these as standalone messages so they register.
|
||||
{
|
||||
session: {
|
||||
scope: "per-sender", // keep group keys separate
|
||||
dmScope: "main", // DM continuity (set per-channel-peer for shared inboxes)
|
||||
dmScope: "main", // DM continuity (set per-channel-peer/per-account-channel-peer for shared inboxes)
|
||||
identityLinks: {
|
||||
alice: ["telegram:123456789", "discord:987654321012345678"]
|
||||
},
|
||||
|
||||
@ -2408,8 +2408,8 @@ Use Moonshot's OpenAI-compatible endpoint:
|
||||
env: { MOONSHOT_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "moonshot/kimi-k2-0905-preview" },
|
||||
models: { "moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" } }
|
||||
model: { primary: "moonshot/kimi-k2.5" },
|
||||
models: { "moonshot/kimi-k2.5": { alias: "Kimi K2.5" } }
|
||||
}
|
||||
},
|
||||
models: {
|
||||
@ -2421,8 +2421,8 @@ Use Moonshot's OpenAI-compatible endpoint:
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "kimi-k2-0905-preview",
|
||||
name: "Kimi K2 0905 Preview",
|
||||
id: "kimi-k2.5",
|
||||
name: "Kimi K2.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@ -2438,7 +2438,7 @@ Use Moonshot's OpenAI-compatible endpoint:
|
||||
|
||||
Notes:
|
||||
- Set `MOONSHOT_API_KEY` in the environment or use `moltbot onboard --auth-choice moonshot-api-key`.
|
||||
- Model ref: `moonshot/kimi-k2-0905-preview`.
|
||||
- Model ref: `moonshot/kimi-k2.5`.
|
||||
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
|
||||
|
||||
### Kimi Code
|
||||
@ -2669,7 +2669,8 @@ Fields:
|
||||
- `main`: all DMs share the main session for continuity.
|
||||
- `per-peer`: isolate DMs by sender id across channels.
|
||||
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
|
||||
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
|
||||
- `per-account-channel-peer`: isolate DMs per account + channel + sender (recommended for multi-account inboxes).
|
||||
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer`, `per-channel-peer`, or `per-account-channel-peer`.
|
||||
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
|
||||
- `reset`: primary reset policy. Defaults to daily resets at 4:00 AM local time on the gateway host.
|
||||
- `mode`: `daily` or `idle` (default: `daily` when `reset` is present).
|
||||
|
||||
@ -199,7 +199,7 @@ By default, Moltbot routes **all DMs into the main session** so your assistant h
|
||||
}
|
||||
```
|
||||
|
||||
This prevents cross-user context leakage while keeping group chats isolated. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
|
||||
This prevents cross-user context leakage while keeping group chats isolated. If you run multiple accounts on the same channel, use `per-account-channel-peer` instead. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
|
||||
|
||||
## Allowlists (DM + groups) — terminology
|
||||
|
||||
|
||||
@ -1026,7 +1026,7 @@ Docs: [Cron jobs](/automation/cron-jobs), [Cron vs Heartbeat](/automation/cron-v
|
||||
|
||||
**Can I run Apple macOS only skills from Linux**
|
||||
|
||||
Not directly. macOS skills are gated by `metadata.clawdbot.os` plus required binaries, and skills only appear in the system prompt when they are eligible on the **Gateway host**. On Linux, `darwin`-only skills (like `imsg`, `apple-notes`, `apple-reminders`) will not load unless you override the gating.
|
||||
Not directly. macOS skills are gated by `metadata.moltbot.os` plus required binaries, and skills only appear in the system prompt when they are eligible on the **Gateway host**. On Linux, `darwin`-only skills (like `imsg`, `apple-notes`, `apple-reminders`) will not load unless you override the gating.
|
||||
|
||||
You have three supported patterns:
|
||||
|
||||
|
||||
@ -149,7 +149,7 @@ No configuration needed.
|
||||
|
||||
### Metadata Fields
|
||||
|
||||
The `metadata.clawdbot` object supports:
|
||||
The `metadata.moltbot` object supports:
|
||||
|
||||
- **`emoji`**: Display emoji for CLI (e.g., `"💾"`)
|
||||
- **`events`**: Array of events to listen for (e.g., `["command:new", "command:reset"]`)
|
||||
|
||||
@ -7,40 +7,47 @@ read_when:
|
||||
|
||||
# exe.dev
|
||||
|
||||
Goal: Moltbot Gateway running on an exe.dev VM, reachable from your laptop via:
|
||||
- **exe.dev HTTPS proxy** (easy, no tunnel) or
|
||||
- **SSH tunnel** (most secure; loopback-only Gateway)
|
||||
Goal: Moltbot Gateway running on an exe.dev VM, reachable from your laptop via: `https://<vm-name>.exe.xyz`
|
||||
|
||||
This page assumes **Ubuntu/Debian**. If you picked a different distro, map packages accordingly.
|
||||
|
||||
If you’re on any other Linux VPS, the same steps apply — you just won’t use the exe.dev proxy commands.
|
||||
This page assumes exe.dev's default **exeuntu** image. If you picked a different distro, map packages accordingly.
|
||||
|
||||
## Beginner quick path
|
||||
|
||||
1) Create VM → install Node 22 → install Moltbot
|
||||
2) Run `moltbot onboard --install-daemon`
|
||||
3) Tunnel from laptop (`ssh -N -L 18789:127.0.0.1:18789 …`)
|
||||
4) Open `http://127.0.0.1:18789/` and paste your token
|
||||
1) [https://exe.new/moltbot](https://exe.new/moltbot)
|
||||
2) Fill in your auth key/token as needed
|
||||
3) Click on "Agent" next to your VM, and wait...
|
||||
4) ???
|
||||
5) Profit
|
||||
|
||||
## What you need
|
||||
|
||||
- exe.dev account + `ssh exe.dev` working on your laptop
|
||||
- SSH keys set up (your laptop → exe.dev)
|
||||
- Model auth (OAuth or API key) you want to use
|
||||
- Provider credentials (optional): WhatsApp QR scan, Telegram bot token, Discord bot token, …
|
||||
- exe.dev account
|
||||
- `ssh exe.dev` access to [exe.dev](https://exe.dev) virtual machines (optional)
|
||||
|
||||
|
||||
## Automated Install with Shelley
|
||||
|
||||
Shelley, [exe.dev](https://exe.dev)'s agent, can install Moltbot instantly with our
|
||||
prompt. The prompt used is as below:
|
||||
|
||||
```
|
||||
Set up Moltbot (https://docs.molt.bot/install) on this VM. Use the non-interactive and accept-risk flags for moltbot onboarding. Add the supplied auth or token as needed. Configure nginx to forward from the default port 18789 to the root location on the default enabled site config, making sure to enable Websocket support. Pairing is done by "moltbot devices list" and "moltbot device approve <request id>". Make sure the dashboard shows that Moltbot's health is OK. exe.dev handles forwarding from port 8000 to port 80/443 and HTTPS for us, so the final "reachable" should be <vm-name>.exe.xyz, without port specification.
|
||||
```
|
||||
|
||||
## Manual installation
|
||||
|
||||
## 1) Create the VM
|
||||
|
||||
From your laptop:
|
||||
From your device:
|
||||
|
||||
```bash
|
||||
ssh exe.dev new --name=moltbot
|
||||
ssh exe.dev new
|
||||
```
|
||||
|
||||
Then connect:
|
||||
|
||||
```bash
|
||||
ssh moltbot.exe.xyz
|
||||
ssh <vm-name>.exe.xyz
|
||||
```
|
||||
|
||||
Tip: keep this VM **stateful**. Moltbot stores state under `~/.clawdbot/` and `~/clawd/`.
|
||||
@ -52,130 +59,61 @@ sudo apt-get update
|
||||
sudo apt-get install -y git curl jq ca-certificates openssl
|
||||
```
|
||||
|
||||
### Node 22
|
||||
|
||||
Install Node **>= 22.12** (any method is fine). Quick check:
|
||||
|
||||
```bash
|
||||
node -v
|
||||
```
|
||||
|
||||
If you don’t already have Node 22 on the VM, use your preferred Node manager (nvm/mise/asdf) or a distro package source that provides Node 22+.
|
||||
|
||||
Common Ubuntu/Debian option (NodeSource):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
```
|
||||
|
||||
## 3) Install Moltbot
|
||||
|
||||
Recommended on servers: npm global install.
|
||||
Run the Moltbot install script:
|
||||
|
||||
```bash
|
||||
npm i -g moltbot@latest
|
||||
moltbot --version
|
||||
curl -fsSL https://molt.bot/install.sh | bash
|
||||
```
|
||||
|
||||
If native deps fail to install (rare; usually `sharp`), add build tools:
|
||||
## 4) Setup nginx to proxy Moltbot to port 8000
|
||||
|
||||
Edit `/etc/nginx/sites-enabled/default` with
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y build-essential python3
|
||||
```
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
listen 8000;
|
||||
listen [::]:8000;
|
||||
|
||||
## 4) First-time setup (wizard)
|
||||
server_name _;
|
||||
|
||||
Run the onboarding wizard on the VM:
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:18789;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
```bash
|
||||
moltbot onboard --install-daemon
|
||||
```
|
||||
# WebSocket support
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
It can set up:
|
||||
- `~/clawd` workspace bootstrap
|
||||
- `~/.clawdbot/moltbot.json` config
|
||||
- model auth profiles
|
||||
- model provider config/login
|
||||
- Linux systemd **user** service (service)
|
||||
# Standard proxy headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
If you’re doing OAuth on a headless VM: do OAuth on a normal machine first, then copy the auth profile to the VM (see [Help](/help)).
|
||||
|
||||
## 5) Remote access options
|
||||
|
||||
### Option A (recommended): SSH tunnel (loopback-only)
|
||||
|
||||
Keep Gateway on loopback (default) and tunnel it from your laptop:
|
||||
|
||||
```bash
|
||||
ssh -N -L 18789:127.0.0.1:18789 moltbot.exe.xyz
|
||||
```
|
||||
|
||||
Open locally:
|
||||
- `http://127.0.0.1:18789/` (Control UI)
|
||||
|
||||
Runbook: [Remote access](/gateway/remote)
|
||||
|
||||
### Option B: exe.dev HTTPS proxy (no tunnel)
|
||||
|
||||
To let exe.dev proxy traffic to the VM, bind the Gateway to the LAN interface and set a token:
|
||||
|
||||
```bash
|
||||
export CLAWDBOT_GATEWAY_TOKEN="$(openssl rand -hex 32)"
|
||||
moltbot gateway --bind lan --port 8080 --token "$CLAWDBOT_GATEWAY_TOKEN"
|
||||
```
|
||||
|
||||
For service runs, persist it in `~/.clawdbot/moltbot.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
mode: "local",
|
||||
port: 8080,
|
||||
bind: "lan",
|
||||
auth: { mode: "token", token: "YOUR_TOKEN" }
|
||||
}
|
||||
# Timeout settings for long-lived connections
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Non-loopback binds require `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
|
||||
- `gateway.remote.token` is only for remote CLI calls; it does not enable local auth.
|
||||
## 5) Access Moltbot and grant privileges
|
||||
|
||||
Then point exe.dev’s proxy at `8080` (or whatever port you chose) and open your VM’s HTTPS URL:
|
||||
Access `https://<vm-name>.exe.xyz/?token=YOUR-TOKEN-FROM-TERMINAL`. Approve
|
||||
devices with `moltbot devices list` and `moltbot device approve`. When in doubt,
|
||||
use Shelley from your browser!
|
||||
|
||||
```bash
|
||||
ssh exe.dev share port moltbot 8080
|
||||
```
|
||||
## Remote Access
|
||||
|
||||
Open:
|
||||
- `https://moltbot.exe.xyz/`
|
||||
Remote access is handled by [exe.dev](https://exe.dev)'s authentication. By
|
||||
default, HTTP traffic from port 8000 is forwarded to `https://<vm-name>.exe.xyz`
|
||||
with email auth.
|
||||
|
||||
In the Control UI, paste the token (UI → Settings → token). The UI sends it as `connect.params.auth.token`.
|
||||
|
||||
Notes:
|
||||
- Prefer a **non-default** port (like `8080`) if your proxy expects an app port.
|
||||
- Treat the token like a password.
|
||||
|
||||
Control UI details: [Control UI](/web/control-ui)
|
||||
|
||||
## 6) Keep it running (service)
|
||||
|
||||
On Linux, Moltbot uses a systemd **user** service. After `--install-daemon`, verify:
|
||||
|
||||
```bash
|
||||
systemctl --user status moltbot-gateway[-<profile>].service
|
||||
```
|
||||
|
||||
If the service dies after logout, enable lingering:
|
||||
|
||||
```bash
|
||||
sudo loginctl enable-linger "$USER"
|
||||
```
|
||||
|
||||
More: [Linux](/platforms/linux)
|
||||
|
||||
## 7) Updates
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
npm i -g moltbot@latest
|
||||
|
||||
@ -185,7 +185,7 @@ cat > /data/moltbot.json << 'EOF'
|
||||
"bind": "auto"
|
||||
},
|
||||
"meta": {
|
||||
"lastTouchedVersion": "2026.1.26"
|
||||
"lastTouchedVersion": "2026.1.27-beta.1"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
@ -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.26 \
|
||||
APP_VERSION=2026.1.27-beta.1 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
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.26.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/Moltbot.app dist/Moltbot-2026.1.27-beta.1.zip
|
||||
|
||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/Moltbot.app dist/Moltbot-2026.1.26.dmg
|
||||
scripts/create-dmg.sh dist/Moltbot.app dist/Moltbot-2026.1.27-beta.1.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.26.dmg
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=moltbot-notary \
|
||||
BUNDLE_ID=bot.molt.mac \
|
||||
APP_VERSION=2026.1.26 \
|
||||
APP_VERSION=2026.1.27-beta.1 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
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.26.dSYM.zip
|
||||
ditto -c -k --keepParent apps/macos/.build/release/Moltbot.app.dSYM dist/Moltbot-2026.1.27-beta.1.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.26.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.27-beta.1.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.26.zip` (and `Moltbot-2026.1.26.dSYM.zip`) to the GitHub release for tag `v2026.1.26`.
|
||||
- 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`.
|
||||
- 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.
|
||||
|
||||
@ -11,10 +11,10 @@ The macOS app surfaces Moltbot skills via the gateway; it does not parse skills
|
||||
## Data source
|
||||
- `skills.status` (gateway) returns all skills plus eligibility and missing requirements
|
||||
(including allowlist blocks for bundled skills).
|
||||
- Requirements are derived from `metadata.clawdbot.requires` in each `SKILL.md`.
|
||||
- Requirements are derived from `metadata.moltbot.requires` in each `SKILL.md`.
|
||||
|
||||
## Install actions
|
||||
- `metadata.clawdbot.install` defines install options (brew/node/go/uv).
|
||||
- `metadata.moltbot.install` defines install options (brew/node/go/uv).
|
||||
- The app calls `skills.install` to run installers on the gateway host.
|
||||
- The gateway surfaces only one preferred installer when multiple are provided
|
||||
(brew when available, otherwise node manager from `skills.install`, default npm).
|
||||
|
||||
@ -9,11 +9,12 @@ read_when:
|
||||
# Moonshot AI (Kimi)
|
||||
|
||||
Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the
|
||||
provider and set the default model to `moonshot/kimi-k2-0905-preview`, or use
|
||||
provider and set the default model to `moonshot/kimi-k2.5`, or use
|
||||
Kimi Code with `kimi-code/kimi-for-coding`.
|
||||
|
||||
Current Kimi K2 model IDs:
|
||||
{/* moonshot-kimi-k2-ids:start */}
|
||||
- `kimi-k2.5`
|
||||
- `kimi-k2-0905-preview`
|
||||
- `kimi-k2-turbo-preview`
|
||||
- `kimi-k2-thinking`
|
||||
@ -39,9 +40,10 @@ Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeabl
|
||||
env: { MOONSHOT_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "moonshot/kimi-k2-0905-preview" },
|
||||
model: { primary: "moonshot/kimi-k2.5" },
|
||||
models: {
|
||||
// moonshot-kimi-k2-aliases:start
|
||||
"moonshot/kimi-k2.5": { alias: "Kimi K2.5" },
|
||||
"moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" },
|
||||
"moonshot/kimi-k2-turbo-preview": { alias: "Kimi K2 Turbo" },
|
||||
"moonshot/kimi-k2-thinking": { alias: "Kimi K2 Thinking" },
|
||||
@ -59,6 +61,15 @@ Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeabl
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
// moonshot-kimi-k2-models:start
|
||||
{
|
||||
id: "kimi-k2.5",
|
||||
name: "Kimi K2.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 256000,
|
||||
maxTokens: 8192
|
||||
},
|
||||
{
|
||||
id: "kimi-k2-0905-preview",
|
||||
name: "Kimi K2 0905 Preview",
|
||||
|
||||
@ -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.26`).
|
||||
- [ ] Bump `package.json` version (e.g., `2026.1.27-beta.1`).
|
||||
- [ ] 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`.
|
||||
|
||||
@ -60,7 +60,7 @@ Per-skill fields:
|
||||
## Notes
|
||||
|
||||
- Keys under `entries` map to the skill name by default. If a skill defines
|
||||
`metadata.clawdbot.skillKey`, use that key instead.
|
||||
`metadata.moltbot.skillKey`, use that key instead.
|
||||
- Changes to skills are picked up on the next agent turn when the watcher is enabled.
|
||||
|
||||
### Sandboxed skills + env vars
|
||||
|
||||
@ -41,7 +41,7 @@ applies: workspace wins, then managed/local, then bundled.
|
||||
Plugins can ship their own skills by listing `skills` directories in
|
||||
`moltbot.plugin.json` (paths relative to the plugin root). Plugin skills load
|
||||
when the plugin is enabled and participate in the normal skill precedence rules.
|
||||
You can gate them via `metadata.clawdbot.requires.config` on the plugin’s config
|
||||
You can gate them via `metadata.moltbot.requires.config` on the plugin’s config
|
||||
entry. See [Plugins](/plugin) for discovery/config and [Tools](/tools) for the
|
||||
tool surface those skills teach.
|
||||
|
||||
@ -89,7 +89,7 @@ Notes:
|
||||
- `metadata` should be a **single-line JSON object**.
|
||||
- Use `{baseDir}` in instructions to reference the skill folder path.
|
||||
- Optional frontmatter keys:
|
||||
- `homepage` — URL surfaced as “Website” in the macOS Skills UI (also supported via `metadata.clawdbot.homepage`).
|
||||
- `homepage` — URL surfaced as “Website” in the macOS Skills UI (also supported via `metadata.moltbot.homepage`).
|
||||
- `user-invocable` — `true|false` (default: `true`). When `true`, the skill is exposed as a user slash command.
|
||||
- `disable-model-invocation` — `true|false` (default: `false`). When `true`, the skill is excluded from the model prompt (still available via user invocation).
|
||||
- `command-dispatch` — `tool` (optional). When set to `tool`, the slash command bypasses the model and dispatches directly to a tool.
|
||||
@ -111,7 +111,7 @@ metadata: {"moltbot":{"requires":{"bins":["uv"],"env":["GEMINI_API_KEY"],"config
|
||||
---
|
||||
```
|
||||
|
||||
Fields under `metadata.clawdbot`:
|
||||
Fields under `metadata.moltbot`:
|
||||
- `always: true` — always include the skill (skip other gates).
|
||||
- `emoji` — optional emoji used by the macOS Skills UI.
|
||||
- `homepage` — optional URL shown as “Website” in the macOS Skills UI.
|
||||
@ -152,7 +152,7 @@ Notes:
|
||||
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible.
|
||||
- Download installs: `url` (required), `archive` (`tar.gz` | `tar.bz2` | `zip`), `extract` (default: auto when archive detected), `stripComponents`, `targetDir` (default: `~/.clawdbot/tools/<skillKey>`).
|
||||
|
||||
If no `metadata.clawdbot` is present, the skill is always eligible (unless
|
||||
If no `metadata.moltbot` is present, the skill is always eligible (unless
|
||||
disabled in config or blocked by `skills.allowBundled` for bundled skills).
|
||||
|
||||
## Config overrides (`~/.clawdbot/moltbot.json`)
|
||||
@ -184,12 +184,12 @@ Bundled/managed skills can be toggled and supplied with env values:
|
||||
Note: if the skill name contains hyphens, quote the key (JSON5 allows quoted keys).
|
||||
|
||||
Config keys match the **skill name** by default. If a skill defines
|
||||
`metadata.clawdbot.skillKey`, use that key under `skills.entries`.
|
||||
`metadata.moltbot.skillKey`, use that key under `skills.entries`.
|
||||
|
||||
Rules:
|
||||
- `enabled: false` disables the skill even if it’s bundled/installed.
|
||||
- `env`: injected **only if** the variable isn’t already set in the process.
|
||||
- `apiKey`: convenience for skills that declare `metadata.clawdbot.primaryEnv`.
|
||||
- `apiKey`: convenience for skills that declare `metadata.moltbot.primaryEnv`.
|
||||
- `config`: optional bag for custom per-skill fields; custom keys must live here.
|
||||
- `allowBundled`: optional allowlist for **bundled** skills only. If set, only
|
||||
bundled skills in the list are eligible (managed/workspace skills unaffected).
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/bluebubbles",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot BlueBubbles channel plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/copilot-proxy",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot Copilot Proxy provider plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/diagnostics-otel",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot diagnostics OpenTelemetry exporter",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/discord",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot Discord channel plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/google-antigravity-auth",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot Google Antigravity OAuth provider plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/google-gemini-cli-auth",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot Gemini CLI OAuth provider plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/googlechat",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot Google Chat channel plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/imessage",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot iMessage channel plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/line",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot LINE channel plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/llm-task",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot JSON-only LLM task plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/lobster",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.27-beta.1
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.23
|
||||
|
||||
### Changes
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/matrix",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot Matrix channel plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/mattermost",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot Mattermost channel plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/memory-core",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot core memory search plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/memory-lancedb",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.27-beta.1
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.23
|
||||
|
||||
### Changes
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/msteams",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot Microsoft Teams channel plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/nextcloud-talk",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot Nextcloud Talk channel plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.27-beta.1
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.23
|
||||
|
||||
### Changes
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/nostr",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot Nostr channel plugin for NIP-04 encrypted DMs",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/open-prose",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/signal",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot Signal channel plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/slack",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot Slack channel plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/telegram",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot Telegram channel plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/tlon",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot Tlon/Urbit channel plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.27-beta.1
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.23
|
||||
|
||||
### Features
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/twitch",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"description": "Moltbot Twitch channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.27-beta.1
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.26
|
||||
|
||||
### Changes
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/voice-call",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot voice-call plugin",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/whatsapp",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot WhatsApp channel plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.27-beta.1
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.23
|
||||
|
||||
### Changes
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/zalo",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot Zalo channel plugin",
|
||||
"moltbot": {
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.27-beta.1
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.23
|
||||
|
||||
### Changes
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@moltbot/zalouser",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"type": "module",
|
||||
"description": "Moltbot Zalo Personal Account plugin via zca-cli",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moltbot",
|
||||
"version": "2026.1.26",
|
||||
"version": "2026.1.27-beta.1",
|
||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@ -23,6 +23,7 @@ function runPackDry(): PackResult[] {
|
||||
const raw = execSync("npm pack --dry-run --json --ignore-scripts", {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 1024 * 1024 * 100,
|
||||
});
|
||||
return JSON.parse(raw) as PackResult[];
|
||||
}
|
||||
|
||||
53
src/agents/channel-tools.test.ts
Normal file
53
src/agents/channel-tools.test.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { __testing, listAllChannelSupportedActions } from "./channel-tools.js";
|
||||
|
||||
describe("channel tools", () => {
|
||||
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
const plugin: ChannelPlugin = {
|
||||
id: "test",
|
||||
meta: {
|
||||
id: "test",
|
||||
label: "Test",
|
||||
selectionLabel: "Test",
|
||||
docsPath: "/channels/test",
|
||||
blurb: "test plugin",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
actions: {
|
||||
listActions: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
__testing.resetLoggedListActionErrors();
|
||||
errorSpy.mockClear();
|
||||
setActivePluginRegistry(createTestRegistry([{ pluginId: "test", source: "test", plugin }]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
errorSpy.mockClear();
|
||||
});
|
||||
|
||||
it("skips crashing plugins and logs once", () => {
|
||||
const cfg = {} as MoltbotConfig;
|
||||
expect(listAllChannelSupportedActions({ cfg })).toEqual([]);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(listAllChannelSupportedActions({ cfg })).toEqual([]);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -1,8 +1,13 @@
|
||||
import { getChannelDock } from "../channels/dock.js";
|
||||
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { normalizeAnyChannelId } from "../channels/registry.js";
|
||||
import type { ChannelAgentTool, ChannelMessageActionName } from "../channels/plugins/types.js";
|
||||
import type {
|
||||
ChannelAgentTool,
|
||||
ChannelMessageActionName,
|
||||
ChannelPlugin,
|
||||
} from "../channels/plugins/types.js";
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
|
||||
/**
|
||||
* Get the list of supported message actions for a specific channel.
|
||||
@ -16,7 +21,7 @@ export function listChannelSupportedActions(params: {
|
||||
const plugin = getChannelPlugin(params.channel as Parameters<typeof getChannelPlugin>[0]);
|
||||
if (!plugin?.actions?.listActions) return [];
|
||||
const cfg = params.cfg ?? ({} as MoltbotConfig);
|
||||
return plugin.actions.listActions({ cfg });
|
||||
return runPluginListActions(plugin, cfg);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -29,7 +34,7 @@ export function listAllChannelSupportedActions(params: {
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
if (!plugin.actions?.listActions) continue;
|
||||
const cfg = params.cfg ?? ({} as MoltbotConfig);
|
||||
const channelActions = plugin.actions.listActions({ cfg });
|
||||
const channelActions = runPluginListActions(plugin, cfg);
|
||||
for (const action of channelActions) {
|
||||
actions.add(action);
|
||||
}
|
||||
@ -64,3 +69,35 @@ export function resolveChannelMessageToolHints(params: {
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const loggedListActionErrors = new Set<string>();
|
||||
|
||||
function runPluginListActions(
|
||||
plugin: ChannelPlugin,
|
||||
cfg: MoltbotConfig,
|
||||
): ChannelMessageActionName[] {
|
||||
if (!plugin.actions?.listActions) return [];
|
||||
try {
|
||||
const listed = plugin.actions.listActions({ cfg });
|
||||
return Array.isArray(listed) ? listed : [];
|
||||
} catch (err) {
|
||||
logListActionsError(plugin.id, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function logListActionsError(pluginId: string, err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const key = `${pluginId}:${message}`;
|
||||
if (loggedListActionErrors.has(key)) return;
|
||||
loggedListActionErrors.add(key);
|
||||
const stack = err instanceof Error && err.stack ? err.stack : null;
|
||||
const details = stack ?? message;
|
||||
defaultRuntime.error?.(`[channel-tools] ${pluginId}.actions.listActions failed: ${details}`);
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resetLoggedListActionErrors() {
|
||||
loggedListActionErrors.clear();
|
||||
},
|
||||
};
|
||||
|
||||
@ -17,7 +17,7 @@ import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
|
||||
type ModelsConfig = NonNullable<MoltbotConfig["models"]>;
|
||||
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
|
||||
|
||||
const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic";
|
||||
const MINIMAX_API_BASE_URL = "https://api.minimax.chat/v1";
|
||||
const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.1";
|
||||
const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01";
|
||||
const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000;
|
||||
@ -31,7 +31,7 @@ const MINIMAX_API_COST = {
|
||||
};
|
||||
|
||||
const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1";
|
||||
const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2-0905-preview";
|
||||
const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5";
|
||||
const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000;
|
||||
const MOONSHOT_DEFAULT_MAX_TOKENS = 8192;
|
||||
const MOONSHOT_DEFAULT_COST = {
|
||||
@ -244,7 +244,7 @@ export function normalizeProviders(params: {
|
||||
function buildMinimaxProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: MINIMAX_API_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: MINIMAX_DEFAULT_MODEL_ID,
|
||||
@ -275,7 +275,7 @@ function buildMoonshotProvider(): ProviderConfig {
|
||||
models: [
|
||||
{
|
||||
id: MOONSHOT_DEFAULT_MODEL_ID,
|
||||
name: "Kimi K2 0905 Preview",
|
||||
name: "Kimi K2.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: MOONSHOT_DEFAULT_COST,
|
||||
|
||||
@ -136,7 +136,7 @@ describe("models-config", () => {
|
||||
}
|
||||
>;
|
||||
};
|
||||
expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic");
|
||||
expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.chat/v1");
|
||||
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
|
||||
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
|
||||
expect(ids).toContain("MiniMax-M2.1");
|
||||
|
||||
@ -275,7 +275,7 @@ describe("image tool MiniMax VLM routing", () => {
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
expect(String(url)).toBe("https://api.minimax.io/v1/coding_plan/vlm");
|
||||
expect(String(url)).toBe("https://api.minimax.chat/v1/coding_plan/vlm");
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(String((init?.headers as Record<string, string>)?.Authorization)).toBe(
|
||||
"Bearer minimax-test",
|
||||
|
||||
@ -51,7 +51,8 @@ function isOpenAiProvider(provider?: string | null): boolean {
|
||||
function isAnthropicApi(modelApi?: string | null, provider?: string | null): boolean {
|
||||
if (modelApi === "anthropic-messages") return true;
|
||||
const normalized = normalizeProviderId(provider ?? "");
|
||||
return normalized === "anthropic" || normalized === "minimax";
|
||||
// MiniMax now uses openai-completions API, not anthropic-messages
|
||||
return normalized === "anthropic";
|
||||
}
|
||||
|
||||
function isMistralModel(params: { provider?: string | null; modelId?: string | null }): boolean {
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
export type { DirectoryConfigParams } from "./plugins/directory-config.js";
|
||||
export type { ChannelDirectoryEntry } from "./plugins/types.js";
|
||||
|
||||
export type MessagingTargetKind = "user" | "channel";
|
||||
|
||||
export type MessagingTarget = {
|
||||
|
||||
@ -65,12 +65,13 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {}
|
||||
}
|
||||
|
||||
const LOBSTER_ASCII = [
|
||||
"░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀",
|
||||
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░",
|
||||
"█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░",
|
||||
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░",
|
||||
"░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░",
|
||||
" 🦞 FRESH DAILY 🦞",
|
||||
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
|
||||
"██░▄▀▄░██░▄▄▄░██░████▄▄░▄▄██░▄▄▀██░▄▄▄░█▄▄░▄▄██",
|
||||
"██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████",
|
||||
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
|
||||
" 🦞 FRESH DAILY 🦞 ",
|
||||
" ",
|
||||
];
|
||||
|
||||
export function formatCliBannerArt(options: BannerOptions = {}): string {
|
||||
|
||||
@ -168,6 +168,11 @@ const entries: SubCliEntry[] = [
|
||||
name: "pairing",
|
||||
description: "Pairing helpers",
|
||||
register: async (program) => {
|
||||
// Initialize plugins before registering pairing CLI.
|
||||
// The pairing CLI calls listPairingChannels() at registration time,
|
||||
// which requires the plugin registry to be populated with channel plugins.
|
||||
const { registerPluginCliCommands } = await import("../../plugins/cli.js");
|
||||
registerPluginCliCommands(program, await loadConfig());
|
||||
const mod = await import("../pairing-cli.js");
|
||||
mod.registerPairingCli(program);
|
||||
},
|
||||
|
||||
@ -128,7 +128,7 @@ function moveLegacyConfigFile(legacyPath: string, canonicalPath: string) {
|
||||
fs.mkdirSync(path.dirname(canonicalPath), { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
fs.renameSync(legacyPath, canonicalPath);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
fs.copyFileSync(legacyPath, canonicalPath);
|
||||
fs.chmodSync(canonicalPath, 0o600);
|
||||
try {
|
||||
|
||||
@ -124,7 +124,7 @@ export async function noteSecurityWarnings(cfg: MoltbotConfig) {
|
||||
|
||||
if (dmScope === "main" && isMultiUserDm) {
|
||||
warnings.push(
|
||||
`- ${params.label} DMs: multiple senders share the main session; set session.dmScope="per-channel-peer" to isolate sessions.`,
|
||||
`- ${params.label} DMs: multiple senders share the main session; set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate sessions.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -190,7 +190,7 @@ async function noteChannelPrimer(
|
||||
"DM security: default is pairing; unknown DMs get a pairing code.",
|
||||
`Approve with: ${formatCliCommand("moltbot pairing approve <channel> <code>")}`,
|
||||
'Public DMs require dmPolicy="open" + allowFrom=["*"].',
|
||||
'Multi-user DMs: set session.dmScope="per-channel-peer" to isolate sessions.',
|
||||
'Multi-user DMs: set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate sessions.',
|
||||
`Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`,
|
||||
"",
|
||||
...channelLines,
|
||||
@ -238,7 +238,7 @@ async function maybeConfigureDmPolicies(params: {
|
||||
`Approve: ${formatCliCommand(`moltbot pairing approve ${policy.channel} <code>`)}`,
|
||||
`Allowlist DMs: ${policy.policyKey}="allowlist" + ${policy.allowFromKey} entries.`,
|
||||
`Public DMs: ${policy.policyKey}="open" + ${policy.allowFromKey} includes "*".`,
|
||||
'Multi-user DMs: set session.dmScope="per-channel-peer" to isolate sessions.',
|
||||
'Multi-user DMs: set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate sessions.',
|
||||
`Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`,
|
||||
].join("\n"),
|
||||
`${policy.label} DM access`,
|
||||
|
||||
@ -64,12 +64,13 @@ export function randomToken(): string {
|
||||
|
||||
export function printWizardHeader(runtime: RuntimeEnv) {
|
||||
const header = [
|
||||
"░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀",
|
||||
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░",
|
||||
"█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░",
|
||||
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░",
|
||||
"░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░",
|
||||
" 🦞 FRESH DAILY 🦞",
|
||||
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
|
||||
"██░▄▀▄░██░▄▄▄░██░████▄▄░▄▄██░▄▄▀██░▄▄▄░█▄▄░▄▄██",
|
||||
"██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████",
|
||||
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
|
||||
" 🦞 FRESH DAILY 🦞 ",
|
||||
" ",
|
||||
].join("\n");
|
||||
runtime.log(header);
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
resolveDefaultConfigCandidates,
|
||||
resolveConfigPath,
|
||||
resolveOAuthDir,
|
||||
resolveOAuthPath,
|
||||
resolveStateDir,
|
||||
@ -69,6 +70,9 @@ describe("state + config path candidates", () => {
|
||||
it("CONFIG_PATH prefers existing legacy filename when present", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-config-"));
|
||||
const previousHome = process.env.HOME;
|
||||
const previousUserProfile = process.env.USERPROFILE;
|
||||
const previousHomeDrive = process.env.HOMEDRIVE;
|
||||
const previousHomePath = process.env.HOMEPATH;
|
||||
const previousMoltbotConfig = process.env.MOLTBOT_CONFIG_PATH;
|
||||
const previousClawdbotConfig = process.env.CLAWDBOT_CONFIG_PATH;
|
||||
const previousMoltbotState = process.env.MOLTBOT_STATE_DIR;
|
||||
@ -80,6 +84,12 @@ describe("state + config path candidates", () => {
|
||||
await fs.writeFile(legacyPath, "{}", "utf-8");
|
||||
|
||||
process.env.HOME = root;
|
||||
if (process.platform === "win32") {
|
||||
process.env.USERPROFILE = root;
|
||||
const parsed = path.win32.parse(root);
|
||||
process.env.HOMEDRIVE = parsed.root.replace(/\\$/, "");
|
||||
process.env.HOMEPATH = root.slice(parsed.root.length - 1);
|
||||
}
|
||||
delete process.env.MOLTBOT_CONFIG_PATH;
|
||||
delete process.env.CLAWDBOT_CONFIG_PATH;
|
||||
delete process.env.MOLTBOT_STATE_DIR;
|
||||
@ -94,6 +104,12 @@ describe("state + config path candidates", () => {
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = previousUserProfile;
|
||||
if (previousHomeDrive === undefined) delete process.env.HOMEDRIVE;
|
||||
else process.env.HOMEDRIVE = previousHomeDrive;
|
||||
if (previousHomePath === undefined) delete process.env.HOMEPATH;
|
||||
else process.env.HOMEPATH = previousHomePath;
|
||||
if (previousMoltbotConfig === undefined) delete process.env.MOLTBOT_CONFIG_PATH;
|
||||
else process.env.MOLTBOT_CONFIG_PATH = previousMoltbotConfig;
|
||||
if (previousClawdbotConfig === undefined) delete process.env.CLAWDBOT_CONFIG_PATH;
|
||||
@ -106,4 +122,21 @@ describe("state + config path candidates", () => {
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
|
||||
it("respects state dir overrides when config is missing", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-config-override-"));
|
||||
try {
|
||||
const legacyDir = path.join(root, ".clawdbot");
|
||||
await fs.mkdir(legacyDir, { recursive: true });
|
||||
const legacyConfig = path.join(legacyDir, "moltbot.json");
|
||||
await fs.writeFile(legacyConfig, "{}", "utf-8");
|
||||
|
||||
const overrideDir = path.join(root, "override");
|
||||
const env = { MOLTBOT_STATE_DIR: overrideDir } as NodeJS.ProcessEnv;
|
||||
const resolved = resolveConfigPath(env, overrideDir, () => root);
|
||||
expect(resolved).toBe(path.join(overrideDir, "moltbot.json"));
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -113,6 +113,7 @@ export function resolveConfigPath(
|
||||
): string {
|
||||
const override = env.MOLTBOT_CONFIG_PATH?.trim() || env.CLAWDBOT_CONFIG_PATH?.trim();
|
||||
if (override) return resolveUserPath(override);
|
||||
const stateOverride = env.MOLTBOT_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
|
||||
const candidates = [
|
||||
path.join(stateDir, CONFIG_FILENAME),
|
||||
path.join(stateDir, LEGACY_CONFIG_FILENAME),
|
||||
@ -125,6 +126,7 @@ export function resolveConfigPath(
|
||||
}
|
||||
});
|
||||
if (existing) return existing;
|
||||
if (stateOverride) return path.join(stateDir, CONFIG_FILENAME);
|
||||
const defaultStateDir = resolveStateDir(env, homedir);
|
||||
if (path.resolve(stateDir) === path.resolve(defaultStateDir)) {
|
||||
return resolveConfigPathCandidate(env, homedir);
|
||||
|
||||
@ -591,7 +591,7 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"commands.restart": "Allow /restart and gateway restart tool actions (default: false).",
|
||||
"commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.",
|
||||
"session.dmScope":
|
||||
'DM session scoping: "main" keeps continuity; "per-peer" or "per-channel-peer" isolates DM history (recommended for shared inboxes).',
|
||||
'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).',
|
||||
"session.identityLinks":
|
||||
"Map canonical identities to provider-prefixed peer IDs for DM session linking (example: telegram:123456).",
|
||||
"channels.telegram.configWrites":
|
||||
|
||||
@ -3,7 +3,7 @@ import type { NormalizedChatType } from "../channels/chat-type.js";
|
||||
export type ReplyMode = "text" | "command";
|
||||
export type TypingMode = "never" | "instant" | "thinking" | "message";
|
||||
export type SessionScope = "per-sender" | "global";
|
||||
export type DmScope = "main" | "per-peer" | "per-channel-peer";
|
||||
export type DmScope = "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
|
||||
export type ReplyToMode = "off" | "first" | "all";
|
||||
export type GroupPolicy = "open" | "disabled" | "allowlist";
|
||||
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
|
||||
|
||||
@ -20,7 +20,12 @@ export const SessionSchema = z
|
||||
.object({
|
||||
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
|
||||
dmScope: z
|
||||
.union([z.literal("main"), z.literal("per-peer"), z.literal("per-channel-peer")])
|
||||
.union([
|
||||
z.literal("main"),
|
||||
z.literal("per-peer"),
|
||||
z.literal("per-channel-peer"),
|
||||
z.literal("per-account-channel-peer"),
|
||||
])
|
||||
.optional(),
|
||||
identityLinks: z.record(z.string(), z.array(z.string())).optional(),
|
||||
resetTriggers: z.array(z.string()).optional(),
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js";
|
||||
import { LEGACY_MACOS_APP_SOURCES_DIR, MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js";
|
||||
import { CronPayloadSchema } from "../gateway/protocol/schema.js";
|
||||
|
||||
type SchemaLike = {
|
||||
@ -30,7 +30,26 @@ function extractCronChannels(schema: SchemaLike): string[] {
|
||||
|
||||
const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"];
|
||||
|
||||
const SWIFT_FILES = [`${MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`];
|
||||
const SWIFT_FILE_CANDIDATES = [
|
||||
`${MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`,
|
||||
`${LEGACY_MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`,
|
||||
];
|
||||
|
||||
async function resolveSwiftFiles(cwd: string): Promise<string[]> {
|
||||
const matches: string[] = [];
|
||||
for (const relPath of SWIFT_FILE_CANDIDATES) {
|
||||
try {
|
||||
await fs.access(path.join(cwd, relPath));
|
||||
matches.push(relPath);
|
||||
} catch {
|
||||
// ignore missing path
|
||||
}
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`Missing Swift cron definition. Tried: ${SWIFT_FILE_CANDIDATES.join(", ")}`);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
describe("cron protocol conformance", () => {
|
||||
it("ui + swift include all cron providers from gateway schema", async () => {
|
||||
@ -45,7 +64,8 @@ describe("cron protocol conformance", () => {
|
||||
}
|
||||
}
|
||||
|
||||
for (const relPath of SWIFT_FILES) {
|
||||
const swiftFiles = await resolveSwiftFiles(cwd);
|
||||
for (const relPath of swiftFiles) {
|
||||
const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
|
||||
for (const channel of channels) {
|
||||
const pattern = new RegExp(`\\bcase\\s+${channel}\\b`);
|
||||
@ -61,7 +81,8 @@ describe("cron protocol conformance", () => {
|
||||
expect(uiTypes.includes("jobs:")).toBe(true);
|
||||
expect(uiTypes.includes("jobCount")).toBe(false);
|
||||
|
||||
const swiftPath = path.join(cwd, SWIFT_FILES[0]);
|
||||
const [swiftRelPath] = await resolveSwiftFiles(cwd);
|
||||
const swiftPath = path.join(cwd, swiftRelPath);
|
||||
const swift = await fs.readFile(swiftPath, "utf-8");
|
||||
expect(swift.includes("struct CronSchedulerStatus")).toBe(true);
|
||||
expect(swift.includes("let jobs:")).toBe(true);
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
createDiscordClient,
|
||||
normalizeDiscordPollInput,
|
||||
normalizeStickerIds,
|
||||
parseRecipient,
|
||||
parseAndResolveRecipient,
|
||||
resolveChannelId,
|
||||
sendDiscordMedia,
|
||||
sendDiscordText,
|
||||
@ -49,7 +49,7 @@ export async function sendMessageDiscord(
|
||||
const chunkMode = resolveChunkMode(cfg, "discord", accountInfo.accountId);
|
||||
const textWithTables = convertMarkdownTables(text ?? "", tableMode);
|
||||
const { token, rest, request } = createDiscordClient(opts, cfg);
|
||||
const recipient = parseRecipient(to);
|
||||
const recipient = await parseAndResolveRecipient(to, opts.accountId);
|
||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||
let result: { id: string; channel_id: string } | { id: string | null; channel_id: string };
|
||||
try {
|
||||
@ -104,7 +104,7 @@ export async function sendStickerDiscord(
|
||||
): Promise<DiscordSendResult> {
|
||||
const cfg = loadConfig();
|
||||
const { rest, request } = createDiscordClient(opts, cfg);
|
||||
const recipient = parseRecipient(to);
|
||||
const recipient = await parseAndResolveRecipient(to, opts.accountId);
|
||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||
const content = opts.content?.trim();
|
||||
const stickers = normalizeStickerIds(stickerIds);
|
||||
@ -131,7 +131,7 @@ export async function sendPollDiscord(
|
||||
): Promise<DiscordSendResult> {
|
||||
const cfg = loadConfig();
|
||||
const { rest, request } = createDiscordClient(opts, cfg);
|
||||
const recipient = parseRecipient(to);
|
||||
const recipient = await parseAndResolveRecipient(to, opts.accountId);
|
||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||
const content = opts.content?.trim();
|
||||
const payload = normalizeDiscordPollInput(poll);
|
||||
|
||||
@ -13,7 +13,7 @@ import type { ChunkMode } from "../auto-reply/chunk.js";
|
||||
import { chunkDiscordTextWithMode } from "./chunk.js";
|
||||
import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js";
|
||||
import { DiscordSendError } from "./send.types.js";
|
||||
import { parseDiscordTarget } from "./targets.js";
|
||||
import { parseDiscordTarget, resolveDiscordTarget } from "./targets.js";
|
||||
import { normalizeDiscordToken } from "./token.js";
|
||||
|
||||
const DISCORD_TEXT_LIMIT = 2000;
|
||||
@ -101,6 +101,51 @@ function parseRecipient(raw: string): DiscordRecipient {
|
||||
return { kind: target.kind, id: target.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and resolve Discord recipient, including username lookup.
|
||||
* This enables sending DMs by username (e.g., "john.doe") by querying
|
||||
* the Discord directory to resolve usernames to user IDs.
|
||||
*
|
||||
* @param raw - The recipient string (username, ID, or known format)
|
||||
* @param accountId - Discord account ID to use for directory lookup
|
||||
* @returns Parsed DiscordRecipient with resolved user ID if applicable
|
||||
*/
|
||||
export async function parseAndResolveRecipient(
|
||||
raw: string,
|
||||
accountId?: string,
|
||||
): Promise<DiscordRecipient> {
|
||||
const cfg = loadConfig();
|
||||
const accountInfo = resolveDiscordAccount({ cfg, accountId });
|
||||
|
||||
// First try to resolve using directory lookup (handles usernames)
|
||||
const trimmed = raw.trim();
|
||||
const parseOptions = {
|
||||
ambiguousMessage: `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`,
|
||||
};
|
||||
|
||||
const resolved = await resolveDiscordTarget(
|
||||
raw,
|
||||
{
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
},
|
||||
parseOptions,
|
||||
);
|
||||
|
||||
if (resolved) {
|
||||
return { kind: resolved.kind, id: resolved.id };
|
||||
}
|
||||
|
||||
// Fallback to standard parsing (for channels, etc.)
|
||||
const parsed = parseDiscordTarget(raw, parseOptions);
|
||||
|
||||
if (!parsed) {
|
||||
throw new Error("Recipient is required for Discord sends");
|
||||
}
|
||||
|
||||
return { kind: parsed.kind, id: parsed.id };
|
||||
}
|
||||
|
||||
function normalizeStickerIds(raw: string[]) {
|
||||
const ids = raw.map((entry) => entry.trim()).filter(Boolean);
|
||||
if (ids.length === 0) {
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { normalizeDiscordMessagingTarget } from "../channels/plugins/normalize/discord.js";
|
||||
import { parseDiscordTarget, resolveDiscordChannelId } from "./targets.js";
|
||||
import { listDiscordDirectoryPeersLive } from "./directory-live.js";
|
||||
import { parseDiscordTarget, resolveDiscordChannelId, resolveDiscordTarget } from "./targets.js";
|
||||
|
||||
vi.mock("./directory-live.js", () => ({
|
||||
listDiscordDirectoryPeersLive: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("parseDiscordTarget", () => {
|
||||
it("parses user mention and prefixes", () => {
|
||||
@ -68,6 +74,38 @@ describe("resolveDiscordChannelId", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDiscordTarget", () => {
|
||||
const cfg = { channels: { discord: {} } } as ClawdbotConfig;
|
||||
const listPeers = vi.mocked(listDiscordDirectoryPeersLive);
|
||||
|
||||
beforeEach(() => {
|
||||
listPeers.mockReset();
|
||||
});
|
||||
|
||||
it("returns a resolved user for usernames", async () => {
|
||||
listPeers.mockResolvedValueOnce([{ kind: "user", id: "user:999", name: "Jane" } as const]);
|
||||
|
||||
await expect(
|
||||
resolveDiscordTarget("jane", { cfg, accountId: "default" }),
|
||||
).resolves.toMatchObject({ kind: "user", id: "999", normalized: "user:999" });
|
||||
});
|
||||
|
||||
it("falls back to parsing when lookup misses", async () => {
|
||||
listPeers.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
resolveDiscordTarget("general", { cfg, accountId: "default" }),
|
||||
).resolves.toMatchObject({ kind: "channel", id: "general" });
|
||||
});
|
||||
|
||||
it("does not call directory lookup for explicit user ids", async () => {
|
||||
listPeers.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
resolveDiscordTarget("user:123", { cfg, accountId: "default" }),
|
||||
).resolves.toMatchObject({ kind: "user", id: "123" });
|
||||
expect(listPeers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeDiscordMessagingTarget", () => {
|
||||
it("defaults raw numeric ids to channels", () => {
|
||||
expect(normalizeDiscordMessagingTarget("123")).toBe("channel:123");
|
||||
|
||||
@ -7,6 +7,10 @@ import {
|
||||
type MessagingTargetParseOptions,
|
||||
} from "../channels/targets.js";
|
||||
|
||||
import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js";
|
||||
|
||||
import { listDiscordDirectoryPeersLive } from "./directory-live.js";
|
||||
|
||||
export type DiscordTargetKind = MessagingTargetKind;
|
||||
|
||||
export type DiscordTarget = MessagingTarget;
|
||||
@ -60,3 +64,93 @@ export function resolveDiscordChannelId(raw: string): string {
|
||||
const target = parseDiscordTarget(raw, { defaultKind: "channel" });
|
||||
return requireTargetKind({ platform: "Discord", target, kind: "channel" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Discord username to user ID using the directory lookup.
|
||||
* This enables sending DMs by username instead of requiring explicit user IDs.
|
||||
*
|
||||
* @param raw - The username or raw target string (e.g., "john.doe")
|
||||
* @param options - Directory configuration params (cfg, accountId, limit)
|
||||
* @param parseOptions - Messaging target parsing options (defaults, ambiguity message)
|
||||
* @returns Parsed MessagingTarget with user ID, or undefined if not found
|
||||
*/
|
||||
export async function resolveDiscordTarget(
|
||||
raw: string,
|
||||
options: DirectoryConfigParams,
|
||||
parseOptions: DiscordTargetParseOptions = {},
|
||||
): Promise<MessagingTarget | undefined> {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const likelyUsername = isLikelyUsername(trimmed);
|
||||
const shouldLookup = isExplicitUserLookup(trimmed, parseOptions) || likelyUsername;
|
||||
const directParse = safeParseDiscordTarget(trimmed, parseOptions);
|
||||
if (directParse && directParse.kind !== "channel" && !likelyUsername) {
|
||||
return directParse;
|
||||
}
|
||||
if (!shouldLookup) {
|
||||
return directParse ?? parseDiscordTarget(trimmed, parseOptions);
|
||||
}
|
||||
|
||||
// Try to resolve as a username via directory lookup
|
||||
try {
|
||||
const directoryEntries = await listDiscordDirectoryPeersLive({
|
||||
...options,
|
||||
query: trimmed,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const match = directoryEntries[0];
|
||||
if (match && match.kind === "user") {
|
||||
// Extract user ID from the directory entry (format: "user:<id>")
|
||||
const userId = match.id.replace(/^user:/, "");
|
||||
return buildMessagingTarget("user", userId, trimmed);
|
||||
}
|
||||
} catch {
|
||||
// Directory lookup failed - fall through to parse as-is
|
||||
// This preserves existing behavior for channel names
|
||||
}
|
||||
|
||||
// Fallback to original parsing (for channels, etc.)
|
||||
return parseDiscordTarget(trimmed, parseOptions);
|
||||
}
|
||||
|
||||
function safeParseDiscordTarget(
|
||||
input: string,
|
||||
options: DiscordTargetParseOptions,
|
||||
): MessagingTarget | undefined {
|
||||
try {
|
||||
return parseDiscordTarget(input, options);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isExplicitUserLookup(input: string, options: DiscordTargetParseOptions): boolean {
|
||||
if (/^<@!?(\d+)>$/.test(input)) {
|
||||
return true;
|
||||
}
|
||||
if (/^(user:|discord:)/.test(input)) {
|
||||
return true;
|
||||
}
|
||||
if (input.startsWith("@")) {
|
||||
return true;
|
||||
}
|
||||
if (/^\d+$/.test(input)) {
|
||||
return options.defaultKind === "user";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string looks like a Discord username (not a mention, prefix, or ID).
|
||||
* Usernames typically don't start with special characters except underscore.
|
||||
*/
|
||||
function isLikelyUsername(input: string): boolean {
|
||||
// Skip if it's already a known format
|
||||
if (/^(user:|channel:|discord:|@|<@!?)|[\d]+$/.test(input)) {
|
||||
return false;
|
||||
}
|
||||
// Likely a username if it doesn't match known patterns
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ Automatically saves session context to your workspace memory when you issue the
|
||||
When you run `/new` to start a fresh session:
|
||||
|
||||
1. **Finds the previous session** - Uses the pre-reset session entry to locate the correct transcript
|
||||
2. **Extracts conversation** - Reads the last 15 lines of conversation from the session
|
||||
2. **Extracts conversation** - Reads the last N user/assistant messages from the session (default: 15, configurable)
|
||||
3. **Generates descriptive slug** - Uses LLM to create a meaningful filename slug based on conversation content
|
||||
4. **Saves to memory** - Creates a new file at `<workspace>/memory/YYYY-MM-DD-slug.md`
|
||||
5. **Sends confirmation** - Notifies you with the file path
|
||||
@ -57,7 +57,30 @@ The hook uses your configured LLM provider to generate slugs, so it works with a
|
||||
|
||||
## Configuration
|
||||
|
||||
No additional configuration required. The hook automatically:
|
||||
The hook supports optional configuration:
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ---------- | ------ | ------- | --------------------------------------------------------------- |
|
||||
| `messages` | number | 15 | Number of user/assistant messages to include in the memory file |
|
||||
|
||||
Example configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"internal": {
|
||||
"entries": {
|
||||
"session-memory": {
|
||||
"enabled": true,
|
||||
"messages": 25
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The hook automatically:
|
||||
|
||||
- Uses your workspace directory (`~/clawd` by default)
|
||||
- Uses your configured LLM for slug generation
|
||||
|
||||
379
src/hooks/bundled/session-memory/handler.test.ts
Normal file
379
src/hooks/bundled/session-memory/handler.test.ts
Normal file
@ -0,0 +1,379 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import handler from "./handler.js";
|
||||
import { createHookEvent } from "../../hooks.js";
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import { makeTempWorkspace, writeWorkspaceFile } from "../../../test-helpers/workspace.js";
|
||||
|
||||
/**
|
||||
* Create a mock session JSONL file with various entry types
|
||||
*/
|
||||
function createMockSessionContent(
|
||||
entries: Array<{ role: string; content: string } | { type: string }>,
|
||||
): string {
|
||||
return entries
|
||||
.map((entry) => {
|
||||
if ("role" in entry) {
|
||||
return JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: entry.role,
|
||||
content: entry.content,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Non-message entry (tool call, system, etc.)
|
||||
return JSON.stringify(entry);
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
describe("session-memory hook", () => {
|
||||
it("skips non-command events", async () => {
|
||||
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
|
||||
|
||||
const event = createHookEvent("agent", "bootstrap", "agent:main:main", {
|
||||
workspaceDir: tempDir,
|
||||
});
|
||||
|
||||
await handler(event);
|
||||
|
||||
// Memory directory should not be created for non-command events
|
||||
const memoryDir = path.join(tempDir, "memory");
|
||||
await expect(fs.access(memoryDir)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("skips commands other than new", async () => {
|
||||
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
|
||||
|
||||
const event = createHookEvent("command", "help", "agent:main:main", {
|
||||
workspaceDir: tempDir,
|
||||
});
|
||||
|
||||
await handler(event);
|
||||
|
||||
// Memory directory should not be created for other commands
|
||||
const memoryDir = path.join(tempDir, "memory");
|
||||
await expect(fs.access(memoryDir)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("creates memory file with session content on /new command", async () => {
|
||||
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
|
||||
const sessionsDir = path.join(tempDir, "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
// Create a mock session file with user/assistant messages
|
||||
const sessionContent = createMockSessionContent([
|
||||
{ role: "user", content: "Hello there" },
|
||||
{ role: "assistant", content: "Hi! How can I help?" },
|
||||
{ role: "user", content: "What is 2+2?" },
|
||||
{ role: "assistant", content: "2+2 equals 4" },
|
||||
]);
|
||||
const sessionFile = await writeWorkspaceFile({
|
||||
dir: sessionsDir,
|
||||
name: "test-session.jsonl",
|
||||
content: sessionContent,
|
||||
});
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { workspace: tempDir } },
|
||||
};
|
||||
|
||||
const event = createHookEvent("command", "new", "agent:main:main", {
|
||||
cfg,
|
||||
previousSessionEntry: {
|
||||
sessionId: "test-123",
|
||||
sessionFile,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(event);
|
||||
|
||||
// Memory file should be created
|
||||
const memoryDir = path.join(tempDir, "memory");
|
||||
const files = await fs.readdir(memoryDir);
|
||||
expect(files.length).toBe(1);
|
||||
|
||||
// Read the memory file and verify content
|
||||
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
|
||||
expect(memoryContent).toContain("user: Hello there");
|
||||
expect(memoryContent).toContain("assistant: Hi! How can I help?");
|
||||
expect(memoryContent).toContain("user: What is 2+2?");
|
||||
expect(memoryContent).toContain("assistant: 2+2 equals 4");
|
||||
});
|
||||
|
||||
it("filters out non-message entries (tool calls, system)", async () => {
|
||||
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
|
||||
const sessionsDir = path.join(tempDir, "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
// Create session with mixed entry types
|
||||
const sessionContent = createMockSessionContent([
|
||||
{ role: "user", content: "Hello" },
|
||||
{ type: "tool_use", tool: "search", input: "test" },
|
||||
{ role: "assistant", content: "World" },
|
||||
{ type: "tool_result", result: "found it" },
|
||||
{ role: "user", content: "Thanks" },
|
||||
]);
|
||||
const sessionFile = await writeWorkspaceFile({
|
||||
dir: sessionsDir,
|
||||
name: "test-session.jsonl",
|
||||
content: sessionContent,
|
||||
});
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { workspace: tempDir } },
|
||||
};
|
||||
|
||||
const event = createHookEvent("command", "new", "agent:main:main", {
|
||||
cfg,
|
||||
previousSessionEntry: {
|
||||
sessionId: "test-123",
|
||||
sessionFile,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(event);
|
||||
|
||||
const memoryDir = path.join(tempDir, "memory");
|
||||
const files = await fs.readdir(memoryDir);
|
||||
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
|
||||
|
||||
// Only user/assistant messages should be present
|
||||
expect(memoryContent).toContain("user: Hello");
|
||||
expect(memoryContent).toContain("assistant: World");
|
||||
expect(memoryContent).toContain("user: Thanks");
|
||||
// Tool entries should not appear
|
||||
expect(memoryContent).not.toContain("tool_use");
|
||||
expect(memoryContent).not.toContain("tool_result");
|
||||
expect(memoryContent).not.toContain("search");
|
||||
});
|
||||
|
||||
it("filters out command messages starting with /", async () => {
|
||||
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
|
||||
const sessionsDir = path.join(tempDir, "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
const sessionContent = createMockSessionContent([
|
||||
{ role: "user", content: "/help" },
|
||||
{ role: "assistant", content: "Here is help info" },
|
||||
{ role: "user", content: "Normal message" },
|
||||
{ role: "user", content: "/new" },
|
||||
]);
|
||||
const sessionFile = await writeWorkspaceFile({
|
||||
dir: sessionsDir,
|
||||
name: "test-session.jsonl",
|
||||
content: sessionContent,
|
||||
});
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { workspace: tempDir } },
|
||||
};
|
||||
|
||||
const event = createHookEvent("command", "new", "agent:main:main", {
|
||||
cfg,
|
||||
previousSessionEntry: {
|
||||
sessionId: "test-123",
|
||||
sessionFile,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(event);
|
||||
|
||||
const memoryDir = path.join(tempDir, "memory");
|
||||
const files = await fs.readdir(memoryDir);
|
||||
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
|
||||
|
||||
// Command messages should be filtered out
|
||||
expect(memoryContent).not.toContain("/help");
|
||||
expect(memoryContent).not.toContain("/new");
|
||||
// Normal messages should be present
|
||||
expect(memoryContent).toContain("assistant: Here is help info");
|
||||
expect(memoryContent).toContain("user: Normal message");
|
||||
});
|
||||
|
||||
it("respects custom messages config (limits to N messages)", async () => {
|
||||
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
|
||||
const sessionsDir = path.join(tempDir, "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
// Create 10 messages
|
||||
const entries = [];
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
entries.push({ role: "user", content: `Message ${i}` });
|
||||
}
|
||||
const sessionContent = createMockSessionContent(entries);
|
||||
const sessionFile = await writeWorkspaceFile({
|
||||
dir: sessionsDir,
|
||||
name: "test-session.jsonl",
|
||||
content: sessionContent,
|
||||
});
|
||||
|
||||
// Configure to only include last 3 messages
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { workspace: tempDir } },
|
||||
hooks: {
|
||||
internal: {
|
||||
entries: {
|
||||
"session-memory": { enabled: true, messages: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const event = createHookEvent("command", "new", "agent:main:main", {
|
||||
cfg,
|
||||
previousSessionEntry: {
|
||||
sessionId: "test-123",
|
||||
sessionFile,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(event);
|
||||
|
||||
const memoryDir = path.join(tempDir, "memory");
|
||||
const files = await fs.readdir(memoryDir);
|
||||
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
|
||||
|
||||
// Only last 3 messages should be present
|
||||
expect(memoryContent).not.toContain("user: Message 1\n");
|
||||
expect(memoryContent).not.toContain("user: Message 7\n");
|
||||
expect(memoryContent).toContain("user: Message 8");
|
||||
expect(memoryContent).toContain("user: Message 9");
|
||||
expect(memoryContent).toContain("user: Message 10");
|
||||
});
|
||||
|
||||
it("filters messages before slicing (fix for #2681)", async () => {
|
||||
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
|
||||
const sessionsDir = path.join(tempDir, "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
// Create session with many tool entries interspersed with messages
|
||||
// This tests that we filter FIRST, then slice - not the other way around
|
||||
const entries = [
|
||||
{ role: "user", content: "First message" },
|
||||
{ type: "tool_use", tool: "test1" },
|
||||
{ type: "tool_result", result: "result1" },
|
||||
{ role: "assistant", content: "Second message" },
|
||||
{ type: "tool_use", tool: "test2" },
|
||||
{ type: "tool_result", result: "result2" },
|
||||
{ role: "user", content: "Third message" },
|
||||
{ type: "tool_use", tool: "test3" },
|
||||
{ type: "tool_result", result: "result3" },
|
||||
{ role: "assistant", content: "Fourth message" },
|
||||
];
|
||||
const sessionContent = createMockSessionContent(entries);
|
||||
const sessionFile = await writeWorkspaceFile({
|
||||
dir: sessionsDir,
|
||||
name: "test-session.jsonl",
|
||||
content: sessionContent,
|
||||
});
|
||||
|
||||
// Request 3 messages - if we sliced first, we'd only get 1-2 messages
|
||||
// because the last 3 lines include tool entries
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { workspace: tempDir } },
|
||||
hooks: {
|
||||
internal: {
|
||||
entries: {
|
||||
"session-memory": { enabled: true, messages: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const event = createHookEvent("command", "new", "agent:main:main", {
|
||||
cfg,
|
||||
previousSessionEntry: {
|
||||
sessionId: "test-123",
|
||||
sessionFile,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(event);
|
||||
|
||||
const memoryDir = path.join(tempDir, "memory");
|
||||
const files = await fs.readdir(memoryDir);
|
||||
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
|
||||
|
||||
// Should have exactly 3 user/assistant messages (the last 3)
|
||||
expect(memoryContent).not.toContain("First message");
|
||||
expect(memoryContent).toContain("user: Third message");
|
||||
expect(memoryContent).toContain("assistant: Second message");
|
||||
expect(memoryContent).toContain("assistant: Fourth message");
|
||||
});
|
||||
|
||||
it("handles empty session files gracefully", async () => {
|
||||
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
|
||||
const sessionsDir = path.join(tempDir, "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
const sessionFile = await writeWorkspaceFile({
|
||||
dir: sessionsDir,
|
||||
name: "test-session.jsonl",
|
||||
content: "",
|
||||
});
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { workspace: tempDir } },
|
||||
};
|
||||
|
||||
const event = createHookEvent("command", "new", "agent:main:main", {
|
||||
cfg,
|
||||
previousSessionEntry: {
|
||||
sessionId: "test-123",
|
||||
sessionFile,
|
||||
},
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
await handler(event);
|
||||
|
||||
// Memory file should still be created with metadata
|
||||
const memoryDir = path.join(tempDir, "memory");
|
||||
const files = await fs.readdir(memoryDir);
|
||||
expect(files.length).toBe(1);
|
||||
});
|
||||
|
||||
it("handles session files with fewer messages than requested", async () => {
|
||||
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
|
||||
const sessionsDir = path.join(tempDir, "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
// Only 2 messages but requesting 15 (default)
|
||||
const sessionContent = createMockSessionContent([
|
||||
{ role: "user", content: "Only message 1" },
|
||||
{ role: "assistant", content: "Only message 2" },
|
||||
]);
|
||||
const sessionFile = await writeWorkspaceFile({
|
||||
dir: sessionsDir,
|
||||
name: "test-session.jsonl",
|
||||
content: sessionContent,
|
||||
});
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { workspace: tempDir } },
|
||||
};
|
||||
|
||||
const event = createHookEvent("command", "new", "agent:main:main", {
|
||||
cfg,
|
||||
previousSessionEntry: {
|
||||
sessionId: "test-123",
|
||||
sessionFile,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(event);
|
||||
|
||||
const memoryDir = path.join(tempDir, "memory");
|
||||
const files = await fs.readdir(memoryDir);
|
||||
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
|
||||
|
||||
// Both messages should be included
|
||||
expect(memoryContent).toContain("user: Only message 1");
|
||||
expect(memoryContent).toContain("assistant: Only message 2");
|
||||
});
|
||||
});
|
||||
@ -8,25 +8,27 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { MoltbotConfig } from "../../../config/config.js";
|
||||
import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js";
|
||||
import { resolveHookConfig } from "../../config.js";
|
||||
import type { HookHandler } from "../../hooks.js";
|
||||
|
||||
/**
|
||||
* Read recent messages from session file for slug generation
|
||||
*/
|
||||
async function getRecentSessionContent(sessionFilePath: string): Promise<string | null> {
|
||||
async function getRecentSessionContent(
|
||||
sessionFilePath: string,
|
||||
messageCount: number = 15,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const content = await fs.readFile(sessionFilePath, "utf-8");
|
||||
const lines = content.trim().split("\n");
|
||||
|
||||
// Get last 15 lines (recent conversation)
|
||||
const recentLines = lines.slice(-15);
|
||||
|
||||
// Parse JSONL and extract messages
|
||||
const messages: string[] = [];
|
||||
for (const line of recentLines) {
|
||||
// Parse JSONL and extract user/assistant messages first
|
||||
const allMessages: string[] = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
// Session files have entries with type="message" containing a nested message object
|
||||
@ -39,7 +41,7 @@ async function getRecentSessionContent(sessionFilePath: string): Promise<string
|
||||
? msg.content.find((c: any) => c.type === "text")?.text
|
||||
: msg.content;
|
||||
if (text && !text.startsWith("/")) {
|
||||
messages.push(`${role}: ${text}`);
|
||||
allMessages.push(`${role}: ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -48,7 +50,9 @@ async function getRecentSessionContent(sessionFilePath: string): Promise<string
|
||||
}
|
||||
}
|
||||
|
||||
return messages.join("\n");
|
||||
// Then slice to get exactly messageCount messages
|
||||
const recentMessages = allMessages.slice(-messageCount);
|
||||
return recentMessages.join("\n");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -93,12 +97,19 @@ const saveSessionToMemory: HookHandler = async (event) => {
|
||||
|
||||
const sessionFile = currentSessionFile || undefined;
|
||||
|
||||
// Read message count from hook config (default: 15)
|
||||
const hookConfig = resolveHookConfig(cfg, "session-memory");
|
||||
const messageCount =
|
||||
typeof hookConfig?.messages === "number" && hookConfig.messages > 0
|
||||
? hookConfig.messages
|
||||
: 15;
|
||||
|
||||
let slug: string | null = null;
|
||||
let sessionContent: string | null = null;
|
||||
|
||||
if (sessionFile) {
|
||||
// Get recent conversation content
|
||||
sessionContent = await getRecentSessionContent(sessionFile);
|
||||
sessionContent = await getRecentSessionContent(sessionFile, messageCount);
|
||||
console.log("[session-memory] sessionContent length:", sessionContent?.length || 0);
|
||||
|
||||
if (sessionContent && cfg) {
|
||||
@ -106,10 +117,7 @@ const saveSessionToMemory: HookHandler = async (event) => {
|
||||
// Dynamically import the LLM slug generator (avoids module caching issues)
|
||||
// When compiled, handler is at dist/hooks/bundled/session-memory/handler.js
|
||||
// Going up ../.. puts us at dist/hooks/, so just add llm-slug-generator.js
|
||||
const moltbotRoot = path.resolve(
|
||||
path.dirname(import.meta.url.replace("file://", "")),
|
||||
"../..",
|
||||
);
|
||||
const moltbotRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const slugGenPath = path.join(moltbotRoot, "llm-slug-generator.js");
|
||||
const { generateSlugViaLLM } = await import(slugGenPath);
|
||||
|
||||
|
||||
@ -103,11 +103,13 @@ function buildBaseSessionKey(params: {
|
||||
cfg: MoltbotConfig;
|
||||
agentId: string;
|
||||
channel: ChannelId;
|
||||
accountId?: string | null;
|
||||
peer: RoutePeer;
|
||||
}): string {
|
||||
return buildAgentSessionKey({
|
||||
agentId: params.agentId,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
peer: params.peer,
|
||||
dmScope: params.cfg.session?.dmScope ?? "main",
|
||||
identityLinks: params.cfg.session?.identityLinks,
|
||||
@ -200,6 +202,7 @@ async function resolveSlackSession(
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "slack",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
const threadId = normalizeThreadId(params.threadId ?? params.replyToId);
|
||||
@ -237,6 +240,7 @@ function resolveDiscordSession(
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
const explicitThreadId = normalizeThreadId(params.threadId);
|
||||
@ -285,6 +289,7 @@ function resolveTelegramSession(
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "telegram",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
@ -312,6 +317,7 @@ function resolveWhatsAppSession(
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "whatsapp",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
@ -337,6 +343,7 @@ function resolveSignalSession(
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "signal",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
@ -371,6 +378,7 @@ function resolveSignalSession(
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "signal",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
@ -395,6 +403,7 @@ function resolveIMessageSession(
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "imessage",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
@ -419,6 +428,7 @@ function resolveIMessageSession(
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "imessage",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
const toPrefix =
|
||||
@ -450,6 +460,7 @@ function resolveMatrixSession(
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "matrix",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
@ -483,6 +494,7 @@ function resolveMSTeamsSession(
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "msteams",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
@ -517,6 +529,7 @@ function resolveMattermostSession(
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "mattermost",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
const threadId = normalizeThreadId(params.replyToId ?? params.threadId);
|
||||
@ -561,6 +574,7 @@ function resolveBlueBubblesSession(
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "bluebubbles",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
@ -586,6 +600,7 @@ function resolveNextcloudTalkSession(
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "nextcloud-talk",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
@ -612,6 +627,7 @@ function resolveZaloSession(
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "zalo",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
@ -639,6 +655,7 @@ function resolveZalouserSession(
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "zalouser",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
@ -661,6 +678,7 @@ function resolveNostrSession(
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "nostr",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
@ -719,6 +737,7 @@ function resolveTlonSession(
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "tlon",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
|
||||
@ -10,7 +10,7 @@ describe("installUnhandledRejectionHandler - fatal detection", () => {
|
||||
let originalExit: typeof process.exit;
|
||||
|
||||
beforeAll(() => {
|
||||
originalExit = process.exit;
|
||||
originalExit = process.exit.bind(process);
|
||||
installUnhandledRejectionHandler();
|
||||
});
|
||||
|
||||
|
||||
@ -14,11 +14,7 @@ const FATAL_ERROR_CODES = new Set([
|
||||
"ERR_WORKER_INITIALIZATION_FAILED",
|
||||
]);
|
||||
|
||||
const CONFIG_ERROR_CODES = new Set([
|
||||
"INVALID_CONFIG",
|
||||
"MISSING_API_KEY",
|
||||
"MISSING_CREDENTIALS",
|
||||
]);
|
||||
const CONFIG_ERROR_CODES = new Set(["INVALID_CONFIG", "MISSING_API_KEY", "MISSING_CREDENTIALS"]);
|
||||
|
||||
// Network error codes that indicate transient failures (shouldn't crash the gateway)
|
||||
const TRANSIENT_NETWORK_CODES = new Set([
|
||||
@ -145,10 +141,7 @@ export function installUnhandledRejectionHandler(): void {
|
||||
}
|
||||
|
||||
if (isConfigError(reason)) {
|
||||
console.error(
|
||||
"[moltbot] CONFIGURATION ERROR - requires fix:",
|
||||
formatUncaughtError(reason),
|
||||
);
|
||||
console.error("[moltbot] CONFIGURATION ERROR - requires fix:", formatUncaughtError(reason));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import JSZip from "jszip";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { detectMime, imageMimeFromFormat } from "./mime.js";
|
||||
import { detectMime, extensionForMime, imageMimeFromFormat } from "./mime.js";
|
||||
|
||||
async function makeOoxmlZip(opts: { mainMime: string; partPath: string }): Promise<Buffer> {
|
||||
const zip = new JSZip();
|
||||
@ -53,3 +53,47 @@ describe("mime detection", () => {
|
||||
expect(mime).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extensionForMime", () => {
|
||||
it("maps image MIME types to extensions", () => {
|
||||
expect(extensionForMime("image/jpeg")).toBe(".jpg");
|
||||
expect(extensionForMime("image/png")).toBe(".png");
|
||||
expect(extensionForMime("image/webp")).toBe(".webp");
|
||||
expect(extensionForMime("image/gif")).toBe(".gif");
|
||||
expect(extensionForMime("image/heic")).toBe(".heic");
|
||||
});
|
||||
|
||||
it("maps audio MIME types to extensions", () => {
|
||||
expect(extensionForMime("audio/mpeg")).toBe(".mp3");
|
||||
expect(extensionForMime("audio/ogg")).toBe(".ogg");
|
||||
expect(extensionForMime("audio/x-m4a")).toBe(".m4a");
|
||||
expect(extensionForMime("audio/mp4")).toBe(".m4a");
|
||||
});
|
||||
|
||||
it("maps video MIME types to extensions", () => {
|
||||
expect(extensionForMime("video/mp4")).toBe(".mp4");
|
||||
expect(extensionForMime("video/quicktime")).toBe(".mov");
|
||||
});
|
||||
|
||||
it("maps document MIME types to extensions", () => {
|
||||
expect(extensionForMime("application/pdf")).toBe(".pdf");
|
||||
expect(extensionForMime("text/plain")).toBe(".txt");
|
||||
expect(extensionForMime("text/markdown")).toBe(".md");
|
||||
});
|
||||
|
||||
it("handles case insensitivity", () => {
|
||||
expect(extensionForMime("IMAGE/JPEG")).toBe(".jpg");
|
||||
expect(extensionForMime("Audio/X-M4A")).toBe(".m4a");
|
||||
expect(extensionForMime("Video/QuickTime")).toBe(".mov");
|
||||
});
|
||||
|
||||
it("returns undefined for unknown MIME types", () => {
|
||||
expect(extensionForMime("video/unknown")).toBeUndefined();
|
||||
expect(extensionForMime("application/x-custom")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for null or undefined input", () => {
|
||||
expect(extensionForMime(null)).toBeUndefined();
|
||||
expect(extensionForMime(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -13,7 +13,10 @@ const EXT_BY_MIME: Record<string, string> = {
|
||||
"image/gif": ".gif",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/x-m4a": ".m4a",
|
||||
"audio/mp4": ".m4a",
|
||||
"video/mp4": ".mp4",
|
||||
"video/quicktime": ".mov",
|
||||
"application/pdf": ".pdf",
|
||||
"application/json": ".json",
|
||||
"application/zip": ".zip",
|
||||
|
||||
@ -227,3 +227,29 @@ describe("resolveAgentRoute", () => {
|
||||
expect(route.sessionKey).toBe("agent:home:main");
|
||||
});
|
||||
});
|
||||
|
||||
test("dmScope=per-account-channel-peer isolates DM sessions per account, channel and sender", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
session: { dmScope: "per-account-channel-peer" },
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: "tasks",
|
||||
peer: { kind: "dm", id: "7550356539" },
|
||||
});
|
||||
expect(route.sessionKey).toBe("agent:main:telegram:tasks:dm:7550356539");
|
||||
});
|
||||
|
||||
test("dmScope=per-account-channel-peer uses default accountId when not provided", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
session: { dmScope: "per-account-channel-peer" },
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: null,
|
||||
peer: { kind: "dm", id: "7550356539" },
|
||||
});
|
||||
expect(route.sessionKey).toBe("agent:main:telegram:default:dm:7550356539");
|
||||
});
|
||||
|
||||
@ -69,9 +69,10 @@ function matchesAccountId(match: string | undefined, actual: string): boolean {
|
||||
export function buildAgentSessionKey(params: {
|
||||
agentId: string;
|
||||
channel: string;
|
||||
accountId?: string | null;
|
||||
peer?: RoutePeer | null;
|
||||
/** DM session scope. */
|
||||
dmScope?: "main" | "per-peer" | "per-channel-peer";
|
||||
dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
|
||||
identityLinks?: Record<string, string[]>;
|
||||
}): string {
|
||||
const channel = normalizeToken(params.channel) || "unknown";
|
||||
@ -80,6 +81,7 @@ export function buildAgentSessionKey(params: {
|
||||
agentId: params.agentId,
|
||||
mainKey: DEFAULT_MAIN_KEY,
|
||||
channel,
|
||||
accountId: params.accountId,
|
||||
peerKind: peer?.kind ?? "dm",
|
||||
peerId: peer ? normalizeId(peer.id) || "unknown" : null,
|
||||
dmScope: params.dmScope,
|
||||
@ -160,6 +162,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
|
||||
const sessionKey = buildAgentSessionKey({
|
||||
agentId: resolvedAgentId,
|
||||
channel,
|
||||
accountId,
|
||||
peer,
|
||||
dmScope,
|
||||
identityLinks,
|
||||
|
||||
@ -111,11 +111,12 @@ export function buildAgentPeerSessionKey(params: {
|
||||
agentId: string;
|
||||
mainKey?: string | undefined;
|
||||
channel: string;
|
||||
accountId?: string | null;
|
||||
peerKind?: "dm" | "group" | "channel" | null;
|
||||
peerId?: string | null;
|
||||
identityLinks?: Record<string, string[]>;
|
||||
/** DM session scope. */
|
||||
dmScope?: "main" | "per-peer" | "per-channel-peer";
|
||||
dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
|
||||
}): string {
|
||||
const peerKind = params.peerKind ?? "dm";
|
||||
if (peerKind === "dm") {
|
||||
@ -131,6 +132,11 @@ export function buildAgentPeerSessionKey(params: {
|
||||
});
|
||||
if (linkedPeerId) peerId = linkedPeerId;
|
||||
peerId = peerId.toLowerCase();
|
||||
if (dmScope === "per-account-channel-peer" && peerId) {
|
||||
const channel = (params.channel ?? "").trim().toLowerCase() || "unknown";
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
return `agent:${normalizeAgentId(params.agentId)}:${channel}:${accountId}:dm:${peerId}`;
|
||||
}
|
||||
if (dmScope === "per-channel-peer" && peerId) {
|
||||
const channel = (params.channel ?? "").trim().toLowerCase() || "unknown";
|
||||
return `agent:${normalizeAgentId(params.agentId)}:${channel}:dm:${peerId}`;
|
||||
|
||||
@ -519,7 +519,8 @@ async function collectChannelSecurityFindings(params: {
|
||||
title: `${input.label} DMs share the main session`,
|
||||
detail:
|
||||
"Multiple DM senders currently share the main session, which can leak context across users.",
|
||||
remediation: 'Set session.dmScope="per-channel-peer" to isolate DM sessions per sender.',
|
||||
remediation:
|
||||
'Set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate DM sessions per sender.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -70,3 +70,102 @@ describe("buildTelegramMessageContext dm thread sessions", () => {
|
||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTelegramMessageContext group sessions without forum", () => {
|
||||
const baseConfig = {
|
||||
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } },
|
||||
channels: { telegram: {} },
|
||||
messages: { groupChat: { mentionPatterns: [] } },
|
||||
} as never;
|
||||
|
||||
const buildContext = async (message: Record<string, unknown>) =>
|
||||
await buildTelegramMessageContext({
|
||||
primaryCtx: {
|
||||
message,
|
||||
me: { id: 7, username: "bot" },
|
||||
} as never,
|
||||
allMedia: [],
|
||||
storeAllowFrom: [],
|
||||
options: { forceWasMentioned: true },
|
||||
bot: {
|
||||
api: {
|
||||
sendChatAction: vi.fn(),
|
||||
setMessageReaction: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
cfg: baseConfig,
|
||||
account: { accountId: "default" } as never,
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
dmPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
ackReactionScope: "off",
|
||||
logger: { info: vi.fn() },
|
||||
resolveGroupActivation: () => true,
|
||||
resolveGroupRequireMention: () => false,
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig: { requireMention: false },
|
||||
topicConfig: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
it("ignores message_thread_id for regular groups (not forums)", async () => {
|
||||
// When someone replies to a message in a non-forum group, Telegram sends
|
||||
// message_thread_id but this should NOT create a separate session
|
||||
const ctx = await buildContext({
|
||||
message_id: 1,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
|
||||
date: 1700000000,
|
||||
text: "@bot hello",
|
||||
message_thread_id: 42, // This is a reply thread, NOT a forum topic
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
// Session key should NOT include :topic:42
|
||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890");
|
||||
// MessageThreadId should be undefined (not a forum)
|
||||
expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps same session for regular group with and without message_thread_id", async () => {
|
||||
const ctxWithThread = await buildContext({
|
||||
message_id: 1,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
|
||||
date: 1700000000,
|
||||
text: "@bot hello",
|
||||
message_thread_id: 42,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
});
|
||||
|
||||
const ctxWithoutThread = await buildContext({
|
||||
message_id: 2,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
|
||||
date: 1700000001,
|
||||
text: "@bot world",
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
});
|
||||
|
||||
expect(ctxWithThread).not.toBeNull();
|
||||
expect(ctxWithoutThread).not.toBeNull();
|
||||
// Both messages should use the same session key
|
||||
expect(ctxWithThread?.ctxPayload?.SessionKey).toBe(ctxWithoutThread?.ctxPayload?.SessionKey);
|
||||
});
|
||||
|
||||
it("uses topic session for forum groups with message_thread_id", async () => {
|
||||
const ctx = await buildContext({
|
||||
message_id: 1,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
|
||||
date: 1700000000,
|
||||
text: "@bot hello",
|
||||
message_thread_id: 99,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
// Session key SHOULD include :topic:99 for forums
|
||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:99");
|
||||
expect(ctx?.ctxPayload?.MessageThreadId).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
@ -173,7 +173,8 @@ export const buildTelegramMessageContext = async ({
|
||||
},
|
||||
});
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const dmThreadId = !isGroup ? resolvedThreadId : undefined;
|
||||
// DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
|
||||
const dmThreadId = !isGroup ? messageThreadId : undefined;
|
||||
const threadKeys =
|
||||
dmThreadId != null
|
||||
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
|
||||
@ -601,7 +602,8 @@ export const buildTelegramMessageContext = async ({
|
||||
Sticker: allMedia[0]?.stickerMetadata,
|
||||
...(locationData ? toLocationContext(locationData) : undefined),
|
||||
CommandAuthorized: commandAuthorized,
|
||||
MessageThreadId: resolvedThreadId,
|
||||
// For groups: use resolvedThreadId (forum topics only); for DMs: use raw messageThreadId
|
||||
MessageThreadId: isGroup ? resolvedThreadId : messageThreadId,
|
||||
IsForum: isForum,
|
||||
// Originating channel for reply routing.
|
||||
OriginatingChannel: "telegram" as const,
|
||||
|
||||
@ -322,7 +322,7 @@ export const registerTelegramNativeCommands = ({
|
||||
];
|
||||
|
||||
if (allCommands.length > 0) {
|
||||
void withTelegramApiErrorLogging({
|
||||
withTelegramApiErrorLogging({
|
||||
operation: "setMyCommands",
|
||||
runtime,
|
||||
fn: () => bot.api.setMyCommands(allCommands),
|
||||
@ -360,6 +360,8 @@ export const registerTelegramNativeCommands = ({
|
||||
topicConfig,
|
||||
commandAuthorized,
|
||||
} = auth;
|
||||
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
||||
const threadIdForSend = isGroup ? resolvedThreadId : messageThreadId;
|
||||
|
||||
const commandDefinition = findCommandByNativeName(command.name, "telegram");
|
||||
const rawText = ctx.match?.trim() ?? "";
|
||||
@ -406,7 +408,7 @@ export const registerTelegramNativeCommands = ({
|
||||
fn: () =>
|
||||
bot.api.sendMessage(chatId, title, {
|
||||
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||
...(resolvedThreadId != null ? { message_thread_id: resolvedThreadId } : {}),
|
||||
...(threadIdForSend != null ? { message_thread_id: threadIdForSend } : {}),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
@ -421,7 +423,8 @@ export const registerTelegramNativeCommands = ({
|
||||
},
|
||||
});
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const dmThreadId = !isGroup ? resolvedThreadId : undefined;
|
||||
// DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
|
||||
const dmThreadId = !isGroup ? messageThreadId : undefined;
|
||||
const threadKeys =
|
||||
dmThreadId != null
|
||||
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
|
||||
@ -466,7 +469,7 @@ export const registerTelegramNativeCommands = ({
|
||||
CommandSource: "native" as const,
|
||||
SessionKey: `telegram:slash:${senderId || chatId}`,
|
||||
CommandTargetSessionKey: sessionKey,
|
||||
MessageThreadId: resolvedThreadId,
|
||||
MessageThreadId: threadIdForSend,
|
||||
IsForum: isForum,
|
||||
// Originating context for sub-agent announce routing
|
||||
OriginatingChannel: "telegram" as const,
|
||||
@ -493,7 +496,7 @@ export const registerTelegramNativeCommands = ({
|
||||
bot,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
messageThreadId: resolvedThreadId,
|
||||
messageThreadId: threadIdForSend,
|
||||
tableMode,
|
||||
chunkMode,
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
@ -541,7 +544,9 @@ export const registerTelegramNativeCommands = ({
|
||||
requireAuth: match.command.requireAuth !== false,
|
||||
});
|
||||
if (!auth) return;
|
||||
const { resolvedThreadId, senderId, commandAuthorized } = auth;
|
||||
const { resolvedThreadId, senderId, commandAuthorized, isGroup } = auth;
|
||||
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
||||
const threadIdForSend = isGroup ? resolvedThreadId : messageThreadId;
|
||||
|
||||
const result = await executePluginCommand({
|
||||
command: match.command,
|
||||
@ -567,7 +572,7 @@ export const registerTelegramNativeCommands = ({
|
||||
bot,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
messageThreadId: resolvedThreadId,
|
||||
messageThreadId: threadIdForSend,
|
||||
tableMode,
|
||||
chunkMode,
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
@ -576,7 +581,7 @@ export const registerTelegramNativeCommands = ({
|
||||
}
|
||||
}
|
||||
} else if (nativeDisabledExplicit) {
|
||||
void withTelegramApiErrorLogging({
|
||||
withTelegramApiErrorLogging({
|
||||
operation: "setMyCommands",
|
||||
runtime,
|
||||
fn: () => bot.api.setMyCommands([]),
|
||||
|
||||
@ -238,12 +238,17 @@ describe("createTelegramBot", () => {
|
||||
expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123");
|
||||
expect(
|
||||
getTelegramSequentialKey({
|
||||
message: { chat: { id: 123 }, message_thread_id: 9 },
|
||||
message: { chat: { id: 123, type: "private" }, message_thread_id: 9 },
|
||||
}),
|
||||
).toBe("telegram:123:topic:9");
|
||||
expect(
|
||||
getTelegramSequentialKey({
|
||||
message: { chat: { id: 123, is_forum: true } },
|
||||
message: { chat: { id: 123, type: "supergroup" }, message_thread_id: 9 },
|
||||
}),
|
||||
).toBe("telegram:123");
|
||||
expect(
|
||||
getTelegramSequentialKey({
|
||||
message: { chat: { id: 123, type: "supergroup", is_forum: true } },
|
||||
}),
|
||||
).toBe("telegram:123:topic:1");
|
||||
expect(
|
||||
|
||||
@ -340,12 +340,17 @@ describe("createTelegramBot", () => {
|
||||
expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123");
|
||||
expect(
|
||||
getTelegramSequentialKey({
|
||||
message: { chat: { id: 123 }, message_thread_id: 9 },
|
||||
message: { chat: { id: 123, type: "private" }, message_thread_id: 9 },
|
||||
}),
|
||||
).toBe("telegram:123:topic:9");
|
||||
expect(
|
||||
getTelegramSequentialKey({
|
||||
message: { chat: { id: 123, is_forum: true } },
|
||||
message: { chat: { id: 123, type: "supergroup" }, message_thread_id: 9 },
|
||||
}),
|
||||
).toBe("telegram:123");
|
||||
expect(
|
||||
getTelegramSequentialKey({
|
||||
message: { chat: { id: 123, type: "supergroup", is_forum: true } },
|
||||
}),
|
||||
).toBe("telegram:123:topic:1");
|
||||
expect(
|
||||
|
||||
@ -94,11 +94,12 @@ export function getTelegramSequentialKey(ctx: {
|
||||
if (typeof chatId === "number") return `telegram:${chatId}:control`;
|
||||
return "telegram:control";
|
||||
}
|
||||
const isGroup = msg?.chat?.type === "group" || msg?.chat?.type === "supergroup";
|
||||
const messageThreadId = msg?.message_thread_id;
|
||||
const isForum = (msg?.chat as { is_forum?: boolean } | undefined)?.is_forum;
|
||||
const threadId = resolveTelegramForumThreadId({
|
||||
isForum,
|
||||
messageThreadId: msg?.message_thread_id,
|
||||
});
|
||||
const threadId = isGroup
|
||||
? resolveTelegramForumThreadId({ isForum, messageThreadId })
|
||||
: messageThreadId;
|
||||
if (typeof chatId === "number") {
|
||||
return threadId != null ? `telegram:${chatId}:topic:${threadId}` : `telegram:${chatId}`;
|
||||
}
|
||||
@ -427,7 +428,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
peer: { kind: isGroup ? "group" : "dm", id: peerId },
|
||||
});
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const dmThreadId = !isGroup ? resolvedThreadId : undefined;
|
||||
// DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
|
||||
const dmThreadId = !isGroup ? messageThreadId : undefined;
|
||||
const threadKeys =
|
||||
dmThreadId != null
|
||||
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user