diff --git a/CHANGELOG.md b/CHANGELOG.md index e16c962a4..3c5321870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Status: beta. - Branding: update launchd labels, mobile bundle IDs, and logging subsystems to bot.molt (legacy com.clawdbot migrations). Thanks @thewilloftheshadow. - Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt. - Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47. +- Memory Search: allow extra paths for memory indexing (ignores symlinks). (#3600) Thanks @kira-ariaki. - Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204. - Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281) - Matrix: switch plugin SDK to @vector-im/matrix-bot-sdk. @@ -66,20 +67,27 @@ Status: beta. - 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). ### Fixes +- Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796) +- Mentions: honor mentionPatterns even when explicit mentions are present. (#3303) Thanks @HirokiKobayashi-R. - 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. +- Telegram: include AccountId in native command context for multi-agent routing. (#2942) Thanks @Chloe-VP. +- Telegram: handle video note attachments in media extraction. (#2905) Thanks @mylukin. +- TTS: read OPENAI_TTS_BASE_URL at runtime instead of module load to honor config.env. (#3341) Thanks @hclsys. - macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee. - Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101. - Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops. - Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky. +- Discord: stop resolveDiscordTarget from passing directory params into messaging target parsers. Fixes #3167. Thanks @thewilloftheshadow. - Discord: avoid resolving bare channel names to user DMs when a username matches. Thanks @thewilloftheshadow. - Discord: fix directory config type import for target resolution. Thanks @thewilloftheshadow. - Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb. @@ -104,6 +112,7 @@ Status: beta. - Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne. - Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. - Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. +- Media: fix text attachment MIME misclassification with CSV/TSV inference and UTF-16 detection; add XML attribute escaping for file output. (#3628) Thanks @frankekn. - Build: align memory-core peer dependency with lockfile. - Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie. - Security: harden URL fetches with DNS pinning to reduce rebinding risk. Thanks Chris Zheng. diff --git a/README.md b/README.md index 7e884be33..70ca70157 100644 --- a/README.md +++ b/README.md @@ -479,36 +479,38 @@ Thanks to all clawtributors:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`; codes expire after 1 hour).
- Open: requires `channels.whatsapp.allowFrom` to include `"*"`.
- - Self messages are always allowed; “self-chat mode” still requires `channels.whatsapp.allowFrom` to include your own number.
+ - Your linked WhatsApp number is implicitly trusted, so self messages skip `channels.whatsapp.dmPolicy` and `channels.whatsapp.allowFrom` checks.
### Personal-number mode (fallback)
If you run Moltbot on your **personal WhatsApp number**, enable `channels.whatsapp.selfChatMode` (see sample above).
diff --git a/docs/cli/memory.md b/docs/cli/memory.md
index 3dc79932f..513b7ef07 100644
--- a/docs/cli/memory.md
+++ b/docs/cli/memory.md
@@ -39,3 +39,4 @@ Notes:
- `memory status --deep` probes vector + embedding availability.
- `memory status --deep --index` runs a reindex if the store is dirty.
- `memory index --verbose` prints per-phase details (provider, model, sources, batch activity).
+- `memory status` includes any extra paths configured via `memorySearch.extraPaths`.
diff --git a/docs/cli/security.md b/docs/cli/security.md
index 662181616..551debc99 100644
--- a/docs/cli/security.md
+++ b/docs/cli/security.md
@@ -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.
diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md
index 8a386aba9..f2bca461a 100644
--- a/docs/concepts/memory.md
+++ b/docs/concepts/memory.md
@@ -75,8 +75,9 @@ For the full compaction lifecycle, see
## Vector memory search
-Moltbot can build a small vector index over `MEMORY.md` and `memory/*.md` so
-semantic queries can find related notes even when wording differs.
+Moltbot can build a small vector index over `MEMORY.md` and `memory/*.md` (plus
+any extra directories or files you opt in) so semantic queries can find related
+notes even when wording differs.
Defaults:
- Enabled by default.
@@ -96,6 +97,27 @@ embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or
`models.providers.google.apiKey`. When using a custom OpenAI-compatible endpoint,
set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
+### Additional memory paths
+
+If you want to index Markdown files outside the default workspace layout, add
+explicit paths:
+
+```json5
+agents: {
+ defaults: {
+ memorySearch: {
+ extraPaths: ["../team-docs", "/srv/shared-notes/overview.md"]
+ }
+ }
+}
+```
+
+Notes:
+- Paths can be absolute or workspace-relative.
+- Directories are scanned recursively for `.md` files.
+- Only Markdown files are indexed.
+- Symlinks are ignored (files or directories).
+
### Gemini embeddings (native)
Set the provider to `gemini` to use the Gemini embeddings API directly:
@@ -189,14 +211,14 @@ Local mode:
### How the memory tools work
- `memory_search` semantically searches Markdown chunks (~400 token target, 80-token overlap) from `MEMORY.md` + `memory/**/*.md`. It returns snippet text (capped ~700 chars), file path, line range, score, provider/model, and whether we fell back from local → remote embeddings. No full file payload is returned.
-- `memory_get` reads a specific memory Markdown file (workspace-relative), optionally from a starting line and for N lines. Paths outside `MEMORY.md` / `memory/` are rejected.
+- `memory_get` reads a specific memory Markdown file (workspace-relative), optionally from a starting line and for N lines. Paths outside `MEMORY.md` / `memory/` are allowed only when explicitly listed in `memorySearch.extraPaths`.
- Both tools are enabled only when `memorySearch.enabled` resolves true for the agent.
### What gets indexed (and when)
-- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
+- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`, plus any `.md` files under `memorySearch.extraPaths`).
- Index storage: per-agent SQLite at `~/.clawdbot/memory/.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
-- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync is scheduled on session start, on search, or on an interval and runs asynchronously. Session transcripts use delta thresholds to trigger background sync.
+- Freshness: watcher on `MEMORY.md`, `memory/`, and `memorySearch.extraPaths` marks the index dirty (debounce 1.5s). Sync is scheduled on session start, on search, or on an interval and runs asynchronously. Session transcripts use delta thresholds to trigger background sync.
- Reindex triggers: the index stores the embedding **provider/model + endpoint fingerprint + chunking params**. If any of those change, Moltbot automatically resets and reindexes the entire store.
### Hybrid search (BM25 + vector)
diff --git a/docs/concepts/session.md b/docs/concepts/session.md
index 58ac57145..b15b1a1ea 100644
--- a/docs/concepts/session.md
+++ b/docs/concepts/session.md
@@ -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::dm:`.
- `per-channel-peer`: `agent:::dm:`.
+ - `per-account-channel-peer`: `agent::::dm:` (accountId defaults to `default`).
- If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `` so the same person shares a session across channels.
- Group chats isolate state: `agent:::group:` (rooms/channels use `agent:::channel:`).
- Telegram forum topics append `:topic:` 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"]
},
diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md
index 11ac14337..470689673 100644
--- a/docs/gateway/configuration-examples.md
+++ b/docs/gateway/configuration-examples.md
@@ -267,7 +267,8 @@ Save to `~/.clawdbot/moltbot.json` and you can DM the bot from that number.
model: "gemini-embedding-001",
remote: {
apiKey: "${GEMINI_API_KEY}"
- }
+ },
+ extraPaths: ["../team-docs", "/srv/shared-notes"]
},
sandbox: {
mode: "non-main",
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index 15261c809..1d270974d 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -2657,7 +2657,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).
diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md
index d29c3df48..a5d841c18 100644
--- a/docs/gateway/security/index.md
+++ b/docs/gateway/security/index.md
@@ -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
diff --git a/docs/providers/venice.md b/docs/providers/venice.md
index f6b535a68..140aa9ae0 100644
--- a/docs/providers/venice.md
+++ b/docs/providers/venice.md
@@ -4,9 +4,9 @@ read_when:
- You want privacy-focused inference in Moltbot
- You want Venice AI setup guidance
---
-# Venice AI (Venius highlight)
+# Venice AI (Venice highlight)
-**Venius** is our highlight Venice setup for privacy-first inference with optional anonymized access to proprietary models.
+**Venice** is our highlight Venice setup for privacy-first inference with optional anonymized access to proprietary models.
Venice AI provides privacy-focused AI inference with support for uncensored models and access to major proprietary models through their anonymized proxy. All inference is private by default—no training on your data, no logging.
diff --git a/skills/bitwarden/SKILL.md b/skills/bitwarden/SKILL.md
deleted file mode 100644
index 3e384597a..000000000
--- a/skills/bitwarden/SKILL.md
+++ /dev/null
@@ -1,101 +0,0 @@
----
-name: bitwarden
-description: Manage passwords and credentials via Bitwarden CLI (bw). Use for storing, retrieving, creating, or updating logins, credit cards, secure notes, and identities. Trigger when automating authentication, filling payment forms, or managing secrets programmatically.
----
-
-# Bitwarden CLI
-
-Full read/write vault access via `bw` command.
-
-## Prerequisites
-
-```bash
-brew install bitwarden-cli
-bw login # one-time, prompts for master password
-```
-
-## Session Management
-
-Bitwarden requires an unlocked session. Use the helper script:
-
-```bash
-source scripts/bw-session.sh
-# Sets BW_SESSION env var
-```
-
-Or manually:
-```bash
-export BW_SESSION=$(echo '' | bw unlock --raw)
-bw sync # always sync after unlock
-```
-
-## Common Operations
-
-### Retrieve credentials
-```bash
-bw get password "Site Name"
-bw get username "Site Name"
-bw get item "Site Name" --pretty | jq '.login'
-```
-
-### Create login
-```bash
-bw get template item | jq '
- .type = 1 |
- .name = "Site Name" |
- .login.username = "user@email.com" |
- .login.password = "secret123" |
- .login.uris = [{uri: "https://example.com"}]
-' | bw encode | bw create item
-```
-
-### Create credit card
-```bash
-bw get template item | jq '
- .type = 3 |
- .name = "Card Name" |
- .card.cardholderName = "John Doe" |
- .card.brand = "Visa" |
- .card.number = "4111111111111111" |
- .card.expMonth = "12" |
- .card.expYear = "2030" |
- .card.code = "123"
-' | bw encode | bw create item
-```
-
-### Get card for payment automation
-```bash
-bw get item "Card Name" | jq -r '.card | "\(.number) \(.expMonth)/\(.expYear) \(.code)"'
-```
-
-### List items
-```bash
-bw list items | jq -r '.[] | "\(.type)|\(.name)"'
-# Types: 1=login, 2=note, 3=card, 4=identity
-```
-
-### Search
-```bash
-bw list items --search "vilaviniteca" | jq '.[0]'
-```
-
-## Item Types
-
-| Type | Value | Use |
-|------|-------|-----|
-| Login | 1 | Website credentials |
-| Secure Note | 2 | Freeform text |
-| Card | 3 | Credit/debit cards |
-| Identity | 4 | Personal info |
-
-## References
-
-- [templates.md](references/templates.md) — Full jq templates for all item types
-- [Bitwarden CLI docs](https://bitwarden.com/help/cli/)
-
-## Tips
-
-1. **Always sync** after creating/editing items: `bw sync`
-2. **Session expires** — re-unlock if you get auth errors
-3. **Delete sensitive messages** after receiving credentials
-4. **Card numbers** may not import from other managers (security restriction)
diff --git a/skills/bitwarden/references/templates.md b/skills/bitwarden/references/templates.md
deleted file mode 100644
index a14e011e4..000000000
--- a/skills/bitwarden/references/templates.md
+++ /dev/null
@@ -1,116 +0,0 @@
-# Bitwarden Item Templates
-
-jq patterns for creating vault items via CLI.
-
-## Login (type=1)
-
-```bash
-bw get template item | jq '
- .type = 1 |
- .name = "Example Site" |
- .notes = "Optional notes" |
- .favorite = false |
- .login.username = "user@example.com" |
- .login.password = "secretPassword123" |
- .login.totp = "otpauth://totp/..." |
- .login.uris = [
- {uri: "https://example.com", match: null},
- {uri: "https://app.example.com", match: null}
- ]
-' | bw encode | bw create item
-```
-
-## Credit Card (type=3)
-
-```bash
-bw get template item | jq '
- .type = 3 |
- .name = "Visa ending 1234" |
- .notes = "Primary card" |
- .card.cardholderName = "JOHN DOE" |
- .card.brand = "Visa" |
- .card.number = "4111111111111111" |
- .card.expMonth = "12" |
- .card.expYear = "2030" |
- .card.code = "123"
-' | bw encode | bw create item
-```
-
-**Brands:** Visa, Mastercard, Amex, Discover, Diners Club, JCB, Maestro, UnionPay, Other
-
-## Secure Note (type=2)
-
-```bash
-bw get template item | jq '
- .type = 2 |
- .name = "API Keys" |
- .notes = "OPENAI_KEY=sk-xxx\nANTHROPIC_KEY=sk-ant-xxx" |
- .secureNote.type = 0
-' | bw encode | bw create item
-```
-
-## Identity (type=4)
-
-```bash
-bw get template item | jq '
- .type = 4 |
- .name = "Personal Info" |
- .identity.title = "Mr" |
- .identity.firstName = "John" |
- .identity.lastName = "Doe" |
- .identity.email = "john@example.com" |
- .identity.phone = "+34612345678" |
- .identity.address1 = "123 Main St" |
- .identity.city = "Barcelona" |
- .identity.state = "Catalunya" |
- .identity.postalCode = "08001" |
- .identity.country = "ES"
-' | bw encode | bw create item
-```
-
-## Edit Existing Item
-
-```bash
-# Get item, modify, update
-bw get item | jq '.login.password = "newPassword"' | bw encode | bw edit item
-```
-
-## Custom Fields
-
-```bash
-bw get template item | jq '
- .type = 1 |
- .name = "With Custom Fields" |
- .fields = [
- {name: "Security Question", value: "Pet name", type: 0},
- {name: "PIN", value: "1234", type: 1}
- ]
-' | bw encode | bw create item
-```
-
-**Field types:** 0=text, 1=hidden, 2=boolean
-
-## Retrieve Patterns
-
-```bash
-# Password only
-bw get password "Site Name"
-
-# Username only
-bw get username "Site Name"
-
-# Full login object
-bw get item "Site Name" | jq '.login'
-
-# Card number
-bw get item "Card Name" | jq -r '.card.number'
-
-# All card fields for form filling
-bw get item "Card Name" | jq -r '.card | [.number, .expMonth, .expYear, .code] | @tsv'
-
-# Search by URL
-bw list items --url "example.com" | jq '.[0].login'
-
-# List all cards
-bw list items | jq '.[] | select(.type == 3) | .name'
-```
diff --git a/skills/bitwarden/scripts/bw-session.sh b/skills/bitwarden/scripts/bw-session.sh
deleted file mode 100755
index 1b353583e..000000000
--- a/skills/bitwarden/scripts/bw-session.sh
+++ /dev/null
@@ -1,33 +0,0 @@
-#!/bin/bash
-# Unlock Bitwarden vault and export session key
-# Usage: source bw-session.sh
-# Or: source bw-session.sh (prompts for password)
-
-set -e
-
-if [ -n "$1" ]; then
- MASTER_PW="$1"
-else
- read -sp "Bitwarden master password: " MASTER_PW
- echo
-fi
-
-# Check if already logged in
-if ! bw login --check &>/dev/null; then
- echo "Not logged in. Run: bw login "
- return 1
-fi
-
-# Unlock and get session
-export BW_SESSION=$(echo "$MASTER_PW" | bw unlock --raw 2>/dev/null)
-
-if [ -z "$BW_SESSION" ]; then
- echo "Failed to unlock vault"
- return 1
-fi
-
-# Sync to get latest
-bw sync &>/dev/null
-
-echo "✓ Vault unlocked and synced"
-echo "Session valid for this shell"
diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts
index e6b86ea3d..c3165815f 100644
--- a/src/agents/memory-search.test.ts
+++ b/src/agents/memory-search.test.ts
@@ -82,6 +82,29 @@ describe("memory search config", () => {
expect(resolved?.store.vector.extensionPath).toBe("/opt/sqlite-vec.dylib");
});
+ it("merges extra memory paths from defaults and overrides", () => {
+ const cfg = {
+ agents: {
+ defaults: {
+ memorySearch: {
+ extraPaths: ["/shared/notes", " docs "],
+ },
+ },
+ list: [
+ {
+ id: "main",
+ default: true,
+ memorySearch: {
+ extraPaths: ["/shared/notes", "../team-notes"],
+ },
+ },
+ ],
+ },
+ };
+ const resolved = resolveMemorySearchConfig(cfg, "main");
+ expect(resolved?.extraPaths).toEqual(["/shared/notes", "docs", "../team-notes"]);
+ });
+
it("includes batch defaults for openai without remote overrides", () => {
const cfg = {
agents: {
diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts
index c08161d4f..25aeb7cac 100644
--- a/src/agents/memory-search.ts
+++ b/src/agents/memory-search.ts
@@ -9,6 +9,7 @@ import { resolveAgentConfig } from "./agent-scope.js";
export type ResolvedMemorySearchConfig = {
enabled: boolean;
sources: Array<"memory" | "sessions">;
+ extraPaths: string[];
provider: "openai" | "local" | "gemini" | "auto";
remote?: {
baseUrl?: string;
@@ -162,6 +163,10 @@ function mergeConfig(
modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
};
const sources = normalizeSources(overrides?.sources ?? defaults?.sources, sessionMemory);
+ const rawPaths = [...(defaults?.extraPaths ?? []), ...(overrides?.extraPaths ?? [])]
+ .map((value) => value.trim())
+ .filter(Boolean);
+ const extraPaths = Array.from(new Set(rawPaths));
const vector = {
enabled: overrides?.store?.vector?.enabled ?? defaults?.store?.vector?.enabled ?? true,
extensionPath:
@@ -236,6 +241,7 @@ function mergeConfig(
return {
enabled,
sources,
+ extraPaths,
provider,
remote,
experimental: {
diff --git a/src/agents/tools/memory-tool.ts b/src/agents/tools/memory-tool.ts
index b7b619af3..274af4c02 100644
--- a/src/agents/tools/memory-tool.ts
+++ b/src/agents/tools/memory-tool.ts
@@ -83,7 +83,7 @@ export function createMemoryGetTool(options: {
label: "Memory Get",
name: "memory_get",
description:
- "Safe snippet read from MEMORY.md or memory/*.md with optional from/lines; use after memory_search to pull only the needed lines and keep context small.",
+ "Safe snippet read from MEMORY.md, memory/*.md, or configured memorySearch.extraPaths with optional from/lines; use after memory_search to pull only the needed lines and keep context small.",
parameters: MemoryGetSchema,
execute: async (_toolCallId, params) => {
const relPath = readStringParam(params, "path", { required: true });
diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts
index c080ef55f..2604038ec 100644
--- a/src/auto-reply/reply/dispatch-from-config.test.ts
+++ b/src/auto-reply/reply/dispatch-from-config.test.ts
@@ -138,7 +138,7 @@ describe("dispatchReplyFromConfig", () => {
);
});
- it("does not provide onToolResult when routing cross-provider", async () => {
+ it("provides onToolResult in DM sessions", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: false,
aborted: false,
@@ -147,9 +147,34 @@ describe("dispatchReplyFromConfig", () => {
const cfg = {} as MoltbotConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
- Provider: "slack",
- OriginatingChannel: "telegram",
- OriginatingTo: "telegram:999",
+ Provider: "telegram",
+ ChatType: "direct",
+ });
+
+ const replyResolver = async (
+ _ctx: MsgContext,
+ opts: GetReplyOptions | undefined,
+ _cfg: ClawdbotConfig,
+ ) => {
+ expect(opts?.onToolResult).toBeDefined();
+ expect(typeof opts?.onToolResult).toBe("function");
+ return { text: "hi" } satisfies ReplyPayload;
+ };
+
+ await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
+ expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not provide onToolResult in group sessions", async () => {
+ mocks.tryFastAbortFromMessage.mockResolvedValue({
+ handled: false,
+ aborted: false,
+ });
+ const cfg = {} as ClawdbotConfig;
+ const dispatcher = createDispatcher();
+ const ctx = buildTestCtx({
+ Provider: "telegram",
+ ChatType: "group",
});
const replyResolver = async (
@@ -162,12 +187,62 @@ describe("dispatchReplyFromConfig", () => {
};
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
+ expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
+ });
- expect(mocks.routeReply).toHaveBeenCalledWith(
- expect.objectContaining({
- payload: expect.objectContaining({ text: "hi" }),
- }),
+ it("sends tool results via dispatcher in DM sessions", async () => {
+ mocks.tryFastAbortFromMessage.mockResolvedValue({
+ handled: false,
+ aborted: false,
+ });
+ const cfg = {} as ClawdbotConfig;
+ const dispatcher = createDispatcher();
+ const ctx = buildTestCtx({
+ Provider: "telegram",
+ ChatType: "direct",
+ });
+
+ const replyResolver = async (
+ _ctx: MsgContext,
+ opts: GetReplyOptions | undefined,
+ _cfg: ClawdbotConfig,
+ ) => {
+ // Simulate tool result emission
+ await opts?.onToolResult?.({ text: "🔧 exec: ls" });
+ return { text: "done" } satisfies ReplyPayload;
+ };
+
+ await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
+ expect(dispatcher.sendToolResult).toHaveBeenCalledWith(
+ expect.objectContaining({ text: "🔧 exec: ls" }),
);
+ expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not provide onToolResult for native slash commands", async () => {
+ mocks.tryFastAbortFromMessage.mockResolvedValue({
+ handled: false,
+ aborted: false,
+ });
+ const cfg = {} as ClawdbotConfig;
+ const dispatcher = createDispatcher();
+ const ctx = buildTestCtx({
+ Provider: "telegram",
+ ChatType: "direct",
+ CommandSource: "native",
+ });
+
+ const replyResolver = async (
+ _ctx: MsgContext,
+ opts: GetReplyOptions | undefined,
+ _cfg: ClawdbotConfig,
+ ) => {
+ expect(opts?.onToolResult).toBeUndefined();
+ return { text: "hi" } satisfies ReplyPayload;
+ };
+
+ await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
+ expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
});
it("fast-aborts without calling the reply resolver", async () => {
diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts
index 58d5d71b5..c85e654de 100644
--- a/src/auto-reply/reply/dispatch-from-config.ts
+++ b/src/auto-reply/reply/dispatch-from-config.ts
@@ -276,6 +276,27 @@ export async function dispatchReplyFromConfig(params: {
ctx,
{
...params.replyOptions,
+ onToolResult:
+ ctx.ChatType !== "group" && ctx.CommandSource !== "native"
+ ? (payload: ReplyPayload) => {
+ const run = async () => {
+ const ttsPayload = await maybeApplyTtsToPayload({
+ payload,
+ cfg,
+ channel: ttsChannel,
+ kind: "tool",
+ inboundAudio,
+ ttsAuto: sessionTtsAuto,
+ });
+ if (shouldRouteToOriginating) {
+ await sendPayloadAsync(ttsPayload, undefined, false);
+ } else {
+ dispatcher.sendToolResult(ttsPayload);
+ }
+ };
+ return run();
+ }
+ : undefined,
onBlockReply: (payload: ReplyPayload, context) => {
const run = async () => {
// Accumulate block text for TTS generation after streaming
diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts
index 7742b4f30..07cff23a9 100644
--- a/src/auto-reply/reply/mentions.test.ts
+++ b/src/auto-reply/reply/mentions.test.ts
@@ -4,7 +4,7 @@ import { matchesMentionWithExplicit } from "./mentions.js";
describe("matchesMentionWithExplicit", () => {
const mentionRegexes = [/\bclawd\b/i];
- it("prefers explicit mentions when other mentions are present", () => {
+ it("checks mentionPatterns even when explicit mention is available", () => {
const result = matchesMentionWithExplicit({
text: "@clawd hello",
mentionRegexes,
@@ -14,6 +14,19 @@ describe("matchesMentionWithExplicit", () => {
canResolveExplicit: true,
},
});
+ expect(result).toBe(true);
+ });
+
+ it("returns false when explicit is false and no regex match", () => {
+ const result = matchesMentionWithExplicit({
+ text: "<@999999> hello",
+ mentionRegexes,
+ explicit: {
+ hasAnyMention: true,
+ isExplicitlyMentioned: false,
+ canResolveExplicit: true,
+ },
+ });
expect(result).toBe(false);
});
diff --git a/src/auto-reply/reply/mentions.ts b/src/auto-reply/reply/mentions.ts
index 71964ac5f..9554a3c7b 100644
--- a/src/auto-reply/reply/mentions.ts
+++ b/src/auto-reply/reply/mentions.ts
@@ -90,7 +90,9 @@ export function matchesMentionWithExplicit(params: {
const explicit = params.explicit?.isExplicitlyMentioned === true;
const explicitAvailable = params.explicit?.canResolveExplicit === true;
const hasAnyMention = params.explicit?.hasAnyMention === true;
- if (hasAnyMention && explicitAvailable) return explicit;
+ if (hasAnyMention && explicitAvailable) {
+ return explicit || params.mentionRegexes.some((re) => re.test(cleaned));
+ }
if (!cleaned) return explicit;
return explicit || params.mentionRegexes.some((re) => re.test(cleaned));
}
diff --git a/src/auto-reply/reply/normalize-reply.test.ts b/src/auto-reply/reply/normalize-reply.test.ts
index 30fb5e3f5..b9547c2b1 100644
--- a/src/auto-reply/reply/normalize-reply.test.ts
+++ b/src/auto-reply/reply/normalize-reply.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
+import { SILENT_REPLY_TOKEN } from "../tokens.js";
import { normalizeReplyPayload } from "./normalize-reply.js";
// Keep channelData-only payloads so channel-specific replies survive normalization.
@@ -19,4 +20,30 @@ describe("normalizeReplyPayload", () => {
expect(normalized?.text).toBeUndefined();
expect(normalized?.channelData).toEqual(payload.channelData);
});
+
+ it("records silent skips", () => {
+ const reasons: string[] = [];
+ const normalized = normalizeReplyPayload(
+ { text: SILENT_REPLY_TOKEN },
+ {
+ onSkip: (reason) => reasons.push(reason),
+ },
+ );
+
+ expect(normalized).toBeNull();
+ expect(reasons).toEqual(["silent"]);
+ });
+
+ it("records empty skips", () => {
+ const reasons: string[] = [];
+ const normalized = normalizeReplyPayload(
+ { text: " " },
+ {
+ onSkip: (reason) => reasons.push(reason),
+ },
+ );
+
+ expect(normalized).toBeNull();
+ expect(reasons).toEqual(["empty"]);
+ });
});
diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts
index 7968088bd..9a58bebde 100644
--- a/src/auto-reply/reply/normalize-reply.ts
+++ b/src/auto-reply/reply/normalize-reply.ts
@@ -8,6 +8,8 @@ import {
} from "./response-prefix-template.js";
import { hasLineDirectives, parseLineDirectives } from "./line-directives.js";
+export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat";
+
export type NormalizeReplyOptions = {
responsePrefix?: string;
/** Context for template variable interpolation in responsePrefix */
@@ -15,6 +17,7 @@ export type NormalizeReplyOptions = {
onHeartbeatStrip?: () => void;
stripHeartbeat?: boolean;
silentToken?: string;
+ onSkip?: (reason: NormalizeReplySkipReason) => void;
};
export function normalizeReplyPayload(
@@ -26,12 +29,18 @@ export function normalizeReplyPayload(
payload.channelData && Object.keys(payload.channelData).length > 0,
);
const trimmed = payload.text?.trim() ?? "";
- if (!trimmed && !hasMedia && !hasChannelData) return null;
+ if (!trimmed && !hasMedia && !hasChannelData) {
+ opts.onSkip?.("empty");
+ return null;
+ }
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
let text = payload.text ?? undefined;
if (text && isSilentReplyText(text, silentToken)) {
- if (!hasMedia && !hasChannelData) return null;
+ if (!hasMedia && !hasChannelData) {
+ opts.onSkip?.("silent");
+ return null;
+ }
text = "";
}
if (text && !trimmed) {
@@ -43,14 +52,20 @@ export function normalizeReplyPayload(
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip) opts.onHeartbeatStrip?.();
- if (stripped.shouldSkip && !hasMedia && !hasChannelData) return null;
+ if (stripped.shouldSkip && !hasMedia && !hasChannelData) {
+ opts.onSkip?.("heartbeat");
+ return null;
+ }
text = stripped.text;
}
if (text) {
text = sanitizeUserFacingText(text);
}
- if (!text?.trim() && !hasMedia && !hasChannelData) return null;
+ if (!text?.trim() && !hasMedia && !hasChannelData) {
+ opts.onSkip?.("empty");
+ return null;
+ }
// Parse LINE-specific directives from text (quick_replies, location, confirm, buttons)
let enrichedPayload: ReplyPayload = { ...payload, text };
diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts
index f41667802..fd7fb5493 100644
--- a/src/auto-reply/reply/reply-dispatcher.ts
+++ b/src/auto-reply/reply/reply-dispatcher.ts
@@ -1,6 +1,6 @@
import type { HumanDelayConfig } from "../../config/types.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
-import { normalizeReplyPayload } from "./normalize-reply.js";
+import { normalizeReplyPayload, type NormalizeReplySkipReason } from "./normalize-reply.js";
import type { ResponsePrefixContext } from "./response-prefix-template.js";
import type { TypingController } from "./typing.js";
@@ -8,6 +8,11 @@ export type ReplyDispatchKind = "tool" | "block" | "final";
type ReplyDispatchErrorHandler = (err: unknown, info: { kind: ReplyDispatchKind }) => void;
+type ReplyDispatchSkipHandler = (
+ payload: ReplyPayload,
+ info: { kind: ReplyDispatchKind; reason: NormalizeReplySkipReason },
+) => void;
+
type ReplyDispatchDeliverer = (
payload: ReplyPayload,
info: { kind: ReplyDispatchKind },
@@ -42,6 +47,8 @@ export type ReplyDispatcherOptions = {
onHeartbeatStrip?: () => void;
onIdle?: () => void;
onError?: ReplyDispatchErrorHandler;
+ // AIDEV-NOTE: onSkip lets channels detect silent/empty drops (e.g. Telegram empty-response fallback).
+ onSkip?: ReplyDispatchSkipHandler;
/** Human-like delay between block replies for natural rhythm. */
humanDelay?: HumanDelayConfig;
};
@@ -65,15 +72,16 @@ export type ReplyDispatcher = {
getQueuedCounts: () => Record;
};
+type NormalizeReplyPayloadInternalOptions = Pick<
+ ReplyDispatcherOptions,
+ "responsePrefix" | "responsePrefixContext" | "responsePrefixContextProvider" | "onHeartbeatStrip"
+> & {
+ onSkip?: (reason: NormalizeReplySkipReason) => void;
+};
+
function normalizeReplyPayloadInternal(
payload: ReplyPayload,
- opts: Pick<
- ReplyDispatcherOptions,
- | "responsePrefix"
- | "responsePrefixContext"
- | "responsePrefixContextProvider"
- | "onHeartbeatStrip"
- >,
+ opts: NormalizeReplyPayloadInternalOptions,
): ReplyPayload | null {
// Prefer dynamic context provider over static context
const prefixContext = opts.responsePrefixContextProvider?.() ?? opts.responsePrefixContext;
@@ -82,6 +90,7 @@ function normalizeReplyPayloadInternal(
responsePrefix: opts.responsePrefix,
responsePrefixContext: prefixContext,
onHeartbeatStrip: opts.onHeartbeatStrip,
+ onSkip: opts.onSkip,
});
}
@@ -99,7 +108,13 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
};
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
- const normalized = normalizeReplyPayloadInternal(payload, options);
+ const normalized = normalizeReplyPayloadInternal(payload, {
+ responsePrefix: options.responsePrefix,
+ responsePrefixContext: options.responsePrefixContext,
+ responsePrefixContextProvider: options.responsePrefixContextProvider,
+ onHeartbeatStrip: options.onHeartbeatStrip,
+ onSkip: (reason) => options.onSkip?.(payload, { kind, reason }),
+ });
if (!normalized) return false;
queuedCounts[kind] += 1;
pending += 1;
diff --git a/src/cli/banner.ts b/src/cli/banner.ts
index 6ca7d4cbc..e19433e11 100644
--- a/src/cli/banner.ts
+++ b/src/cli/banner.ts
@@ -71,6 +71,7 @@ const LOBSTER_ASCII = [
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
" 🦞 FRESH DAILY 🦞 ",
+ " ",
];
export function formatCliBannerArt(options: BannerOptions = {}): string {
diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts
index 68894adf5..b72267a2a 100644
--- a/src/cli/memory-cli.ts
+++ b/src/cli/memory-cli.ts
@@ -12,7 +12,7 @@ import { setVerbose } from "../globals.js";
import { withProgress, withProgressTotals } from "./progress.js";
import { formatErrorMessage, withManager } from "./cli-utils.js";
import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js";
-import { listMemoryFiles } from "../memory/internal.js";
+import { listMemoryFiles, normalizeExtraMemoryPaths } from "../memory/internal.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
@@ -74,6 +74,10 @@ function resolveAgentIds(cfg: ReturnType, agent?: string): st
return [resolveDefaultAgentId(cfg)];
}
+function formatExtraPaths(workspaceDir: string, extraPaths: string[]): string[] {
+ return normalizeExtraMemoryPaths(workspaceDir, extraPaths).map((entry) => shortenHomePath(entry));
+}
+
async function checkReadableFile(pathname: string): Promise<{ exists: boolean; issue?: string }> {
try {
await fs.access(pathname, fsSync.constants.R_OK);
@@ -110,7 +114,10 @@ async function scanSessionFiles(agentId: string): Promise {
}
}
-async function scanMemoryFiles(workspaceDir: string): Promise {
+async function scanMemoryFiles(
+ workspaceDir: string,
+ extraPaths: string[] = [],
+): Promise {
const issues: string[] = [];
const memoryFile = path.join(workspaceDir, "MEMORY.md");
const altMemoryFile = path.join(workspaceDir, "memory.md");
@@ -121,6 +128,25 @@ async function scanMemoryFiles(workspaceDir: string): Promise {
if (primary.issue) issues.push(primary.issue);
if (alt.issue) issues.push(alt.issue);
+ const resolvedExtraPaths = normalizeExtraMemoryPaths(workspaceDir, extraPaths);
+ for (const extraPath of resolvedExtraPaths) {
+ try {
+ const stat = await fs.lstat(extraPath);
+ if (stat.isSymbolicLink()) continue;
+ const extraCheck = await checkReadableFile(extraPath);
+ if (extraCheck.issue) issues.push(extraCheck.issue);
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === "ENOENT") {
+ issues.push(`additional memory path missing (${shortenHomePath(extraPath)})`);
+ } else {
+ issues.push(
+ `additional memory path not accessible (${shortenHomePath(extraPath)}): ${code ?? "error"}`,
+ );
+ }
+ }
+ }
+
let dirReadable: boolean | null = null;
try {
await fs.access(memoryDir, fsSync.constants.R_OK);
@@ -141,7 +167,7 @@ async function scanMemoryFiles(workspaceDir: string): Promise {
let listed: string[] = [];
let listedOk = false;
try {
- listed = await listMemoryFiles(workspaceDir);
+ listed = await listMemoryFiles(workspaceDir, resolvedExtraPaths);
listedOk = true;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
@@ -176,11 +202,13 @@ async function scanMemorySources(params: {
workspaceDir: string;
agentId: string;
sources: MemorySourceName[];
+ extraPaths?: string[];
}): Promise {
const scans: SourceScan[] = [];
+ const extraPaths = params.extraPaths ?? [];
for (const source of params.sources) {
if (source === "memory") {
- scans.push(await scanMemoryFiles(params.workspaceDir));
+ scans.push(await scanMemoryFiles(params.workspaceDir, extraPaths));
}
if (source === "sessions") {
scans.push(await scanSessionFiles(params.agentId));
@@ -268,6 +296,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
workspaceDir: status.workspaceDir,
agentId,
sources,
+ extraPaths: status.extraPaths,
});
allResults.push({ agentId, status, embeddingProbe, indexError, scan });
},
@@ -299,6 +328,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete.";
defaultRuntime.log(line);
}
+ const extraPaths = formatExtraPaths(status.workspaceDir, status.extraPaths ?? []);
const lines = [
`${heading("Memory Search")} ${muted(`(${agentId})`)}`,
`${label("Provider")} ${info(status.provider)} ${muted(
@@ -306,6 +336,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
)}`,
`${label("Model")} ${info(status.model)}`,
status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null,
+ extraPaths.length ? `${label("Extra paths")} ${info(extraPaths.join(", "))}` : null,
`${label("Indexed")} ${success(indexedLabel)}`,
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
`${label("Store")} ${info(shortenHomePath(status.dbPath))}`,
@@ -469,6 +500,7 @@ export function registerMemoryCli(program: Command) {
const sourceLabels = status.sources.map((source) =>
formatSourceLabel(source, status.workspaceDir, agentId),
);
+ const extraPaths = formatExtraPaths(status.workspaceDir, status.extraPaths ?? []);
const lines = [
`${heading("Memory Index")} ${muted(`(${agentId})`)}`,
`${label("Provider")} ${info(status.provider)} ${muted(
@@ -478,6 +510,9 @@ export function registerMemoryCli(program: Command) {
sourceLabels.length
? `${label("Sources")} ${info(sourceLabels.join(", "))}`
: null,
+ extraPaths.length
+ ? `${label("Extra paths")} ${info(extraPaths.join(", "))}`
+ : null,
].filter(Boolean) as string[];
if (status.fallback) {
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts
index 97ca4508a..e5684fbea 100644
--- a/src/cli/program/register.subclis.ts
+++ b/src/cli/program/register.subclis.ts
@@ -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);
},
diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts
index bf2c94da7..856b18bfb 100644
--- a/src/commands/doctor-security.ts
+++ b/src/commands/doctor-security.ts
@@ -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.`,
);
}
};
diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts
index e1f8dbe8e..27ec07de4 100644
--- a/src/commands/onboard-channels.ts
+++ b/src/commands/onboard-channels.ts
@@ -190,7 +190,7 @@ async function noteChannelPrimer(
"DM security: default is pairing; unknown DMs get a pairing code.",
`Approve with: ${formatCliCommand("moltbot pairing approve ")}`,
'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} `)}`,
`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`,
diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts
index 165365bb6..376555a39 100644
--- a/src/commands/onboard-helpers.ts
+++ b/src/commands/onboard-helpers.ts
@@ -69,7 +69,8 @@ export function printWizardHeader(runtime: RuntimeEnv) {
"██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████",
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
- " 🦞 FRESH DAILY 🦞 ",
+ " 🦞 FRESH DAILY 🦞 ",
+ " ",
].join("\n");
runtime.log(header);
}
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 9b5ad8be6..28c994f3d 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -222,6 +222,7 @@ const FIELD_LABELS: Record = {
"agents.defaults.memorySearch": "Memory Search",
"agents.defaults.memorySearch.enabled": "Enable Memory Search",
"agents.defaults.memorySearch.sources": "Memory Search Sources",
+ "agents.defaults.memorySearch.extraPaths": "Extra Memory Paths",
"agents.defaults.memorySearch.experimental.sessionMemory":
"Memory Search Session Index (Experimental)",
"agents.defaults.memorySearch.provider": "Memory Search Provider",
@@ -499,6 +500,8 @@ const FIELD_HELP: Record = {
"Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).",
"agents.defaults.memorySearch.sources":
'Sources to index for memory search (default: ["memory"]; add "sessions" to include session transcripts).',
+ "agents.defaults.memorySearch.extraPaths":
+ "Extra paths to include in memory search (directories or .md files; relative paths resolved from workspace).",
"agents.defaults.memorySearch.experimental.sessionMemory":
"Enable experimental session transcript indexing for memory search (default: false).",
"agents.defaults.memorySearch.provider": 'Embedding provider ("openai", "gemini", or "local").',
@@ -591,7 +594,7 @@ const FIELD_HELP: Record = {
"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":
diff --git a/src/config/types.base.ts b/src/config/types.base.ts
index cc805e8ec..e7da1ecd8 100644
--- a/src/config/types.base.ts
+++ b/src/config/types.base.ts
@@ -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";
diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts
index bb1d45bf0..db32cb59d 100644
--- a/src/config/types.tools.ts
+++ b/src/config/types.tools.ts
@@ -226,6 +226,8 @@ export type MemorySearchConfig = {
enabled?: boolean;
/** Sources to index and search (default: ["memory"]). */
sources?: Array<"memory" | "sessions">;
+ /** Extra paths to include in memory search (directories or .md files). */
+ extraPaths?: string[];
/** Experimental memory search settings. */
experimental?: {
/** Enable session transcript indexing (experimental, default: false). */
diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts
index 7a63e307d..7e95c3538 100644
--- a/src/config/zod-schema.agent-runtime.ts
+++ b/src/config/zod-schema.agent-runtime.ts
@@ -304,6 +304,7 @@ export const MemorySearchSchema = z
.object({
enabled: z.boolean().optional(),
sources: z.array(z.union([z.literal("memory"), z.literal("sessions")])).optional(),
+ extraPaths: z.array(z.string()).optional(),
experimental: z
.object({
sessionMemory: z.boolean().optional(),
diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts
index b9e7b42cc..4412f5515 100644
--- a/src/config/zod-schema.session.ts
+++ b/src/config/zod-schema.session.ts
@@ -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(),
diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts
index 80bb5ff8f..bd4ec38ca 100644
--- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts
+++ b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts
@@ -135,7 +135,7 @@ describe("discord tool result dispatch", () => {
expect(sendMock).toHaveBeenCalledTimes(1);
}, 20_000);
- it("skips guild messages when another user is explicitly mentioned", async () => {
+ it("accepts guild messages when mentionPatterns match even if another user is mentioned", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agents: {
@@ -211,8 +211,8 @@ describe("discord tool result dispatch", () => {
client,
);
- expect(dispatchMock).not.toHaveBeenCalled();
- expect(sendMock).not.toHaveBeenCalled();
+ expect(dispatchMock).toHaveBeenCalledTimes(1);
+ expect(sendMock).toHaveBeenCalledTimes(1);
}, 20_000);
it("accepts guild reply-to-bot messages as implicit mentions", async () => {
diff --git a/src/discord/send.shared.ts b/src/discord/send.shared.ts
index 1cf2a93a9..e247300ee 100644
--- a/src/discord/send.shared.ts
+++ b/src/discord/send.shared.ts
@@ -118,19 +118,26 @@ export async function parseAndResolveRecipient(
const accountInfo = resolveDiscordAccount({ cfg, accountId });
// First try to resolve using directory lookup (handles usernames)
- const resolved = await resolveDiscordTarget(raw, {
- cfg,
- accountId: accountInfo.accountId,
- });
+ 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, {
- ambiguousMessage: `Ambiguous Discord recipient "${raw.trim()}". Use "user:${raw.trim()}" for DMs or "channel:${raw.trim()}" for channel messages.`,
- });
+ const parsed = parseDiscordTarget(raw, parseOptions);
if (!parsed) {
throw new Error("Recipient is required for Discord sends");
diff --git a/src/discord/targets.ts b/src/discord/targets.ts
index c6f56cf53..5ea6f5b1b 100644
--- a/src/discord/targets.ts
+++ b/src/discord/targets.ts
@@ -71,16 +71,17 @@ export function resolveDiscordChannelId(raw: string): string {
*
* @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 {
const trimmed = raw.trim();
if (!trimmed) return undefined;
- const parseOptions: DiscordTargetParseOptions = {};
const likelyUsername = isLikelyUsername(trimmed);
const shouldLookup = isExplicitUserLookup(trimmed, parseOptions) || likelyUsername;
const directParse = safeParseDiscordTarget(trimmed, parseOptions);
diff --git a/src/hooks/bundled/session-memory/HOOK.md b/src/hooks/bundled/session-memory/HOOK.md
index 2a635a645..41223eb05 100644
--- a/src/hooks/bundled/session-memory/HOOK.md
+++ b/src/hooks/bundled/session-memory/HOOK.md
@@ -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 `/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
diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts
new file mode 100644
index 000000000..525e21059
--- /dev/null
+++ b/src/hooks/bundled/session-memory/handler.test.ts
@@ -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");
+ });
+});
diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts
index c087d73e8..5b5a69c9c 100644
--- a/src/hooks/bundled/session-memory/handler.ts
+++ b/src/hooks/bundled/session-memory/handler.ts
@@ -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 {
+async function getRecentSessionContent(
+ sessionFilePath: string,
+ messageCount: number = 15,
+): Promise {
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 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 {
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);
diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts
index c74abc509..9c12fab96 100644
--- a/src/infra/outbound/outbound-session.ts
+++ b/src/infra/outbound/outbound-session.ts
@@ -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 {
diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts
index 8bebb8e20..7a4d68136 100644
--- a/src/media-understanding/apply.test.ts
+++ b/src/media-understanding/apply.test.ts
@@ -41,7 +41,7 @@ describe("applyMediaUnderstanding", () => {
mockedResolveApiKey.mockClear();
mockedFetchRemoteMedia.mockReset();
mockedFetchRemoteMedia.mockResolvedValue({
- buffer: Buffer.from("audio-bytes"),
+ buffer: Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]),
contentType: "audio/ogg",
fileName: "note.ogg",
});
@@ -51,7 +51,7 @@ describe("applyMediaUnderstanding", () => {
const { applyMediaUnderstanding } = await loadApply();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
const audioPath = path.join(dir, "note.ogg");
- await fs.writeFile(audioPath, "hello");
+ await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8]));
const ctx: MsgContext = {
Body: "",
@@ -94,7 +94,7 @@ describe("applyMediaUnderstanding", () => {
const { applyMediaUnderstanding } = await loadApply();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
const audioPath = path.join(dir, "note.ogg");
- await fs.writeFile(audioPath, "hello");
+ await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8]));
const ctx: MsgContext = {
Body: " /capture status",
@@ -176,7 +176,7 @@ describe("applyMediaUnderstanding", () => {
const { applyMediaUnderstanding } = await loadApply();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
const audioPath = path.join(dir, "large.wav");
- await fs.writeFile(audioPath, "0123456789");
+ await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]));
const ctx: MsgContext = {
Body: "",
@@ -211,7 +211,7 @@ describe("applyMediaUnderstanding", () => {
const { applyMediaUnderstanding } = await loadApply();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
const audioPath = path.join(dir, "note.ogg");
- await fs.writeFile(audioPath, "hello");
+ await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8]));
const ctx: MsgContext = {
Body: "",
@@ -352,7 +352,7 @@ describe("applyMediaUnderstanding", () => {
const { applyMediaUnderstanding } = await loadApply();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
const audioPath = path.join(dir, "fallback.ogg");
- await fs.writeFile(audioPath, "hello");
+ await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6]));
const ctx: MsgContext = {
Body: "",
@@ -390,8 +390,8 @@ describe("applyMediaUnderstanding", () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
const audioPathA = path.join(dir, "note-a.ogg");
const audioPathB = path.join(dir, "note-b.ogg");
- await fs.writeFile(audioPathA, "hello");
- await fs.writeFile(audioPathB, "world");
+ await fs.writeFile(audioPathA, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208]));
+ await fs.writeFile(audioPathB, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208]));
const ctx: MsgContext = {
Body: "",
@@ -435,7 +435,7 @@ describe("applyMediaUnderstanding", () => {
const audioPath = path.join(dir, "note.ogg");
const videoPath = path.join(dir, "clip.mp4");
await fs.writeFile(imagePath, "image-bytes");
- await fs.writeFile(audioPath, "audio-bytes");
+ await fs.writeFile(audioPath, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208]));
await fs.writeFile(videoPath, "video-bytes");
const ctx: MsgContext = {
@@ -487,4 +487,187 @@ describe("applyMediaUnderstanding", () => {
expect(ctx.CommandBody).toBe("audio ok");
expect(ctx.BodyForCommands).toBe("audio ok");
});
+
+ it("treats text-like audio attachments as CSV (comma wins over tabs)", async () => {
+ const { applyMediaUnderstanding } = await loadApply();
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
+ const csvPath = path.join(dir, "data.mp3");
+ const csvText = '"a","b"\t"c"\n"1","2"\t"3"';
+ const csvBuffer = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(csvText, "utf16le")]);
+ await fs.writeFile(csvPath, csvBuffer);
+
+ const ctx: MsgContext = {
+ Body: "",
+ MediaPath: csvPath,
+ MediaType: "audio/mpeg",
+ };
+ const cfg: MoltbotConfig = {
+ tools: {
+ media: {
+ audio: { enabled: false },
+ image: { enabled: false },
+ video: { enabled: false },
+ },
+ },
+ };
+
+ const result = await applyMediaUnderstanding({ ctx, cfg });
+
+ expect(result.appliedFile).toBe(true);
+ expect(ctx.Body).toContain('');
+ expect(ctx.Body).toContain('"a","b"\t"c"');
+ });
+
+ it("infers TSV when tabs are present without commas", async () => {
+ const { applyMediaUnderstanding } = await loadApply();
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
+ const tsvPath = path.join(dir, "report.mp3");
+ const tsvText = "a\tb\tc\n1\t2\t3";
+ await fs.writeFile(tsvPath, tsvText);
+
+ const ctx: MsgContext = {
+ Body: "",
+ MediaPath: tsvPath,
+ MediaType: "audio/mpeg",
+ };
+ const cfg: MoltbotConfig = {
+ tools: {
+ media: {
+ audio: { enabled: false },
+ image: { enabled: false },
+ video: { enabled: false },
+ },
+ },
+ };
+
+ const result = await applyMediaUnderstanding({ ctx, cfg });
+
+ expect(result.appliedFile).toBe(true);
+ expect(ctx.Body).toContain('');
+ expect(ctx.Body).toContain("a\tb\tc");
+ });
+
+ it("escapes XML special characters in filenames to prevent injection", async () => {
+ const { applyMediaUnderstanding } = await loadApply();
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
+ // Use & in filename — valid on all platforms (including Windows, which
+ // forbids < and > in NTFS filenames) and still requires XML escaping.
+ // Note: The sanitizeFilename in store.ts would strip most dangerous chars,
+ // but we test that even if some slip through, they get escaped in output
+ const filePath = path.join(dir, "file&test.txt");
+ await fs.writeFile(filePath, "safe content");
+
+ const ctx: MsgContext = {
+ Body: "",
+ MediaPath: filePath,
+ MediaType: "text/plain",
+ };
+ const cfg: MoltbotConfig = {
+ tools: {
+ media: {
+ audio: { enabled: false },
+ image: { enabled: false },
+ video: { enabled: false },
+ },
+ },
+ };
+
+ const result = await applyMediaUnderstanding({ ctx, cfg });
+
+ expect(result.appliedFile).toBe(true);
+ // Verify XML special chars are escaped in the output
+ expect(ctx.Body).toContain("&");
+ // The name attribute should contain the escaped form, not a raw unescaped &
+ expect(ctx.Body).toMatch(/name="file&test\.txt"/);
+ });
+
+ it("normalizes MIME types to prevent attribute injection", async () => {
+ const { applyMediaUnderstanding } = await loadApply();
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
+ const filePath = path.join(dir, "data.txt");
+ await fs.writeFile(filePath, "test content");
+
+ const ctx: MsgContext = {
+ Body: "",
+ MediaPath: filePath,
+ // Attempt to inject via MIME type with quotes - normalization should strip this
+ MediaType: 'text/plain" onclick="alert(1)',
+ };
+ const cfg: MoltbotConfig = {
+ tools: {
+ media: {
+ audio: { enabled: false },
+ image: { enabled: false },
+ video: { enabled: false },
+ },
+ },
+ };
+
+ const result = await applyMediaUnderstanding({ ctx, cfg });
+
+ expect(result.appliedFile).toBe(true);
+ // MIME normalization strips everything after first ; or " - verify injection is blocked
+ expect(ctx.Body).not.toContain("onclick=");
+ expect(ctx.Body).not.toContain("alert(1)");
+ // Verify the MIME type is normalized to just "text/plain"
+ expect(ctx.Body).toContain('mime="text/plain"');
+ });
+
+ it("handles path traversal attempts in filenames safely", async () => {
+ const { applyMediaUnderstanding } = await loadApply();
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
+ // Even if a file somehow got a path-like name, it should be handled safely
+ const filePath = path.join(dir, "normal.txt");
+ await fs.writeFile(filePath, "legitimate content");
+
+ const ctx: MsgContext = {
+ Body: "",
+ MediaPath: filePath,
+ MediaType: "text/plain",
+ };
+ const cfg: MoltbotConfig = {
+ tools: {
+ media: {
+ audio: { enabled: false },
+ image: { enabled: false },
+ video: { enabled: false },
+ },
+ },
+ };
+
+ const result = await applyMediaUnderstanding({ ctx, cfg });
+
+ expect(result.appliedFile).toBe(true);
+ // Verify the file was processed and output contains expected structure
+ expect(ctx.Body).toContain(' {
+ const { applyMediaUnderstanding } = await loadApply();
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
+ const filePath = path.join(dir, "文档.txt");
+ await fs.writeFile(filePath, "中文内容");
+
+ const ctx: MsgContext = {
+ Body: "",
+ MediaPath: filePath,
+ MediaType: "text/plain",
+ };
+ const cfg: MoltbotConfig = {
+ tools: {
+ media: {
+ audio: { enabled: false },
+ image: { enabled: false },
+ video: { enabled: false },
+ },
+ },
+ };
+
+ const result = await applyMediaUnderstanding({ ctx, cfg });
+
+ expect(result.appliedFile).toBe(true);
+ expect(ctx.Body).toContain("中文内容");
+ });
});
diff --git a/src/media-understanding/apply.ts b/src/media-understanding/apply.ts
index dab640789..7c2a18006 100644
--- a/src/media-understanding/apply.ts
+++ b/src/media-understanding/apply.ts
@@ -1,6 +1,22 @@
+import path from "node:path";
+
import type { MoltbotConfig } from "../config/config.js";
import type { MsgContext } from "../auto-reply/templating.js";
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
+import { logVerbose, shouldLogVerbose } from "../globals.js";
+import {
+ DEFAULT_INPUT_FILE_MAX_BYTES,
+ DEFAULT_INPUT_FILE_MAX_CHARS,
+ DEFAULT_INPUT_FILE_MIMES,
+ DEFAULT_INPUT_MAX_REDIRECTS,
+ DEFAULT_INPUT_PDF_MAX_PAGES,
+ DEFAULT_INPUT_PDF_MAX_PIXELS,
+ DEFAULT_INPUT_PDF_MIN_TEXT_CHARS,
+ DEFAULT_INPUT_TIMEOUT_MS,
+ extractFileContentFromSource,
+ normalizeMimeList,
+ normalizeMimeType,
+} from "../media/input-files.js";
import {
extractMediaUserText,
formatAudioTranscripts,
@@ -14,6 +30,7 @@ import type {
} from "./types.js";
import { runWithConcurrency } from "./concurrency.js";
import { resolveConcurrency } from "./resolve.js";
+import { resolveAttachmentKind } from "./attachments.js";
import {
type ActiveMediaModel,
buildProviderRegistry,
@@ -28,9 +45,279 @@ export type ApplyMediaUnderstandingResult = {
appliedImage: boolean;
appliedAudio: boolean;
appliedVideo: boolean;
+ appliedFile: boolean;
};
const CAPABILITY_ORDER: MediaUnderstandingCapability[] = ["image", "audio", "video"];
+const EXTRA_TEXT_MIMES = [
+ "application/xml",
+ "text/xml",
+ "application/x-yaml",
+ "text/yaml",
+ "application/yaml",
+ "application/javascript",
+ "text/javascript",
+ "text/tab-separated-values",
+];
+const TEXT_EXT_MIME = new Map([
+ [".csv", "text/csv"],
+ [".tsv", "text/tab-separated-values"],
+ [".txt", "text/plain"],
+ [".md", "text/markdown"],
+ [".log", "text/plain"],
+ [".ini", "text/plain"],
+ [".cfg", "text/plain"],
+ [".conf", "text/plain"],
+ [".env", "text/plain"],
+ [".json", "application/json"],
+ [".yaml", "text/yaml"],
+ [".yml", "text/yaml"],
+ [".xml", "application/xml"],
+]);
+
+const XML_ESCAPE_MAP: Record = {
+ "<": "<",
+ ">": ">",
+ "&": "&",
+ '"': """,
+ "'": "'",
+};
+
+/**
+ * Escapes special XML characters in attribute values to prevent injection.
+ */
+function xmlEscapeAttr(value: string): string {
+ return value.replace(/[<>&"']/g, (char) => XML_ESCAPE_MAP[char] ?? char);
+}
+
+function resolveFileLimits(cfg: MoltbotConfig) {
+ const files = cfg.gateway?.http?.endpoints?.responses?.files;
+ return {
+ allowUrl: files?.allowUrl ?? true,
+ allowedMimes: normalizeMimeList(files?.allowedMimes, DEFAULT_INPUT_FILE_MIMES),
+ maxBytes: files?.maxBytes ?? DEFAULT_INPUT_FILE_MAX_BYTES,
+ maxChars: files?.maxChars ?? DEFAULT_INPUT_FILE_MAX_CHARS,
+ maxRedirects: files?.maxRedirects ?? DEFAULT_INPUT_MAX_REDIRECTS,
+ timeoutMs: files?.timeoutMs ?? DEFAULT_INPUT_TIMEOUT_MS,
+ pdf: {
+ maxPages: files?.pdf?.maxPages ?? DEFAULT_INPUT_PDF_MAX_PAGES,
+ maxPixels: files?.pdf?.maxPixels ?? DEFAULT_INPUT_PDF_MAX_PIXELS,
+ minTextChars: files?.pdf?.minTextChars ?? DEFAULT_INPUT_PDF_MIN_TEXT_CHARS,
+ },
+ };
+}
+
+function appendFileBlocks(body: string | undefined, blocks: string[]): string {
+ if (!blocks || blocks.length === 0) {
+ return body ?? "";
+ }
+ const base = typeof body === "string" ? body.trim() : "";
+ const suffix = blocks.join("\n\n").trim();
+ if (!base) {
+ return suffix;
+ }
+ return `${base}\n\n${suffix}`.trim();
+}
+
+function resolveUtf16Charset(buffer?: Buffer): "utf-16le" | "utf-16be" | undefined {
+ if (!buffer || buffer.length < 2) return undefined;
+ const b0 = buffer[0];
+ const b1 = buffer[1];
+ if (b0 === 0xff && b1 === 0xfe) {
+ return "utf-16le";
+ }
+ if (b0 === 0xfe && b1 === 0xff) {
+ return "utf-16be";
+ }
+ const sampleLen = Math.min(buffer.length, 2048);
+ let zeroCount = 0;
+ for (let i = 0; i < sampleLen; i += 1) {
+ if (buffer[i] === 0) zeroCount += 1;
+ }
+ if (zeroCount / sampleLen > 0.2) {
+ return "utf-16le";
+ }
+ return undefined;
+}
+
+function looksLikeUtf8Text(buffer?: Buffer): boolean {
+ if (!buffer || buffer.length === 0) return false;
+ const sampleLen = Math.min(buffer.length, 4096);
+ let printable = 0;
+ let other = 0;
+ for (let i = 0; i < sampleLen; i += 1) {
+ const byte = buffer[i];
+ if (byte === 0) {
+ other += 1;
+ continue;
+ }
+ if (byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte <= 126)) {
+ printable += 1;
+ } else {
+ other += 1;
+ }
+ }
+ const total = printable + other;
+ if (total === 0) return false;
+ return printable / total > 0.85;
+}
+
+function decodeTextSample(buffer?: Buffer): string {
+ if (!buffer || buffer.length === 0) return "";
+ const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
+ const utf16Charset = resolveUtf16Charset(sample);
+ if (utf16Charset === "utf-16be") {
+ const swapped = Buffer.alloc(sample.length);
+ for (let i = 0; i + 1 < sample.length; i += 2) {
+ swapped[i] = sample[i + 1];
+ swapped[i + 1] = sample[i];
+ }
+ return new TextDecoder("utf-16le").decode(swapped);
+ }
+ if (utf16Charset === "utf-16le") {
+ return new TextDecoder("utf-16le").decode(sample);
+ }
+ return new TextDecoder("utf-8").decode(sample);
+}
+
+function guessDelimitedMime(text: string): string | undefined {
+ if (!text) return undefined;
+ const line = text.split(/\r?\n/)[0] ?? "";
+ const tabs = (line.match(/\t/g) ?? []).length;
+ const commas = (line.match(/,/g) ?? []).length;
+ if (commas > 0) {
+ return "text/csv";
+ }
+ if (tabs > 0) {
+ return "text/tab-separated-values";
+ }
+ return undefined;
+}
+
+function resolveTextMimeFromName(name?: string): string | undefined {
+ if (!name) return undefined;
+ const ext = path.extname(name).toLowerCase();
+ return TEXT_EXT_MIME.get(ext);
+}
+
+async function extractFileBlocks(params: {
+ attachments: ReturnType;
+ cache: ReturnType;
+ limits: ReturnType;
+}): Promise {
+ const { attachments, cache, limits } = params;
+ if (!attachments || attachments.length === 0) {
+ return [];
+ }
+ const blocks: string[] = [];
+ for (const attachment of attachments) {
+ if (!attachment) {
+ continue;
+ }
+ const forcedTextMime = resolveTextMimeFromName(attachment.path ?? attachment.url ?? "");
+ const kind = forcedTextMime ? "document" : resolveAttachmentKind(attachment);
+ if (!forcedTextMime && (kind === "image" || kind === "video")) {
+ continue;
+ }
+ if (!limits.allowUrl && attachment.url && !attachment.path) {
+ if (shouldLogVerbose()) {
+ logVerbose(`media: file attachment skipped (url disabled) index=${attachment.index}`);
+ }
+ continue;
+ }
+ let bufferResult: Awaited>;
+ try {
+ bufferResult = await cache.getBuffer({
+ attachmentIndex: attachment.index,
+ maxBytes: limits.maxBytes,
+ timeoutMs: limits.timeoutMs,
+ });
+ } catch (err) {
+ if (shouldLogVerbose()) {
+ logVerbose(`media: file attachment skipped (buffer): ${String(err)}`);
+ }
+ continue;
+ }
+ const nameHint = bufferResult?.fileName ?? attachment.path ?? attachment.url;
+ const forcedTextMimeResolved = forcedTextMime ?? resolveTextMimeFromName(nameHint ?? "");
+ const utf16Charset = resolveUtf16Charset(bufferResult?.buffer);
+ const textSample = decodeTextSample(bufferResult?.buffer);
+ const textLike = Boolean(utf16Charset) || looksLikeUtf8Text(bufferResult?.buffer);
+ if (!forcedTextMimeResolved && kind === "audio" && !textLike) {
+ continue;
+ }
+ const guessedDelimited = textLike ? guessDelimitedMime(textSample) : undefined;
+ const textHint =
+ forcedTextMimeResolved ?? guessedDelimited ?? (textLike ? "text/plain" : undefined);
+ const rawMime = bufferResult?.mime ?? attachment.mime;
+ const mimeType = textHint ?? normalizeMimeType(rawMime);
+ // Log when MIME type is overridden from non-text to text for auditability
+ if (textHint && rawMime && !rawMime.startsWith("text/")) {
+ logVerbose(
+ `media: MIME override from "${rawMime}" to "${textHint}" for index=${attachment.index}`,
+ );
+ }
+ if (!mimeType) {
+ if (shouldLogVerbose()) {
+ logVerbose(`media: file attachment skipped (unknown mime) index=${attachment.index}`);
+ }
+ continue;
+ }
+ const allowedMimes = new Set(limits.allowedMimes);
+ for (const extra of EXTRA_TEXT_MIMES) {
+ allowedMimes.add(extra);
+ }
+ if (mimeType.startsWith("text/")) {
+ allowedMimes.add(mimeType);
+ }
+ if (!allowedMimes.has(mimeType)) {
+ if (shouldLogVerbose()) {
+ logVerbose(
+ `media: file attachment skipped (unsupported mime ${mimeType}) index=${attachment.index}`,
+ );
+ }
+ continue;
+ }
+ let extracted: Awaited>;
+ try {
+ const mediaType = utf16Charset ? `${mimeType}; charset=${utf16Charset}` : mimeType;
+ extracted = await extractFileContentFromSource({
+ source: {
+ type: "base64",
+ data: bufferResult.buffer.toString("base64"),
+ mediaType,
+ filename: bufferResult.fileName,
+ },
+ limits: {
+ ...limits,
+ allowedMimes,
+ },
+ });
+ } catch (err) {
+ if (shouldLogVerbose()) {
+ logVerbose(`media: file attachment skipped (extract): ${String(err)}`);
+ }
+ continue;
+ }
+ const text = extracted?.text?.trim() ?? "";
+ let blockText = text;
+ if (!blockText) {
+ if (extracted?.images && extracted.images.length > 0) {
+ blockText = "[PDF content rendered to images; images not forwarded to model]";
+ } else {
+ blockText = "[No extractable text]";
+ }
+ }
+ const safeName = (bufferResult.fileName ?? `file-${attachment.index + 1}`)
+ .replace(/[\r\n\t]+/g, " ")
+ .trim();
+ // Escape XML special characters in attributes to prevent injection
+ blocks.push(
+ `\n${blockText}\n `,
+ );
+ }
+ return blocks;
+}
export async function applyMediaUnderstanding(params: {
ctx: MsgContext;
@@ -51,6 +338,12 @@ export async function applyMediaUnderstanding(params: {
const cache = createMediaAttachmentCache(attachments);
try {
+ const fileBlocks = await extractFileBlocks({
+ attachments,
+ cache,
+ limits: resolveFileLimits(cfg),
+ });
+
const tasks = CAPABILITY_ORDER.map((capability) => async () => {
const config = cfg.tools?.media?.[capability];
return await runCapability({
@@ -99,7 +392,15 @@ export async function applyMediaUnderstanding(params: {
ctx.RawBody = originalUserText;
}
ctx.MediaUnderstanding = [...(ctx.MediaUnderstanding ?? []), ...outputs];
- finalizeInboundContext(ctx, { forceBodyForAgent: true, forceBodyForCommands: true });
+ }
+ if (fileBlocks.length > 0) {
+ ctx.Body = appendFileBlocks(ctx.Body, fileBlocks);
+ }
+ if (outputs.length > 0 || fileBlocks.length > 0) {
+ finalizeInboundContext(ctx, {
+ forceBodyForAgent: true,
+ forceBodyForCommands: outputs.length > 0,
+ });
}
return {
@@ -108,6 +409,7 @@ export async function applyMediaUnderstanding(params: {
appliedImage: outputs.some((output) => output.kind === "image.description"),
appliedAudio: outputs.some((output) => output.kind === "audio.transcription"),
appliedVideo: outputs.some((output) => output.kind === "video.description"),
+ appliedFile: fileBlocks.length > 0,
};
} finally {
await cache.cleanup();
diff --git a/src/media/mime.test.ts b/src/media/mime.test.ts
index a3c2a35d8..92325a62e 100644
--- a/src/media/mime.test.ts
+++ b/src/media/mime.test.ts
@@ -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 {
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();
+ });
+});
diff --git a/src/media/mime.ts b/src/media/mime.ts
index 79677b1cb..c50e9152c 100644
--- a/src/media/mime.ts
+++ b/src/media/mime.ts
@@ -13,7 +13,10 @@ const EXT_BY_MIME: Record = {
"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",
diff --git a/src/memory/index.test.ts b/src/memory/index.test.ts
index 58a98e580..cccd1fa49 100644
--- a/src/memory/index.test.ts
+++ b/src/memory/index.test.ts
@@ -412,4 +412,52 @@ describe("memory index", () => {
manager = result.manager;
await expect(result.manager.readFile({ relPath: "NOTES.md" })).rejects.toThrow("path required");
});
+
+ it("allows reading from additional memory paths and blocks symlinks", async () => {
+ const extraDir = path.join(workspaceDir, "extra");
+ await fs.mkdir(extraDir, { recursive: true });
+ await fs.writeFile(path.join(extraDir, "extra.md"), "Extra content.");
+
+ const cfg = {
+ agents: {
+ defaults: {
+ workspace: workspaceDir,
+ memorySearch: {
+ provider: "openai",
+ model: "mock-embed",
+ store: { path: indexPath },
+ sync: { watch: false, onSessionStart: false, onSearch: true },
+ extraPaths: [extraDir],
+ },
+ },
+ list: [{ id: "main", default: true }],
+ },
+ };
+ const result = await getMemorySearchManager({ cfg, agentId: "main" });
+ expect(result.manager).not.toBeNull();
+ if (!result.manager) throw new Error("manager missing");
+ manager = result.manager;
+ await expect(result.manager.readFile({ relPath: "extra/extra.md" })).resolves.toEqual({
+ path: "extra/extra.md",
+ text: "Extra content.",
+ });
+
+ const linkPath = path.join(extraDir, "linked.md");
+ let symlinkOk = true;
+ try {
+ await fs.symlink(path.join(extraDir, "extra.md"), linkPath, "file");
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === "EPERM" || code === "EACCES") {
+ symlinkOk = false;
+ } else {
+ throw err;
+ }
+ }
+ if (symlinkOk) {
+ await expect(result.manager.readFile({ relPath: "extra/linked.md" })).rejects.toThrow(
+ "path required",
+ );
+ }
+ });
});
diff --git a/src/memory/internal.test.ts b/src/memory/internal.test.ts
index 29c698779..7530d8e44 100644
--- a/src/memory/internal.test.ts
+++ b/src/memory/internal.test.ts
@@ -1,6 +1,117 @@
-import { describe, expect, it } from "vitest";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
-import { chunkMarkdown } from "./internal.js";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import { chunkMarkdown, listMemoryFiles, normalizeExtraMemoryPaths } from "./internal.js";
+
+describe("normalizeExtraMemoryPaths", () => {
+ it("trims, resolves, and dedupes paths", () => {
+ const workspaceDir = path.join(os.tmpdir(), "memory-test-workspace");
+ const absPath = path.resolve(path.sep, "shared-notes");
+ const result = normalizeExtraMemoryPaths(workspaceDir, [
+ " notes ",
+ "./notes",
+ absPath,
+ absPath,
+ "",
+ ]);
+ expect(result).toEqual([path.resolve(workspaceDir, "notes"), absPath]);
+ });
+});
+
+describe("listMemoryFiles", () => {
+ let tmpDir: string;
+
+ beforeEach(async () => {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-test-"));
+ });
+
+ afterEach(async () => {
+ await fs.rm(tmpDir, { recursive: true, force: true });
+ });
+
+ it("includes files from additional paths (directory)", async () => {
+ await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
+ const extraDir = path.join(tmpDir, "extra-notes");
+ await fs.mkdir(extraDir, { recursive: true });
+ await fs.writeFile(path.join(extraDir, "note1.md"), "# Note 1");
+ await fs.writeFile(path.join(extraDir, "note2.md"), "# Note 2");
+ await fs.writeFile(path.join(extraDir, "ignore.txt"), "Not a markdown file");
+
+ const files = await listMemoryFiles(tmpDir, [extraDir]);
+ expect(files).toHaveLength(3);
+ expect(files.some((file) => file.endsWith("MEMORY.md"))).toBe(true);
+ expect(files.some((file) => file.endsWith("note1.md"))).toBe(true);
+ expect(files.some((file) => file.endsWith("note2.md"))).toBe(true);
+ expect(files.some((file) => file.endsWith("ignore.txt"))).toBe(false);
+ });
+
+ it("includes files from additional paths (single file)", async () => {
+ await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
+ const singleFile = path.join(tmpDir, "standalone.md");
+ await fs.writeFile(singleFile, "# Standalone");
+
+ const files = await listMemoryFiles(tmpDir, [singleFile]);
+ expect(files).toHaveLength(2);
+ expect(files.some((file) => file.endsWith("standalone.md"))).toBe(true);
+ });
+
+ it("handles relative paths in additional paths", async () => {
+ await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
+ const extraDir = path.join(tmpDir, "subdir");
+ await fs.mkdir(extraDir, { recursive: true });
+ await fs.writeFile(path.join(extraDir, "nested.md"), "# Nested");
+
+ const files = await listMemoryFiles(tmpDir, ["subdir"]);
+ expect(files).toHaveLength(2);
+ expect(files.some((file) => file.endsWith("nested.md"))).toBe(true);
+ });
+
+ it("ignores non-existent additional paths", async () => {
+ await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
+
+ const files = await listMemoryFiles(tmpDir, ["/does/not/exist"]);
+ expect(files).toHaveLength(1);
+ });
+
+ it("ignores symlinked files and directories", async () => {
+ await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
+ const extraDir = path.join(tmpDir, "extra");
+ await fs.mkdir(extraDir, { recursive: true });
+ await fs.writeFile(path.join(extraDir, "note.md"), "# Note");
+
+ const targetFile = path.join(tmpDir, "target.md");
+ await fs.writeFile(targetFile, "# Target");
+ const linkFile = path.join(extraDir, "linked.md");
+
+ const targetDir = path.join(tmpDir, "target-dir");
+ await fs.mkdir(targetDir, { recursive: true });
+ await fs.writeFile(path.join(targetDir, "nested.md"), "# Nested");
+ const linkDir = path.join(tmpDir, "linked-dir");
+
+ let symlinksOk = true;
+ try {
+ await fs.symlink(targetFile, linkFile, "file");
+ await fs.symlink(targetDir, linkDir, "dir");
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === "EPERM" || code === "EACCES") {
+ symlinksOk = false;
+ } else {
+ throw err;
+ }
+ }
+
+ const files = await listMemoryFiles(tmpDir, [extraDir, linkDir]);
+ expect(files.some((file) => file.endsWith("note.md"))).toBe(true);
+ if (symlinksOk) {
+ expect(files.some((file) => file.endsWith("linked.md"))).toBe(false);
+ expect(files.some((file) => file.endsWith("nested.md"))).toBe(false);
+ }
+ });
+});
describe("chunkMarkdown", () => {
it("splits overly long lines into max-sized chunks", () => {
diff --git a/src/memory/internal.ts b/src/memory/internal.ts
index b68570c35..b2ab8c0a4 100644
--- a/src/memory/internal.ts
+++ b/src/memory/internal.ts
@@ -30,6 +30,17 @@ export function normalizeRelPath(value: string): string {
return trimmed.replace(/\\/g, "/");
}
+export function normalizeExtraMemoryPaths(workspaceDir: string, extraPaths?: string[]): string[] {
+ if (!extraPaths?.length) return [];
+ const resolved = extraPaths
+ .map((value) => value.trim())
+ .filter(Boolean)
+ .map((value) =>
+ path.isAbsolute(value) ? path.resolve(value) : path.resolve(workspaceDir, value),
+ );
+ return Array.from(new Set(resolved));
+}
+
export function isMemoryPath(relPath: string): boolean {
const normalized = normalizeRelPath(relPath);
if (!normalized) return false;
@@ -37,19 +48,11 @@ export function isMemoryPath(relPath: string): boolean {
return normalized.startsWith("memory/");
}
-async function exists(filePath: string): Promise {
- try {
- await fs.access(filePath);
- return true;
- } catch {
- return false;
- }
-}
-
async function walkDir(dir: string, files: string[]) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
+ if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) {
await walkDir(full, files);
continue;
@@ -60,15 +63,48 @@ async function walkDir(dir: string, files: string[]) {
}
}
-export async function listMemoryFiles(workspaceDir: string): Promise {
+export async function listMemoryFiles(
+ workspaceDir: string,
+ extraPaths?: string[],
+): Promise {
const result: string[] = [];
const memoryFile = path.join(workspaceDir, "MEMORY.md");
const altMemoryFile = path.join(workspaceDir, "memory.md");
- if (await exists(memoryFile)) result.push(memoryFile);
- if (await exists(altMemoryFile)) result.push(altMemoryFile);
const memoryDir = path.join(workspaceDir, "memory");
- if (await exists(memoryDir)) {
- await walkDir(memoryDir, result);
+
+ const addMarkdownFile = async (absPath: string) => {
+ try {
+ const stat = await fs.lstat(absPath);
+ if (stat.isSymbolicLink() || !stat.isFile()) return;
+ if (!absPath.endsWith(".md")) return;
+ result.push(absPath);
+ } catch {}
+ };
+
+ await addMarkdownFile(memoryFile);
+ await addMarkdownFile(altMemoryFile);
+ try {
+ const dirStat = await fs.lstat(memoryDir);
+ if (!dirStat.isSymbolicLink() && dirStat.isDirectory()) {
+ await walkDir(memoryDir, result);
+ }
+ } catch {}
+
+ const normalizedExtraPaths = normalizeExtraMemoryPaths(workspaceDir, extraPaths);
+ if (normalizedExtraPaths.length > 0) {
+ for (const inputPath of normalizedExtraPaths) {
+ try {
+ const stat = await fs.lstat(inputPath);
+ if (stat.isSymbolicLink()) continue;
+ if (stat.isDirectory()) {
+ await walkDir(inputPath, result);
+ continue;
+ }
+ if (stat.isFile() && inputPath.endsWith(".md")) {
+ result.push(inputPath);
+ }
+ } catch {}
+ }
}
if (result.length <= 1) return result;
const seen = new Set();
diff --git a/src/memory/manager-cache-key.ts b/src/memory/manager-cache-key.ts
index 9fbe3e436..d143a9057 100644
--- a/src/memory/manager-cache-key.ts
+++ b/src/memory/manager-cache-key.ts
@@ -13,6 +13,7 @@ export function computeMemoryManagerCacheKey(params: {
JSON.stringify({
enabled: settings.enabled,
sources: [...settings.sources].sort((a, b) => a.localeCompare(b)),
+ extraPaths: [...settings.extraPaths].sort((a, b) => a.localeCompare(b)),
provider: settings.provider,
model: settings.model,
fallback: settings.fallback,
diff --git a/src/memory/manager.ts b/src/memory/manager.ts
index 9a9991d10..a799a5e0f 100644
--- a/src/memory/manager.ts
+++ b/src/memory/manager.ts
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
+import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
@@ -35,9 +36,9 @@ import {
hashText,
isMemoryPath,
listMemoryFiles,
+ normalizeExtraMemoryPaths,
type MemoryChunk,
type MemoryFileEntry,
- normalizeRelPath,
parseEmbedding,
} from "./internal.js";
import { bm25RankToScore, buildFtsQuery, mergeHybridResults } from "./hybrid.js";
@@ -396,13 +397,52 @@ export class MemoryIndexManager {
from?: number;
lines?: number;
}): Promise<{ text: string; path: string }> {
- const relPath = normalizeRelPath(params.relPath);
- if (!relPath || !isMemoryPath(relPath)) {
+ const rawPath = params.relPath.trim();
+ if (!rawPath) {
throw new Error("path required");
}
- const absPath = path.resolve(this.workspaceDir, relPath);
- if (!absPath.startsWith(this.workspaceDir)) {
- throw new Error("path escapes workspace");
+ const absPath = path.isAbsolute(rawPath)
+ ? path.resolve(rawPath)
+ : path.resolve(this.workspaceDir, rawPath);
+ const relPath = path.relative(this.workspaceDir, absPath).replace(/\\/g, "/");
+ const inWorkspace =
+ relPath.length > 0 && !relPath.startsWith("..") && !path.isAbsolute(relPath);
+ const allowedWorkspace = inWorkspace && isMemoryPath(relPath);
+ let allowedAdditional = false;
+ if (!allowedWorkspace && this.settings.extraPaths.length > 0) {
+ const additionalPaths = normalizeExtraMemoryPaths(
+ this.workspaceDir,
+ this.settings.extraPaths,
+ );
+ for (const additionalPath of additionalPaths) {
+ try {
+ const stat = await fs.lstat(additionalPath);
+ if (stat.isSymbolicLink()) continue;
+ if (stat.isDirectory()) {
+ if (absPath === additionalPath || absPath.startsWith(`${additionalPath}${path.sep}`)) {
+ allowedAdditional = true;
+ break;
+ }
+ continue;
+ }
+ if (stat.isFile()) {
+ if (absPath === additionalPath && absPath.endsWith(".md")) {
+ allowedAdditional = true;
+ break;
+ }
+ }
+ } catch {}
+ }
+ }
+ if (!allowedWorkspace && !allowedAdditional) {
+ throw new Error("path required");
+ }
+ if (!absPath.endsWith(".md")) {
+ throw new Error("path required");
+ }
+ const stat = await fs.lstat(absPath);
+ if (stat.isSymbolicLink() || !stat.isFile()) {
+ throw new Error("path required");
}
const content = await fs.readFile(absPath, "utf-8");
if (!params.from && !params.lines) {
@@ -425,6 +465,7 @@ export class MemoryIndexManager {
model: string;
requestedProvider: string;
sources: MemorySource[];
+ extraPaths: string[];
sourceCounts: Array<{ source: MemorySource; files: number; chunks: number }>;
cache?: { enabled: boolean; entries?: number; maxEntries?: number };
fts?: { enabled: boolean; available: boolean; error?: string };
@@ -498,6 +539,7 @@ export class MemoryIndexManager {
model: this.provider.model,
requestedProvider: this.requestedProvider,
sources: Array.from(this.sources),
+ extraPaths: this.settings.extraPaths,
sourceCounts,
cache: this.cache.enabled
? {
@@ -769,11 +811,23 @@ export class MemoryIndexManager {
private ensureWatcher() {
if (!this.sources.has("memory") || !this.settings.sync.watch || this.watcher) return;
- const watchPaths = [
+ const additionalPaths = normalizeExtraMemoryPaths(this.workspaceDir, this.settings.extraPaths)
+ .map((entry) => {
+ try {
+ const stat = fsSync.lstatSync(entry);
+ return stat.isSymbolicLink() ? null : entry;
+ } catch {
+ return null;
+ }
+ })
+ .filter((entry): entry is string => Boolean(entry));
+ const watchPaths = new Set([
path.join(this.workspaceDir, "MEMORY.md"),
+ path.join(this.workspaceDir, "memory.md"),
path.join(this.workspaceDir, "memory"),
- ];
- this.watcher = chokidar.watch(watchPaths, {
+ ...additionalPaths,
+ ]);
+ this.watcher = chokidar.watch(Array.from(watchPaths), {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: this.settings.sync.watchDebounceMs,
@@ -975,7 +1029,7 @@ export class MemoryIndexManager {
needsFullReindex: boolean;
progress?: MemorySyncProgressState;
}) {
- const files = await listMemoryFiles(this.workspaceDir);
+ const files = await listMemoryFiles(this.workspaceDir, this.settings.extraPaths);
const fileEntries = await Promise.all(
files.map(async (file) => buildFileEntry(file, this.workspaceDir)),
);
diff --git a/src/memory/sync-memory-files.ts b/src/memory/sync-memory-files.ts
index 53fed7ebe..c5073dc50 100644
--- a/src/memory/sync-memory-files.ts
+++ b/src/memory/sync-memory-files.ts
@@ -14,6 +14,7 @@ type ProgressState = {
export async function syncMemoryFiles(params: {
workspaceDir: string;
+ extraPaths?: string[];
db: DatabaseSync;
needsFullReindex: boolean;
progress?: ProgressState;
@@ -27,7 +28,7 @@ export async function syncMemoryFiles(params: {
ftsAvailable: boolean;
model: string;
}) {
- const files = await listMemoryFiles(params.workspaceDir);
+ const files = await listMemoryFiles(params.workspaceDir, params.extraPaths);
const fileEntries = await Promise.all(
files.map(async (file) => buildFileEntry(file, params.workspaceDir)),
);
diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts
index 6a3366e97..aed0fa755 100644
--- a/src/routing/resolve-route.test.ts
+++ b/src/routing/resolve-route.test.ts
@@ -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");
+});
diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts
index 473dc61f2..0c63f77c8 100644
--- a/src/routing/resolve-route.ts
+++ b/src/routing/resolve-route.ts
@@ -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 {
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,
diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts
index 7f9f209ed..320ffeb83 100644
--- a/src/routing/session-key.ts
+++ b/src/routing/session-key.ts
@@ -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;
/** 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}`;
diff --git a/src/security/audit.ts b/src/security/audit.ts
index 7aebd6928..681d14c1d 100644
--- a/src/security/audit.ts
+++ b/src/security/audit.ts
@@ -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.',
});
}
};
diff --git a/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts
index ce7015399..4481d7589 100644
--- a/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts
+++ b/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts
@@ -392,7 +392,7 @@ describe("monitorSlackProvider tool results", () => {
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
});
- it("skips channel messages when another user is explicitly mentioned", async () => {
+ it("accepts channel messages when mentionPatterns match even if another user is mentioned", async () => {
slackTestState.config = {
messages: {
responsePrefix: "PFX",
@@ -433,8 +433,8 @@ describe("monitorSlackProvider tool results", () => {
controller.abort();
await run;
- expect(replyMock).not.toHaveBeenCalled();
- expect(sendMock).not.toHaveBeenCalled();
+ expect(replyMock).toHaveBeenCalledTimes(1);
+ expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
});
it("treats replies to bot threads as implicit mentions", async () => {
diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts
index 832a4413d..abd06cdef 100644
--- a/src/telegram/bot-message-context.ts
+++ b/src/telegram/bot-message-context.ts
@@ -335,6 +335,7 @@ export const buildTelegramMessageContext = async ({
let placeholder = "";
if (msg.photo) placeholder = "";
else if (msg.video) placeholder = "";
+ else if (msg.video_note) placeholder = "";
else if (msg.audio || msg.voice) placeholder = "";
else if (msg.document) placeholder = "";
else if (msg.sticker) placeholder = "";
diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts
index cead0628a..ea006e316 100644
--- a/src/telegram/bot-message-dispatch.ts
+++ b/src/telegram/bot-message-dispatch.ts
@@ -21,6 +21,8 @@ import { createTelegramDraftStream } from "./draft-stream.js";
import { cacheSticker, describeStickerImage } from "./sticker-cache.js";
import { resolveAgentDir } from "../agents/agent-scope.js";
+const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
+
async function resolveStickerVisionSupport(cfg, agentId) {
try {
const catalog = await loadModelCatalog({ config: cfg });
@@ -198,6 +200,15 @@ export const dispatchTelegramMessage = async ({
}
}
+ const replyQuoteText =
+ ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody
+ ? ctxPayload.ReplyToBody.trim() || undefined
+ : undefined;
+ const deliveryState = {
+ delivered: false,
+ skippedNonSilent: 0,
+ };
+
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
@@ -209,12 +220,7 @@ export const dispatchTelegramMessage = async ({
await flushDraft();
draftStream?.stop();
}
-
- const replyQuoteText =
- ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody
- ? ctxPayload.ReplyToBody.trim() || undefined
- : undefined;
- await deliverReplies({
+ const result = await deliverReplies({
replies: [payload],
chatId: String(chatId),
token: opts.token,
@@ -229,6 +235,12 @@ export const dispatchTelegramMessage = async ({
linkPreview: telegramCfg.linkPreview,
replyQuoteText,
});
+ if (result.delivered) {
+ deliveryState.delivered = true;
+ }
+ },
+ onSkip: (_payload, info) => {
+ if (info.reason !== "silent") deliveryState.skippedNonSilent += 1;
},
onError: (err, info) => {
runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`));
@@ -260,7 +272,27 @@ export const dispatchTelegramMessage = async ({
},
});
draftStream?.stop();
- if (!queuedFinal) {
+ let sentFallback = false;
+ if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) {
+ const result = await deliverReplies({
+ replies: [{ text: EMPTY_RESPONSE_FALLBACK }],
+ chatId: String(chatId),
+ token: opts.token,
+ runtime,
+ bot,
+ replyToMode,
+ textLimit,
+ messageThreadId: resolvedThreadId,
+ tableMode,
+ chunkMode,
+ linkPreview: telegramCfg.linkPreview,
+ replyQuoteText,
+ });
+ sentFallback = result.delivered;
+ }
+
+ const hasFinalResponse = queuedFinal || sentFallback;
+ if (!hasFinalResponse) {
if (isGroup && historyKey) {
clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit });
}
diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts
index 3415ea927..59f109a1f 100644
--- a/src/telegram/bot-native-commands.ts
+++ b/src/telegram/bot-native-commands.ts
@@ -50,6 +50,8 @@ import {
import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
import { readTelegramAllowFromStore } from "./pairing-store.js";
+const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
+
type TelegramNativeCommandContext = Context & { match?: string };
type TelegramCommandAuthResult = {
@@ -468,6 +470,7 @@ export const registerTelegramNativeCommands = ({
CommandAuthorized: commandAuthorized,
CommandSource: "native" as const,
SessionKey: `telegram:slash:${senderId || chatId}`,
+ AccountId: route.accountId,
CommandTargetSessionKey: sessionKey,
MessageThreadId: threadIdForSend,
IsForum: isForum,
@@ -482,13 +485,18 @@ export const registerTelegramNativeCommands = ({
: undefined;
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
+ const deliveryState = {
+ delivered: false,
+ skippedNonSilent: 0,
+ };
+
await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
- deliver: async (payload) => {
- await deliverReplies({
+ deliver: async (payload, _info) => {
+ const result = await deliverReplies({
replies: [payload],
chatId: String(chatId),
token: opts.token,
@@ -501,6 +509,12 @@ export const registerTelegramNativeCommands = ({
chunkMode,
linkPreview: telegramCfg.linkPreview,
});
+ if (result.delivered) {
+ deliveryState.delivered = true;
+ }
+ },
+ onSkip: (_payload, info) => {
+ if (info.reason !== "silent") deliveryState.skippedNonSilent += 1;
},
onError: (err, info) => {
runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`));
@@ -511,6 +525,21 @@ export const registerTelegramNativeCommands = ({
disableBlockStreaming,
},
});
+ if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) {
+ await deliverReplies({
+ replies: [{ text: EMPTY_RESPONSE_FALLBACK }],
+ chatId: String(chatId),
+ token: opts.token,
+ runtime,
+ bot,
+ replyToMode,
+ textLimit,
+ messageThreadId: threadIdForSend,
+ tableMode,
+ chunkMode,
+ linkPreview: telegramCfg.linkPreview,
+ });
+ }
});
}
diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts
index 8bfe1fdd3..03aaeebd7 100644
--- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts
+++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts
@@ -212,7 +212,7 @@ describe("createTelegramBot", () => {
);
});
- it("skips group messages when another user is explicitly mentioned", async () => {
+ it("accepts group messages when mentionPatterns match even if another user is mentioned", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType;
replySpy.mockReset();
@@ -249,7 +249,8 @@ describe("createTelegramBot", () => {
getFile: async () => ({ download: async () => new Uint8Array() }),
});
- expect(replySpy).not.toHaveBeenCalled();
+ expect(replySpy).toHaveBeenCalledTimes(1);
+ expect(replySpy.mock.calls[0][0].WasMentioned).toBe(true);
});
it("keeps group envelope headers stable (sender identity is separate)", async () => {
diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts
index 4f45f9997..669340b20 100644
--- a/src/telegram/bot/delivery.ts
+++ b/src/telegram/bot/delivery.ts
@@ -44,7 +44,7 @@ export async function deliverReplies(params: {
linkPreview?: boolean;
/** Optional quote text for Telegram reply_parameters. */
replyQuoteText?: string;
-}) {
+}): Promise<{ delivered: boolean }> {
const {
replies,
chatId,
@@ -58,6 +58,10 @@ export async function deliverReplies(params: {
} = params;
const chunkMode = params.chunkMode ?? "length";
let hasReplied = false;
+ let hasDelivered = false;
+ const markDelivered = () => {
+ hasDelivered = true;
+ };
const chunkText = (markdown: string) => {
const markdownChunks =
chunkMode === "newline"
@@ -114,6 +118,7 @@ export async function deliverReplies(params: {
linkPreview,
replyMarkup: shouldAttachButtons ? replyMarkup : undefined,
});
+ markDelivered();
if (replyToId && !hasReplied) {
hasReplied = true;
}
@@ -165,18 +170,21 @@ export async function deliverReplies(params: {
runtime,
fn: () => bot.api.sendAnimation(chatId, file, { ...mediaParams }),
});
+ markDelivered();
} else if (kind === "image") {
await withTelegramApiErrorLogging({
operation: "sendPhoto",
runtime,
fn: () => bot.api.sendPhoto(chatId, file, { ...mediaParams }),
});
+ markDelivered();
} else if (kind === "video") {
await withTelegramApiErrorLogging({
operation: "sendVideo",
runtime,
fn: () => bot.api.sendVideo(chatId, file, { ...mediaParams }),
});
+ markDelivered();
} else if (kind === "audio") {
const { useVoice } = resolveTelegramVoiceSend({
wantsVoice: reply.audioAsVoice === true, // default false (backward compatible)
@@ -195,6 +203,7 @@ export async function deliverReplies(params: {
shouldLog: (err) => !isVoiceMessagesForbidden(err),
fn: () => bot.api.sendVoice(chatId, file, { ...mediaParams }),
});
+ markDelivered();
} catch (voiceErr) {
// Fall back to text if voice messages are forbidden in this chat.
// This happens when the recipient has Telegram Premium privacy settings
@@ -221,6 +230,7 @@ export async function deliverReplies(params: {
replyMarkup,
replyQuoteText,
});
+ markDelivered();
// Skip this media item; continue with next.
continue;
}
@@ -233,6 +243,7 @@ export async function deliverReplies(params: {
runtime,
fn: () => bot.api.sendAudio(chatId, file, { ...mediaParams }),
});
+ markDelivered();
}
} else {
await withTelegramApiErrorLogging({
@@ -240,6 +251,7 @@ export async function deliverReplies(params: {
runtime,
fn: () => bot.api.sendDocument(chatId, file, { ...mediaParams }),
});
+ markDelivered();
}
if (replyToId && !hasReplied) {
hasReplied = true;
@@ -260,6 +272,7 @@ export async function deliverReplies(params: {
linkPreview,
replyMarkup: i === 0 ? replyMarkup : undefined,
});
+ markDelivered();
if (replyToId && !hasReplied) {
hasReplied = true;
}
@@ -268,6 +281,8 @@ export async function deliverReplies(params: {
}
}
}
+
+ return { delivered: hasDelivered };
}
export async function resolveMedia(
@@ -310,7 +325,14 @@ export async function resolveMedia(
fetchImpl,
filePathHint: file.file_path,
});
- const saved = await saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes);
+ const originalName = fetched.fileName ?? file.file_path;
+ const saved = await saveMediaBuffer(
+ fetched.buffer,
+ fetched.contentType,
+ "inbound",
+ maxBytes,
+ originalName,
+ );
// Check sticker cache for existing description
const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null;
@@ -361,7 +383,12 @@ export async function resolveMedia(
}
const m =
- msg.photo?.[msg.photo.length - 1] ?? msg.video ?? msg.document ?? msg.audio ?? msg.voice;
+ msg.photo?.[msg.photo.length - 1] ??
+ msg.video ??
+ msg.video_note ??
+ msg.document ??
+ msg.audio ??
+ msg.voice;
if (!m?.file_id) return null;
const file = await ctx.getFile();
if (!file.file_path) {
@@ -377,10 +404,18 @@ export async function resolveMedia(
fetchImpl,
filePathHint: file.file_path,
});
- const saved = await saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes);
+ const originalName = fetched.fileName ?? file.file_path;
+ const saved = await saveMediaBuffer(
+ fetched.buffer,
+ fetched.contentType,
+ "inbound",
+ maxBytes,
+ originalName,
+ );
let placeholder = "";
if (msg.photo) placeholder = "";
else if (msg.video) placeholder = "";
+ else if (msg.video_note) placeholder = "";
else if (msg.audio || msg.voice) placeholder = "";
return { path: saved.path, contentType: saved.contentType, placeholder };
}
diff --git a/src/telegram/download.ts b/src/telegram/download.ts
index 1b3c61e22..31f431db0 100644
--- a/src/telegram/download.ts
+++ b/src/telegram/download.ts
@@ -40,7 +40,7 @@ export async function downloadTelegramFile(
filePath: info.file_path,
});
// save with inbound subdir
- const saved = await saveMediaBuffer(array, mime, "inbound", maxBytes);
+ const saved = await saveMediaBuffer(array, mime, "inbound", maxBytes, info.file_path);
// Ensure extension matches mime if possible
if (!saved.contentType && mime) saved.contentType = mime;
return saved;
diff --git a/src/tts/tts.ts b/src/tts/tts.ts
index af3d7fda5..faa83d3a6 100644
--- a/src/tts/tts.ts
+++ b/src/tts/tts.ts
@@ -757,11 +757,19 @@ export const OPENAI_TTS_MODELS = ["gpt-4o-mini-tts", "tts-1", "tts-1-hd"] as con
* Custom OpenAI-compatible TTS endpoint.
* When set, model/voice validation is relaxed to allow non-OpenAI models.
* Example: OPENAI_TTS_BASE_URL=http://localhost:8880/v1
+ *
+ * Note: Read at runtime (not module load) to support config.env loading.
*/
-const OPENAI_TTS_BASE_URL = (
- process.env.OPENAI_TTS_BASE_URL?.trim() || "https://api.openai.com/v1"
-).replace(/\/+$/, "");
-const isCustomOpenAIEndpoint = OPENAI_TTS_BASE_URL !== "https://api.openai.com/v1";
+function getOpenAITtsBaseUrl(): string {
+ return (process.env.OPENAI_TTS_BASE_URL?.trim() || "https://api.openai.com/v1").replace(
+ /\/+$/,
+ "",
+ );
+}
+
+function isCustomOpenAIEndpoint(): boolean {
+ return getOpenAITtsBaseUrl() !== "https://api.openai.com/v1";
+}
export const OPENAI_TTS_VOICES = [
"alloy",
"ash",
@@ -778,13 +786,13 @@ type OpenAiTtsVoice = (typeof OPENAI_TTS_VOICES)[number];
function isValidOpenAIModel(model: string): boolean {
// Allow any model when using custom endpoint (e.g., Kokoro, LocalAI)
- if (isCustomOpenAIEndpoint) return true;
+ if (isCustomOpenAIEndpoint()) return true;
return OPENAI_TTS_MODELS.includes(model as (typeof OPENAI_TTS_MODELS)[number]);
}
function isValidOpenAIVoice(voice: string): voice is OpenAiTtsVoice {
// Allow any voice when using custom endpoint (e.g., Kokoro Chinese voices)
- if (isCustomOpenAIEndpoint) return true;
+ if (isCustomOpenAIEndpoint()) return true;
return OPENAI_TTS_VOICES.includes(voice as OpenAiTtsVoice);
}
@@ -1011,7 +1019,7 @@ async function openaiTTS(params: {
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
- const response = await fetch(`${OPENAI_TTS_BASE_URL}/audio/speech`, {
+ const response = await fetch(`${getOpenAITtsBaseUrl()}/audio/speech`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
diff --git a/src/web/auto-reply/monitor/broadcast.ts b/src/web/auto-reply/monitor/broadcast.ts
index ef76ce3b0..c8f84a048 100644
--- a/src/web/auto-reply/monitor/broadcast.ts
+++ b/src/web/auto-reply/monitor/broadcast.ts
@@ -54,11 +54,13 @@ export async function maybeBroadcastMessage(params: {
sessionKey: buildAgentSessionKey({
agentId: normalizedAgentId,
channel: "whatsapp",
+ accountId: params.route.accountId,
peer: {
kind: params.msg.chatType === "group" ? "group" : "dm",
id: params.peerId,
},
dmScope: params.cfg.session?.dmScope,
+ identityLinks: params.cfg.session?.identityLinks,
}),
mainSessionKey: buildAgentMainSessionKey({
agentId: normalizedAgentId,
diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts
index 77149f9ad..0b35fb445 100644
--- a/ui/src/ui/app-chat.ts
+++ b/ui/src/ui/app-chat.ts
@@ -21,6 +21,7 @@ type ChatHost = {
basePath: string;
hello: GatewayHelloOk | null;
chatAvatarUrl: string | null;
+ refreshSessionsAfterChat: boolean;
};
export function isChatBusy(host: ChatHost) {
@@ -41,6 +42,14 @@ export function isChatStopCommand(text: string) {
);
}
+function isChatResetCommand(text: string) {
+ const trimmed = text.trim();
+ if (!trimmed) return false;
+ const normalized = trimmed.toLowerCase();
+ if (normalized === "/new" || normalized === "/reset") return true;
+ return normalized.startsWith("/new ") || normalized.startsWith("/reset ");
+}
+
export async function handleAbortChat(host: ChatHost) {
if (!host.connected) return;
host.chatMessage = "";
@@ -71,6 +80,7 @@ async function sendChatMessageNow(
attachments?: ChatAttachment[];
previousAttachments?: ChatAttachment[];
restoreAttachments?: boolean;
+ refreshSessions?: boolean;
},
) {
resetToolStream(host as unknown as Parameters[0]);
@@ -94,6 +104,9 @@ async function sendChatMessageNow(
if (ok && !host.chatRunId) {
void flushChatQueue(host);
}
+ if (ok && opts?.refreshSessions) {
+ host.refreshSessionsAfterChat = true;
+ }
return ok;
}
@@ -132,6 +145,7 @@ export async function handleSendChat(
return;
}
+ const refreshSessions = isChatResetCommand(message);
if (messageOverride == null) {
host.chatMessage = "";
// Clear attachments when sending
@@ -149,13 +163,14 @@ export async function handleSendChat(
attachments: hasAttachments ? attachmentsToSend : undefined,
previousAttachments: messageOverride == null ? attachments : undefined,
restoreAttachments: Boolean(messageOverride && opts?.restoreDraft),
+ refreshSessions,
});
}
export async function refreshChat(host: ChatHost) {
await Promise.all([
loadChatHistory(host as unknown as MoltbotApp),
- loadSessions(host as unknown as MoltbotApp),
+ loadSessions(host as unknown as MoltbotApp, { activeMinutes: 0 }),
refreshChatAvatar(host),
]);
scheduleChatScroll(host as unknown as Parameters[0], true);
diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts
index b2355709c..ba1df61e1 100644
--- a/ui/src/ui/app-gateway.ts
+++ b/ui/src/ui/app-gateway.ts
@@ -26,6 +26,7 @@ import {
import type { MoltbotApp } from "./app";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
import { loadAssistantIdentity } from "./controllers/assistant-identity";
+import { loadSessions } from "./controllers/sessions";
type GatewayHost = {
settings: UiSettings;
@@ -50,6 +51,7 @@ type GatewayHost = {
assistantAgentId: string | null;
sessionKey: string;
chatRunId: string | null;
+ refreshSessionsAfterChat: boolean;
execApprovalQueue: ExecApprovalRequest[];
execApprovalError: string | null;
};
@@ -194,6 +196,12 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
void flushChatQueueForEvent(
host as unknown as Parameters[0],
);
+ if (host.refreshSessionsAfterChat) {
+ host.refreshSessionsAfterChat = false;
+ if (state === "final") {
+ void loadSessions(host as unknown as MoltbotApp, { activeMinutes: 0 });
+ }
+ }
}
if (state === "final") void loadChatHistory(host as unknown as MoltbotApp);
return;
diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts
index 71af9d202..cf5214250 100644
--- a/ui/src/ui/app-lifecycle.ts
+++ b/ui/src/ui/app-lifecycle.ts
@@ -35,6 +35,9 @@ type LifecycleHost = {
export function handleConnected(host: LifecycleHost) {
host.basePath = inferBasePath();
+ applySettingsFromUrl(
+ host as unknown as Parameters[0],
+ );
syncTabWithLocation(
host as unknown as Parameters[0],
true,
@@ -46,9 +49,6 @@ export function handleConnected(host: LifecycleHost) {
host as unknown as Parameters[0],
);
window.addEventListener("popstate", host.popStateHandler);
- applySettingsFromUrl(
- host as unknown as Parameters[0],
- );
connectGateway(host as unknown as Parameters[0]);
startNodesPolling(host as unknown as Parameters[0]);
if (host.tab === "logs") {
diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts
index 22f8d90db..c2190e1c9 100644
--- a/ui/src/ui/app-render.helpers.ts
+++ b/ui/src/ui/app-render.helpers.ts
@@ -5,6 +5,7 @@ import type { AppViewState } from "./app-view-state";
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation";
import { icons } from "./icons";
import { loadChatHistory } from "./controllers/chat";
+import { refreshChat } from "./app-chat";
import { syncUrlWithSessionKey } from "./app-settings";
import type { SessionsListResult } from "./types";
import type { ThemeMode } from "./theme";
@@ -39,7 +40,12 @@ export function renderTab(state: AppViewState, tab: Tab) {
}
export function renderChatControls(state: AppViewState) {
- const sessionOptions = resolveSessionOptions(state.sessionKey, state.sessionsResult);
+ const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult);
+ const sessionOptions = resolveSessionOptions(
+ state.sessionKey,
+ state.sessionsResult,
+ mainSessionKey,
+ );
const disableThinkingToggle = state.onboarding;
const disableFocusToggle = state.onboarding;
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
@@ -87,9 +93,9 @@ export function renderChatControls(state: AppViewState) {
?disabled=${state.chatLoading || !state.connected}
@click=${() => {
state.resetToolStream();
- void loadChatHistory(state);
+ void refreshChat(state as unknown as Parameters[0]);
}}
- title="Refresh chat history"
+ title="Refresh chat data"
>
${refreshIcon}
@@ -132,15 +138,47 @@ export function renderChatControls(state: AppViewState) {
`;
}
-function resolveSessionOptions(sessionKey: string, sessions: SessionsListResult | null) {
+type SessionDefaultsSnapshot = {
+ mainSessionKey?: string;
+ mainKey?: string;
+};
+
+function resolveMainSessionKey(
+ hello: AppViewState["hello"],
+ sessions: SessionsListResult | null,
+): string | null {
+ const snapshot = hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined;
+ const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim();
+ if (mainSessionKey) return mainSessionKey;
+ const mainKey = snapshot?.sessionDefaults?.mainKey?.trim();
+ if (mainKey) return mainKey;
+ if (sessions?.sessions?.some((row) => row.key === "main")) return "main";
+ return null;
+}
+
+function resolveSessionOptions(
+ sessionKey: string,
+ sessions: SessionsListResult | null,
+ mainSessionKey?: string | null,
+) {
const seen = new Set();
const options: Array<{ key: string; displayName?: string }> = [];
+ const resolvedMain =
+ mainSessionKey && sessions?.sessions?.find((s) => s.key === mainSessionKey);
const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey);
- // Add current session key first
- seen.add(sessionKey);
- options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName });
+ // Add main session key first
+ if (mainSessionKey) {
+ seen.add(mainSessionKey);
+ options.push({ key: mainSessionKey, displayName: resolvedMain?.displayName });
+ }
+
+ // Add current session key next
+ if (!seen.has(sessionKey)) {
+ seen.add(sessionKey);
+ options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName });
+ }
// Add sessions from the result
if (sessions?.sessions) {
diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts
index a088c33ff..422af6863 100644
--- a/ui/src/ui/app-render.ts
+++ b/ui/src/ui/app-render.ts
@@ -42,6 +42,7 @@ import { renderNodes } from "./views/nodes";
import { renderOverview } from "./views/overview";
import { renderSessions } from "./views/sessions";
import { renderExecApprovalPrompt } from "./views/exec-approval";
+import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation";
import {
approveDevicePairing,
loadDevices,
@@ -578,6 +579,7 @@ export function renderApp(state: AppViewState) {
: nothing}
${renderExecApprovalPrompt(state)}
+ ${renderGatewayUrlConfirmation(state)}
`;
}
diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts
index e269742b2..7e3ab29cf 100644
--- a/ui/src/ui/app-settings.ts
+++ b/ui/src/ui/app-settings.ts
@@ -33,6 +33,7 @@ type SettingsHost = {
basePath: string;
themeMedia: MediaQueryList | null;
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
+ pendingGatewayUrl?: string | null;
};
export function applySettings(host: SettingsHost, next: UiSettings) {
@@ -98,7 +99,7 @@ export function applySettingsFromUrl(host: SettingsHost) {
if (gatewayUrlRaw != null) {
const gatewayUrl = gatewayUrlRaw.trim();
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) {
- applySettings(host, { ...host.settings, gatewayUrl });
+ host.pendingGatewayUrl = gatewayUrl;
}
params.delete("gatewayUrl");
shouldCleanUrl = true;
diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts
index 069465e32..f58656bfb 100644
--- a/ui/src/ui/app-view-state.ts
+++ b/ui/src/ui/app-view-state.ts
@@ -73,6 +73,7 @@ export type AppViewState = {
execApprovalQueue: ExecApprovalRequest[];
execApprovalBusy: boolean;
execApprovalError: string | null;
+ pendingGatewayUrl: string | null;
configLoading: boolean;
configRaw: string;
configRawOriginal: string;
@@ -165,6 +166,8 @@ export type AppViewState = {
handleNostrProfileImport: () => Promise;
handleNostrProfileToggleAdvanced: () => void;
handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise;
+ handleGatewayUrlConfirm: () => void;
+ handleGatewayUrlCancel: () => void;
handleConfigLoad: () => Promise;
handleConfigSave: () => Promise;
handleConfigApply: () => Promise;
diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts
index d23e543cd..50ffcdf76 100644
--- a/ui/src/ui/app.ts
+++ b/ui/src/ui/app.ts
@@ -152,6 +152,7 @@ export class MoltbotApp extends LitElement {
@state() execApprovalQueue: ExecApprovalRequest[] = [];
@state() execApprovalBusy = false;
@state() execApprovalError: string | null = null;
+ @state() pendingGatewayUrl: string | null = null;
@state() configLoading = false;
@state() configRaw = "{\n}\n";
@@ -257,6 +258,7 @@ export class MoltbotApp extends LitElement {
private logsScrollFrame: number | null = null;
private toolStreamById = new Map();
private toolStreamOrder: string[] = [];
+ refreshSessionsAfterChat = false;
basePath = "";
private popStateHandler = () =>
onPopStateInternal(
@@ -448,6 +450,21 @@ export class MoltbotApp extends LitElement {
}
}
+ handleGatewayUrlConfirm() {
+ const nextGatewayUrl = this.pendingGatewayUrl;
+ if (!nextGatewayUrl) return;
+ this.pendingGatewayUrl = null;
+ applySettingsInternal(
+ this as unknown as Parameters[0],
+ { ...this.settings, gatewayUrl: nextGatewayUrl },
+ );
+ this.connect();
+ }
+
+ handleGatewayUrlCancel() {
+ this.pendingGatewayUrl = null;
+ }
+
// Sidebar handlers for tool output viewing
handleOpenSidebar(content: string) {
if (this.sidebarCloseTimer != null) {
diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts
index 5c5077037..7e87f1911 100644
--- a/ui/src/ui/controllers/sessions.ts
+++ b/ui/src/ui/controllers/sessions.ts
@@ -14,18 +14,29 @@ export type SessionsState = {
sessionsIncludeUnknown: boolean;
};
-export async function loadSessions(state: SessionsState) {
+export async function loadSessions(
+ state: SessionsState,
+ overrides?: {
+ activeMinutes?: number;
+ limit?: number;
+ includeGlobal?: boolean;
+ includeUnknown?: boolean;
+ },
+) {
if (!state.client || !state.connected) return;
if (state.sessionsLoading) return;
state.sessionsLoading = true;
state.sessionsError = null;
try {
+ const includeGlobal = overrides?.includeGlobal ?? state.sessionsIncludeGlobal;
+ const includeUnknown = overrides?.includeUnknown ?? state.sessionsIncludeUnknown;
+ const activeMinutes =
+ overrides?.activeMinutes ?? toNumber(state.sessionsFilterActive, 0);
+ const limit = overrides?.limit ?? toNumber(state.sessionsFilterLimit, 0);
const params: Record = {
- includeGlobal: state.sessionsIncludeGlobal,
- includeUnknown: state.sessionsIncludeUnknown,
+ includeGlobal,
+ includeUnknown,
};
- const activeMinutes = toNumber(state.sessionsFilterActive, 0);
- const limit = toNumber(state.sessionsFilterLimit, 0);
if (activeMinutes > 0) params.activeMinutes = activeMinutes;
if (limit > 0) params.limit = limit;
const res = (await state.client.request("sessions.list", params)) as
diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts
index 9d121d7f1..17a182281 100644
--- a/ui/src/ui/views/config-form.node.ts
+++ b/ui/src/ui/views/config-form.node.ts
@@ -260,6 +260,11 @@ function renderTextInput(params: {
}
onPatch(path, raw);
}}
+ @change=${(e: Event) => {
+ if (inputType === "number") return;
+ const raw = (e.target as HTMLInputElement).value;
+ onPatch(path, raw.trim());
+ }}
/>
${schema.default !== undefined ? html`