diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ae5fdf2..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. @@ -72,11 +73,15 @@ Status: beta. - **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. @@ -107,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:

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

diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 6fd30c751..b6ae260ce 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -125,7 +125,7 @@ the prefix (use `""` to remove it). - **DM policy**: `channels.whatsapp.dmPolicy` controls direct chat access (default: `pairing`). - Pairing: unknown senders get a pairing code (approve via `moltbot pairing approve whatsapp `; codes expire after 1 hour). - Open: requires `channels.whatsapp.allowFrom` to include `"*"`. - - Self messages are always allowed; “self-chat mode” still requires `channels.whatsapp.allowFrom` to include your own number. + - Your linked WhatsApp number is implicitly trusted, so self messages skip ⁠`channels.whatsapp.dmPolicy` and `channels.whatsapp.allowFrom` checks. ### Personal-number mode (fallback) If you run Moltbot on your **personal WhatsApp number**, enable `channels.whatsapp.selfChatMode` (see sample above). diff --git a/docs/cli/memory.md b/docs/cli/memory.md index 3dc79932f..513b7ef07 100644 --- a/docs/cli/memory.md +++ b/docs/cli/memory.md @@ -39,3 +39,4 @@ Notes: - `memory status --deep` probes vector + embedding availability. - `memory status --deep --index` runs a reindex if the store is dirty. - `memory index --verbose` prints per-phase details (provider, model, sources, batch activity). +- `memory status` includes any extra paths configured via `memorySearch.extraPaths`. diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index 8a386aba9..f2bca461a 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -75,8 +75,9 @@ For the full compaction lifecycle, see ## Vector memory search -Moltbot can build a small vector index over `MEMORY.md` and `memory/*.md` so -semantic queries can find related notes even when wording differs. +Moltbot can build a small vector index over `MEMORY.md` and `memory/*.md` (plus +any extra directories or files you opt in) so semantic queries can find related +notes even when wording differs. Defaults: - Enabled by default. @@ -96,6 +97,27 @@ embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or `models.providers.google.apiKey`. When using a custom OpenAI-compatible endpoint, set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`). +### Additional memory paths + +If you want to index Markdown files outside the default workspace layout, add +explicit paths: + +```json5 +agents: { + defaults: { + memorySearch: { + extraPaths: ["../team-docs", "/srv/shared-notes/overview.md"] + } + } +} +``` + +Notes: +- Paths can be absolute or workspace-relative. +- Directories are scanned recursively for `.md` files. +- Only Markdown files are indexed. +- Symlinks are ignored (files or directories). + ### Gemini embeddings (native) Set the provider to `gemini` to use the Gemini embeddings API directly: @@ -189,14 +211,14 @@ Local mode: ### How the memory tools work - `memory_search` semantically searches Markdown chunks (~400 token target, 80-token overlap) from `MEMORY.md` + `memory/**/*.md`. It returns snippet text (capped ~700 chars), file path, line range, score, provider/model, and whether we fell back from local → remote embeddings. No full file payload is returned. -- `memory_get` reads a specific memory Markdown file (workspace-relative), optionally from a starting line and for N lines. Paths outside `MEMORY.md` / `memory/` are rejected. +- `memory_get` reads a specific memory Markdown file (workspace-relative), optionally from a starting line and for N lines. Paths outside `MEMORY.md` / `memory/` are allowed only when explicitly listed in `memorySearch.extraPaths`. - Both tools are enabled only when `memorySearch.enabled` resolves true for the agent. ### What gets indexed (and when) -- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`). +- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`, plus any `.md` files under `memorySearch.extraPaths`). - Index storage: per-agent SQLite at `~/.clawdbot/memory/.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token). -- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync is scheduled on session start, on search, or on an interval and runs asynchronously. Session transcripts use delta thresholds to trigger background sync. +- Freshness: watcher on `MEMORY.md`, `memory/`, and `memorySearch.extraPaths` marks the index dirty (debounce 1.5s). Sync is scheduled on session start, on search, or on an interval and runs asynchronously. Session transcripts use delta thresholds to trigger background sync. - Reindex triggers: the index stores the embedding **provider/model + endpoint fingerprint + chunking params**. If any of those change, Moltbot automatically resets and reindexes the entire store. ### Hybrid search (BM25 + vector) diff --git a/docs/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/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index 6990d3a76..6747aadc8 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -8,6 +9,24 @@ import { buildDockerExecArgs } from "./bash-tools.shared.js"; import { sanitizeBinaryOutput } from "./shell-utils.js"; const isWin = process.platform === "win32"; +const resolveShellFromPath = (name: string) => { + const envPath = process.env.PATH ?? ""; + if (!envPath) return undefined; + const entries = envPath.split(path.delimiter).filter(Boolean); + for (const entry of entries) { + const candidate = path.join(entry, name); + try { + fs.accessSync(candidate, fs.constants.X_OK); + return candidate; + } catch { + // ignore missing or non-executable entries + } + } + return undefined; +}; +const defaultShell = isWin + ? undefined + : process.env.CLAWDBOT_TEST_SHELL || resolveShellFromPath("bash") || process.env.SHELL || "sh"; // PowerShell: Start-Sleep for delays, ; for command separation, $null for null device const shortDelayCmd = isWin ? "Start-Sleep -Milliseconds 50" : "sleep 0.05"; const yieldDelayCmd = isWin ? "Start-Sleep -Milliseconds 200" : "sleep 0.2"; @@ -52,7 +71,7 @@ describe("exec tool backgrounding", () => { const originalShell = process.env.SHELL; beforeEach(() => { - if (!isWin) process.env.SHELL = "/bin/bash"; + if (!isWin && defaultShell) process.env.SHELL = defaultShell; }); afterEach(() => { @@ -282,7 +301,7 @@ describe("exec PATH handling", () => { const originalShell = process.env.SHELL; beforeEach(() => { - if (!isWin) process.env.SHELL = "/bin/bash"; + if (!isWin && defaultShell) process.env.SHELL = defaultShell; }); afterEach(() => { diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index e6b86ea3d..c3165815f 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -82,6 +82,29 @@ describe("memory search config", () => { expect(resolved?.store.vector.extensionPath).toBe("/opt/sqlite-vec.dylib"); }); + it("merges extra memory paths from defaults and overrides", () => { + const cfg = { + agents: { + defaults: { + memorySearch: { + extraPaths: ["/shared/notes", " docs "], + }, + }, + list: [ + { + id: "main", + default: true, + memorySearch: { + extraPaths: ["/shared/notes", "../team-notes"], + }, + }, + ], + }, + }; + const resolved = resolveMemorySearchConfig(cfg, "main"); + expect(resolved?.extraPaths).toEqual(["/shared/notes", "docs", "../team-notes"]); + }); + it("includes batch defaults for openai without remote overrides", () => { const cfg = { agents: { diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index c08161d4f..25aeb7cac 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -9,6 +9,7 @@ import { resolveAgentConfig } from "./agent-scope.js"; export type ResolvedMemorySearchConfig = { enabled: boolean; sources: Array<"memory" | "sessions">; + extraPaths: string[]; provider: "openai" | "local" | "gemini" | "auto"; remote?: { baseUrl?: string; @@ -162,6 +163,10 @@ function mergeConfig( modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir, }; const sources = normalizeSources(overrides?.sources ?? defaults?.sources, sessionMemory); + const rawPaths = [...(defaults?.extraPaths ?? []), ...(overrides?.extraPaths ?? [])] + .map((value) => value.trim()) + .filter(Boolean); + const extraPaths = Array.from(new Set(rawPaths)); const vector = { enabled: overrides?.store?.vector?.enabled ?? defaults?.store?.vector?.enabled ?? true, extensionPath: @@ -236,6 +241,7 @@ function mergeConfig( return { enabled, sources, + extraPaths, provider, remote, experimental: { diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts index 832d368a6..82a2428da 100644 --- a/src/agents/session-write-lock.ts +++ b/src/agents/session-write-lock.ts @@ -35,8 +35,8 @@ function isAlive(pid: number): boolean { function releaseAllLocksSync(): void { for (const [sessionFile, held] of HELD_LOCKS) { try { - if (typeof held.handle.fd === "number") { - fsSync.closeSync(held.handle.fd); + if (typeof held.handle.close === "function") { + void held.handle.close().catch(() => {}); } } catch { // Ignore errors during cleanup - best effort diff --git a/src/agents/tools/memory-tool.ts b/src/agents/tools/memory-tool.ts index b7b619af3..274af4c02 100644 --- a/src/agents/tools/memory-tool.ts +++ b/src/agents/tools/memory-tool.ts @@ -83,7 +83,7 @@ export function createMemoryGetTool(options: { label: "Memory Get", name: "memory_get", description: - "Safe snippet read from MEMORY.md or memory/*.md with optional from/lines; use after memory_search to pull only the needed lines and keep context small.", + "Safe snippet read from MEMORY.md, memory/*.md, or configured memorySearch.extraPaths with optional from/lines; use after memory_search to pull only the needed lines and keep context small.", parameters: MemoryGetSchema, execute: async (_toolCallId, params) => { const relPath = readStringParam(params, "path", { required: true }); diff --git a/src/agents/tools/web-fetch.ssrf.test.ts b/src/agents/tools/web-fetch.ssrf.test.ts index 24e4dfe41..b5c1936b1 100644 --- a/src/agents/tools/web-fetch.ssrf.test.ts +++ b/src/agents/tools/web-fetch.ssrf.test.ts @@ -1,10 +1,9 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import * as ssrf from "../../infra/net/ssrf.js"; const lookupMock = vi.fn(); - -vi.mock("node:dns/promises", () => ({ - lookup: lookupMock, -})); +const resolvePinnedHostname = ssrf.resolvePinnedHostname; function makeHeaders(map: Record): { get: (key: string) => string | null } { return { @@ -33,6 +32,12 @@ function textResponse(body: string): Response { describe("web_fetch SSRF protection", () => { const priorFetch = global.fetch; + beforeEach(() => { + vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) => + resolvePinnedHostname(hostname, lookupMock), + ); + }); + afterEach(() => { // @ts-expect-error restore global.fetch = priorFetch; diff --git a/src/agents/tools/web-tools.fetch.test.ts b/src/agents/tools/web-tools.fetch.test.ts index 04923b607..86bdeb7a2 100644 --- a/src/agents/tools/web-tools.fetch.test.ts +++ b/src/agents/tools/web-tools.fetch.test.ts @@ -1,5 +1,6 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as ssrf from "../../infra/net/ssrf.js"; import { createWebFetchTool } from "./web-tools.js"; type MockResponse = { @@ -73,6 +74,18 @@ function requestUrl(input: RequestInfo): string { describe("web_fetch extraction fallbacks", () => { const priorFetch = global.fetch; + beforeEach(() => { + vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(async (hostname) => { + const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); + const addresses = ["93.184.216.34", "93.184.216.35"]; + return { + hostname: normalized, + addresses, + lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }), + }; + }); + }); + afterEach(() => { // @ts-expect-error restore global.fetch = priorFetch; diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index c080ef55f..2604038ec 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -138,7 +138,7 @@ describe("dispatchReplyFromConfig", () => { ); }); - it("does not provide onToolResult when routing cross-provider", async () => { + it("provides onToolResult in DM sessions", async () => { mocks.tryFastAbortFromMessage.mockResolvedValue({ handled: false, aborted: false, @@ -147,9 +147,34 @@ describe("dispatchReplyFromConfig", () => { const cfg = {} as MoltbotConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ - Provider: "slack", - OriginatingChannel: "telegram", - OriginatingTo: "telegram:999", + Provider: "telegram", + ChatType: "direct", + }); + + const replyResolver = async ( + _ctx: MsgContext, + opts: GetReplyOptions | undefined, + _cfg: ClawdbotConfig, + ) => { + expect(opts?.onToolResult).toBeDefined(); + expect(typeof opts?.onToolResult).toBe("function"); + return { text: "hi" } satisfies ReplyPayload; + }; + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); + }); + + it("does not provide onToolResult in group sessions", async () => { + mocks.tryFastAbortFromMessage.mockResolvedValue({ + handled: false, + aborted: false, + }); + const cfg = {} as ClawdbotConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "telegram", + ChatType: "group", }); const replyResolver = async ( @@ -162,12 +187,62 @@ describe("dispatchReplyFromConfig", () => { }; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); + }); - expect(mocks.routeReply).toHaveBeenCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ text: "hi" }), - }), + it("sends tool results via dispatcher in DM sessions", async () => { + mocks.tryFastAbortFromMessage.mockResolvedValue({ + handled: false, + aborted: false, + }); + const cfg = {} as ClawdbotConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "telegram", + ChatType: "direct", + }); + + const replyResolver = async ( + _ctx: MsgContext, + opts: GetReplyOptions | undefined, + _cfg: ClawdbotConfig, + ) => { + // Simulate tool result emission + await opts?.onToolResult?.({ text: "🔧 exec: ls" }); + return { text: "done" } satisfies ReplyPayload; + }; + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + expect(dispatcher.sendToolResult).toHaveBeenCalledWith( + expect.objectContaining({ text: "🔧 exec: ls" }), ); + expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); + }); + + it("does not provide onToolResult for native slash commands", async () => { + mocks.tryFastAbortFromMessage.mockResolvedValue({ + handled: false, + aborted: false, + }); + const cfg = {} as ClawdbotConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "telegram", + ChatType: "direct", + CommandSource: "native", + }); + + const replyResolver = async ( + _ctx: MsgContext, + opts: GetReplyOptions | undefined, + _cfg: ClawdbotConfig, + ) => { + expect(opts?.onToolResult).toBeUndefined(); + return { text: "hi" } satisfies ReplyPayload; + }; + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); it("fast-aborts without calling the reply resolver", async () => { diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 58d5d71b5..c85e654de 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -276,6 +276,27 @@ export async function dispatchReplyFromConfig(params: { ctx, { ...params.replyOptions, + onToolResult: + ctx.ChatType !== "group" && ctx.CommandSource !== "native" + ? (payload: ReplyPayload) => { + const run = async () => { + const ttsPayload = await maybeApplyTtsToPayload({ + payload, + cfg, + channel: ttsChannel, + kind: "tool", + inboundAudio, + ttsAuto: sessionTtsAuto, + }); + if (shouldRouteToOriginating) { + await sendPayloadAsync(ttsPayload, undefined, false); + } else { + dispatcher.sendToolResult(ttsPayload); + } + }; + return run(); + } + : undefined, onBlockReply: (payload: ReplyPayload, context) => { const run = async () => { // Accumulate block text for TTS generation after streaming diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts index 7742b4f30..07cff23a9 100644 --- a/src/auto-reply/reply/mentions.test.ts +++ b/src/auto-reply/reply/mentions.test.ts @@ -4,7 +4,7 @@ import { matchesMentionWithExplicit } from "./mentions.js"; describe("matchesMentionWithExplicit", () => { const mentionRegexes = [/\bclawd\b/i]; - it("prefers explicit mentions when other mentions are present", () => { + it("checks mentionPatterns even when explicit mention is available", () => { const result = matchesMentionWithExplicit({ text: "@clawd hello", mentionRegexes, @@ -14,6 +14,19 @@ describe("matchesMentionWithExplicit", () => { canResolveExplicit: true, }, }); + expect(result).toBe(true); + }); + + it("returns false when explicit is false and no regex match", () => { + const result = matchesMentionWithExplicit({ + text: "<@999999> hello", + mentionRegexes, + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: false, + canResolveExplicit: true, + }, + }); expect(result).toBe(false); }); diff --git a/src/auto-reply/reply/mentions.ts b/src/auto-reply/reply/mentions.ts index 71964ac5f..9554a3c7b 100644 --- a/src/auto-reply/reply/mentions.ts +++ b/src/auto-reply/reply/mentions.ts @@ -90,7 +90,9 @@ export function matchesMentionWithExplicit(params: { const explicit = params.explicit?.isExplicitlyMentioned === true; const explicitAvailable = params.explicit?.canResolveExplicit === true; const hasAnyMention = params.explicit?.hasAnyMention === true; - if (hasAnyMention && explicitAvailable) return explicit; + if (hasAnyMention && explicitAvailable) { + return explicit || params.mentionRegexes.some((re) => re.test(cleaned)); + } if (!cleaned) return explicit; return explicit || params.mentionRegexes.some((re) => re.test(cleaned)); } diff --git a/src/auto-reply/reply/normalize-reply.test.ts b/src/auto-reply/reply/normalize-reply.test.ts index 30fb5e3f5..b9547c2b1 100644 --- a/src/auto-reply/reply/normalize-reply.test.ts +++ b/src/auto-reply/reply/normalize-reply.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { SILENT_REPLY_TOKEN } from "../tokens.js"; import { normalizeReplyPayload } from "./normalize-reply.js"; // Keep channelData-only payloads so channel-specific replies survive normalization. @@ -19,4 +20,30 @@ describe("normalizeReplyPayload", () => { expect(normalized?.text).toBeUndefined(); expect(normalized?.channelData).toEqual(payload.channelData); }); + + it("records silent skips", () => { + const reasons: string[] = []; + const normalized = normalizeReplyPayload( + { text: SILENT_REPLY_TOKEN }, + { + onSkip: (reason) => reasons.push(reason), + }, + ); + + expect(normalized).toBeNull(); + expect(reasons).toEqual(["silent"]); + }); + + it("records empty skips", () => { + const reasons: string[] = []; + const normalized = normalizeReplyPayload( + { text: " " }, + { + onSkip: (reason) => reasons.push(reason), + }, + ); + + expect(normalized).toBeNull(); + expect(reasons).toEqual(["empty"]); + }); }); diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index 7968088bd..9a58bebde 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -8,6 +8,8 @@ import { } from "./response-prefix-template.js"; import { hasLineDirectives, parseLineDirectives } from "./line-directives.js"; +export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat"; + export type NormalizeReplyOptions = { responsePrefix?: string; /** Context for template variable interpolation in responsePrefix */ @@ -15,6 +17,7 @@ export type NormalizeReplyOptions = { onHeartbeatStrip?: () => void; stripHeartbeat?: boolean; silentToken?: string; + onSkip?: (reason: NormalizeReplySkipReason) => void; }; export function normalizeReplyPayload( @@ -26,12 +29,18 @@ export function normalizeReplyPayload( payload.channelData && Object.keys(payload.channelData).length > 0, ); const trimmed = payload.text?.trim() ?? ""; - if (!trimmed && !hasMedia && !hasChannelData) return null; + if (!trimmed && !hasMedia && !hasChannelData) { + opts.onSkip?.("empty"); + return null; + } const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN; let text = payload.text ?? undefined; if (text && isSilentReplyText(text, silentToken)) { - if (!hasMedia && !hasChannelData) return null; + if (!hasMedia && !hasChannelData) { + opts.onSkip?.("silent"); + return null; + } text = ""; } if (text && !trimmed) { @@ -43,14 +52,20 @@ export function normalizeReplyPayload( if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) { const stripped = stripHeartbeatToken(text, { mode: "message" }); if (stripped.didStrip) opts.onHeartbeatStrip?.(); - if (stripped.shouldSkip && !hasMedia && !hasChannelData) return null; + if (stripped.shouldSkip && !hasMedia && !hasChannelData) { + opts.onSkip?.("heartbeat"); + return null; + } text = stripped.text; } if (text) { text = sanitizeUserFacingText(text); } - if (!text?.trim() && !hasMedia && !hasChannelData) return null; + if (!text?.trim() && !hasMedia && !hasChannelData) { + opts.onSkip?.("empty"); + return null; + } // Parse LINE-specific directives from text (quick_replies, location, confirm, buttons) let enrichedPayload: ReplyPayload = { ...payload, text }; diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index f41667802..fd7fb5493 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -1,6 +1,6 @@ import type { HumanDelayConfig } from "../../config/types.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; -import { normalizeReplyPayload } from "./normalize-reply.js"; +import { normalizeReplyPayload, type NormalizeReplySkipReason } from "./normalize-reply.js"; import type { ResponsePrefixContext } from "./response-prefix-template.js"; import type { TypingController } from "./typing.js"; @@ -8,6 +8,11 @@ export type ReplyDispatchKind = "tool" | "block" | "final"; type ReplyDispatchErrorHandler = (err: unknown, info: { kind: ReplyDispatchKind }) => void; +type ReplyDispatchSkipHandler = ( + payload: ReplyPayload, + info: { kind: ReplyDispatchKind; reason: NormalizeReplySkipReason }, +) => void; + type ReplyDispatchDeliverer = ( payload: ReplyPayload, info: { kind: ReplyDispatchKind }, @@ -42,6 +47,8 @@ export type ReplyDispatcherOptions = { onHeartbeatStrip?: () => void; onIdle?: () => void; onError?: ReplyDispatchErrorHandler; + // AIDEV-NOTE: onSkip lets channels detect silent/empty drops (e.g. Telegram empty-response fallback). + onSkip?: ReplyDispatchSkipHandler; /** Human-like delay between block replies for natural rhythm. */ humanDelay?: HumanDelayConfig; }; @@ -65,15 +72,16 @@ export type ReplyDispatcher = { getQueuedCounts: () => Record; }; +type NormalizeReplyPayloadInternalOptions = Pick< + ReplyDispatcherOptions, + "responsePrefix" | "responsePrefixContext" | "responsePrefixContextProvider" | "onHeartbeatStrip" +> & { + onSkip?: (reason: NormalizeReplySkipReason) => void; +}; + function normalizeReplyPayloadInternal( payload: ReplyPayload, - opts: Pick< - ReplyDispatcherOptions, - | "responsePrefix" - | "responsePrefixContext" - | "responsePrefixContextProvider" - | "onHeartbeatStrip" - >, + opts: NormalizeReplyPayloadInternalOptions, ): ReplyPayload | null { // Prefer dynamic context provider over static context const prefixContext = opts.responsePrefixContextProvider?.() ?? opts.responsePrefixContext; @@ -82,6 +90,7 @@ function normalizeReplyPayloadInternal( responsePrefix: opts.responsePrefix, responsePrefixContext: prefixContext, onHeartbeatStrip: opts.onHeartbeatStrip, + onSkip: opts.onSkip, }); } @@ -99,7 +108,13 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis }; const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => { - const normalized = normalizeReplyPayloadInternal(payload, options); + const normalized = normalizeReplyPayloadInternal(payload, { + responsePrefix: options.responsePrefix, + responsePrefixContext: options.responsePrefixContext, + responsePrefixContextProvider: options.responsePrefixContextProvider, + onHeartbeatStrip: options.onHeartbeatStrip, + onSkip: (reason) => options.onSkip?.(payload, { kind, reason }), + }); if (!normalized) return false; queuedCounts[kind] += 1; pending += 1; diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index e460b2630..4577a16ea 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -202,6 +202,16 @@ describe("canvas host", () => { it("serves the gateway-hosted A2UI scaffold", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-canvas-")); + const a2uiRoot = path.resolve(process.cwd(), "src/canvas-host/a2ui"); + const bundlePath = path.join(a2uiRoot, "a2ui.bundle.js"); + let createdBundle = false; + + try { + await fs.stat(bundlePath); + } catch { + await fs.writeFile(bundlePath, "window.moltbotA2UI = {};", "utf8"); + createdBundle = true; + } const server = await startCanvasHost({ runtime: defaultRuntime, @@ -226,6 +236,9 @@ describe("canvas host", () => { expect(js).toContain("moltbotA2UI"); } finally { await server.close(); + if (createdBundle) { + await fs.rm(bundlePath, { force: true }); + } await fs.rm(dir, { recursive: true, force: true }); } }); diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts index 68894adf5..b72267a2a 100644 --- a/src/cli/memory-cli.ts +++ b/src/cli/memory-cli.ts @@ -12,7 +12,7 @@ import { setVerbose } from "../globals.js"; import { withProgress, withProgressTotals } from "./progress.js"; import { formatErrorMessage, withManager } from "./cli-utils.js"; import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js"; -import { listMemoryFiles } from "../memory/internal.js"; +import { listMemoryFiles, normalizeExtraMemoryPaths } from "../memory/internal.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; @@ -74,6 +74,10 @@ function resolveAgentIds(cfg: ReturnType, agent?: string): st return [resolveDefaultAgentId(cfg)]; } +function formatExtraPaths(workspaceDir: string, extraPaths: string[]): string[] { + return normalizeExtraMemoryPaths(workspaceDir, extraPaths).map((entry) => shortenHomePath(entry)); +} + async function checkReadableFile(pathname: string): Promise<{ exists: boolean; issue?: string }> { try { await fs.access(pathname, fsSync.constants.R_OK); @@ -110,7 +114,10 @@ async function scanSessionFiles(agentId: string): Promise { } } -async function scanMemoryFiles(workspaceDir: string): Promise { +async function scanMemoryFiles( + workspaceDir: string, + extraPaths: string[] = [], +): Promise { const issues: string[] = []; const memoryFile = path.join(workspaceDir, "MEMORY.md"); const altMemoryFile = path.join(workspaceDir, "memory.md"); @@ -121,6 +128,25 @@ async function scanMemoryFiles(workspaceDir: string): Promise { if (primary.issue) issues.push(primary.issue); if (alt.issue) issues.push(alt.issue); + const resolvedExtraPaths = normalizeExtraMemoryPaths(workspaceDir, extraPaths); + for (const extraPath of resolvedExtraPaths) { + try { + const stat = await fs.lstat(extraPath); + if (stat.isSymbolicLink()) continue; + const extraCheck = await checkReadableFile(extraPath); + if (extraCheck.issue) issues.push(extraCheck.issue); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + issues.push(`additional memory path missing (${shortenHomePath(extraPath)})`); + } else { + issues.push( + `additional memory path not accessible (${shortenHomePath(extraPath)}): ${code ?? "error"}`, + ); + } + } + } + let dirReadable: boolean | null = null; try { await fs.access(memoryDir, fsSync.constants.R_OK); @@ -141,7 +167,7 @@ async function scanMemoryFiles(workspaceDir: string): Promise { let listed: string[] = []; let listedOk = false; try { - listed = await listMemoryFiles(workspaceDir); + listed = await listMemoryFiles(workspaceDir, resolvedExtraPaths); listedOk = true; } catch (err) { const code = (err as NodeJS.ErrnoException).code; @@ -176,11 +202,13 @@ async function scanMemorySources(params: { workspaceDir: string; agentId: string; sources: MemorySourceName[]; + extraPaths?: string[]; }): Promise { const scans: SourceScan[] = []; + const extraPaths = params.extraPaths ?? []; for (const source of params.sources) { if (source === "memory") { - scans.push(await scanMemoryFiles(params.workspaceDir)); + scans.push(await scanMemoryFiles(params.workspaceDir, extraPaths)); } if (source === "sessions") { scans.push(await scanSessionFiles(params.agentId)); @@ -268,6 +296,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) { workspaceDir: status.workspaceDir, agentId, sources, + extraPaths: status.extraPaths, }); allResults.push({ agentId, status, embeddingProbe, indexError, scan }); }, @@ -299,6 +328,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) { const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete."; defaultRuntime.log(line); } + const extraPaths = formatExtraPaths(status.workspaceDir, status.extraPaths ?? []); const lines = [ `${heading("Memory Search")} ${muted(`(${agentId})`)}`, `${label("Provider")} ${info(status.provider)} ${muted( @@ -306,6 +336,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) { )}`, `${label("Model")} ${info(status.model)}`, status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null, + extraPaths.length ? `${label("Extra paths")} ${info(extraPaths.join(", "))}` : null, `${label("Indexed")} ${success(indexedLabel)}`, `${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`, `${label("Store")} ${info(shortenHomePath(status.dbPath))}`, @@ -469,6 +500,7 @@ export function registerMemoryCli(program: Command) { const sourceLabels = status.sources.map((source) => formatSourceLabel(source, status.workspaceDir, agentId), ); + const extraPaths = formatExtraPaths(status.workspaceDir, status.extraPaths ?? []); const lines = [ `${heading("Memory Index")} ${muted(`(${agentId})`)}`, `${label("Provider")} ${info(status.provider)} ${muted( @@ -478,6 +510,9 @@ export function registerMemoryCli(program: Command) { sourceLabels.length ? `${label("Sources")} ${info(sourceLabels.join(", "))}` : null, + extraPaths.length + ? `${label("Extra paths")} ${info(extraPaths.join(", "))}` + : null, ].filter(Boolean) as string[]; if (status.fallback) { lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`); diff --git a/src/config/schema.ts b/src/config/schema.ts index b4ec8723b..28c994f3d 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -222,6 +222,7 @@ const FIELD_LABELS: Record = { "agents.defaults.memorySearch": "Memory Search", "agents.defaults.memorySearch.enabled": "Enable Memory Search", "agents.defaults.memorySearch.sources": "Memory Search Sources", + "agents.defaults.memorySearch.extraPaths": "Extra Memory Paths", "agents.defaults.memorySearch.experimental.sessionMemory": "Memory Search Session Index (Experimental)", "agents.defaults.memorySearch.provider": "Memory Search Provider", @@ -499,6 +500,8 @@ const FIELD_HELP: Record = { "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", "agents.defaults.memorySearch.sources": 'Sources to index for memory search (default: ["memory"]; add "sessions" to include session transcripts).', + "agents.defaults.memorySearch.extraPaths": + "Extra paths to include in memory search (directories or .md files; relative paths resolved from workspace).", "agents.defaults.memorySearch.experimental.sessionMemory": "Enable experimental session transcript indexing for memory search (default: false).", "agents.defaults.memorySearch.provider": 'Embedding provider ("openai", "gemini", or "local").', diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index bb1d45bf0..db32cb59d 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -226,6 +226,8 @@ export type MemorySearchConfig = { enabled?: boolean; /** Sources to index and search (default: ["memory"]). */ sources?: Array<"memory" | "sessions">; + /** Extra paths to include in memory search (directories or .md files). */ + extraPaths?: string[]; /** Experimental memory search settings. */ experimental?: { /** Enable session transcript indexing (experimental, default: false). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 7a63e307d..7e95c3538 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -304,6 +304,7 @@ export const MemorySearchSchema = z .object({ enabled: z.boolean().optional(), sources: z.array(z.union([z.literal("memory"), z.literal("sessions")])).optional(), + extraPaths: z.array(z.string()).optional(), experimental: z .object({ sessionMemory: z.boolean().optional(), diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts index 80bb5ff8f..bd4ec38ca 100644 --- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts +++ b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts @@ -135,7 +135,7 @@ describe("discord tool result dispatch", () => { expect(sendMock).toHaveBeenCalledTimes(1); }, 20_000); - it("skips guild messages when another user is explicitly mentioned", async () => { + it("accepts guild messages when mentionPatterns match even if another user is mentioned", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); const cfg = { agents: { @@ -211,8 +211,8 @@ describe("discord tool result dispatch", () => { client, ); - expect(dispatchMock).not.toHaveBeenCalled(); - expect(sendMock).not.toHaveBeenCalled(); + expect(dispatchMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledTimes(1); }, 20_000); it("accepts guild reply-to-bot messages as implicit mentions", async () => { diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index 8bebb8e20..7a4d68136 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -41,7 +41,7 @@ describe("applyMediaUnderstanding", () => { mockedResolveApiKey.mockClear(); mockedFetchRemoteMedia.mockReset(); mockedFetchRemoteMedia.mockResolvedValue({ - buffer: Buffer.from("audio-bytes"), + buffer: Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), contentType: "audio/ogg", fileName: "note.ogg", }); @@ -51,7 +51,7 @@ describe("applyMediaUnderstanding", () => { const { applyMediaUnderstanding } = await loadApply(); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); const audioPath = path.join(dir, "note.ogg"); - await fs.writeFile(audioPath, "hello"); + await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8])); const ctx: MsgContext = { Body: "", @@ -94,7 +94,7 @@ describe("applyMediaUnderstanding", () => { const { applyMediaUnderstanding } = await loadApply(); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); const audioPath = path.join(dir, "note.ogg"); - await fs.writeFile(audioPath, "hello"); + await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8])); const ctx: MsgContext = { Body: " /capture status", @@ -176,7 +176,7 @@ describe("applyMediaUnderstanding", () => { const { applyMediaUnderstanding } = await loadApply(); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); const audioPath = path.join(dir, "large.wav"); - await fs.writeFile(audioPath, "0123456789"); + await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])); const ctx: MsgContext = { Body: "", @@ -211,7 +211,7 @@ describe("applyMediaUnderstanding", () => { const { applyMediaUnderstanding } = await loadApply(); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); const audioPath = path.join(dir, "note.ogg"); - await fs.writeFile(audioPath, "hello"); + await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8])); const ctx: MsgContext = { Body: "", @@ -352,7 +352,7 @@ describe("applyMediaUnderstanding", () => { const { applyMediaUnderstanding } = await loadApply(); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); const audioPath = path.join(dir, "fallback.ogg"); - await fs.writeFile(audioPath, "hello"); + await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6])); const ctx: MsgContext = { Body: "", @@ -390,8 +390,8 @@ describe("applyMediaUnderstanding", () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); const audioPathA = path.join(dir, "note-a.ogg"); const audioPathB = path.join(dir, "note-b.ogg"); - await fs.writeFile(audioPathA, "hello"); - await fs.writeFile(audioPathB, "world"); + await fs.writeFile(audioPathA, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208])); + await fs.writeFile(audioPathB, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208])); const ctx: MsgContext = { Body: "", @@ -435,7 +435,7 @@ describe("applyMediaUnderstanding", () => { const audioPath = path.join(dir, "note.ogg"); const videoPath = path.join(dir, "clip.mp4"); await fs.writeFile(imagePath, "image-bytes"); - await fs.writeFile(audioPath, "audio-bytes"); + await fs.writeFile(audioPath, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208])); await fs.writeFile(videoPath, "video-bytes"); const ctx: MsgContext = { @@ -487,4 +487,187 @@ describe("applyMediaUnderstanding", () => { expect(ctx.CommandBody).toBe("audio ok"); expect(ctx.BodyForCommands).toBe("audio ok"); }); + + it("treats text-like audio attachments as CSV (comma wins over tabs)", async () => { + const { applyMediaUnderstanding } = await loadApply(); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); + const csvPath = path.join(dir, "data.mp3"); + const csvText = '"a","b"\t"c"\n"1","2"\t"3"'; + const csvBuffer = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(csvText, "utf16le")]); + await fs.writeFile(csvPath, csvBuffer); + + const ctx: MsgContext = { + Body: "", + MediaPath: csvPath, + MediaType: "audio/mpeg", + }; + const cfg: MoltbotConfig = { + tools: { + media: { + audio: { enabled: false }, + image: { enabled: false }, + video: { enabled: false }, + }, + }, + }; + + const result = await applyMediaUnderstanding({ ctx, cfg }); + + expect(result.appliedFile).toBe(true); + expect(ctx.Body).toContain(''); + expect(ctx.Body).toContain('"a","b"\t"c"'); + }); + + it("infers TSV when tabs are present without commas", async () => { + const { applyMediaUnderstanding } = await loadApply(); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); + const tsvPath = path.join(dir, "report.mp3"); + const tsvText = "a\tb\tc\n1\t2\t3"; + await fs.writeFile(tsvPath, tsvText); + + const ctx: MsgContext = { + Body: "", + MediaPath: tsvPath, + MediaType: "audio/mpeg", + }; + const cfg: MoltbotConfig = { + tools: { + media: { + audio: { enabled: false }, + image: { enabled: false }, + video: { enabled: false }, + }, + }, + }; + + const result = await applyMediaUnderstanding({ ctx, cfg }); + + expect(result.appliedFile).toBe(true); + expect(ctx.Body).toContain(''); + expect(ctx.Body).toContain("a\tb\tc"); + }); + + it("escapes XML special characters in filenames to prevent injection", async () => { + const { applyMediaUnderstanding } = await loadApply(); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); + // Use & in filename — valid on all platforms (including Windows, which + // forbids < and > in NTFS filenames) and still requires XML escaping. + // Note: The sanitizeFilename in store.ts would strip most dangerous chars, + // but we test that even if some slip through, they get escaped in output + const filePath = path.join(dir, "file&test.txt"); + await fs.writeFile(filePath, "safe content"); + + const ctx: MsgContext = { + Body: "", + MediaPath: filePath, + MediaType: "text/plain", + }; + const cfg: MoltbotConfig = { + tools: { + media: { + audio: { enabled: false }, + image: { enabled: false }, + video: { enabled: false }, + }, + }, + }; + + const result = await applyMediaUnderstanding({ ctx, cfg }); + + expect(result.appliedFile).toBe(true); + // Verify XML special chars are escaped in the output + expect(ctx.Body).toContain("&"); + // The name attribute should contain the escaped form, not a raw unescaped & + expect(ctx.Body).toMatch(/name="file&test\.txt"/); + }); + + it("normalizes MIME types to prevent attribute injection", async () => { + const { applyMediaUnderstanding } = await loadApply(); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); + const filePath = path.join(dir, "data.txt"); + await fs.writeFile(filePath, "test content"); + + const ctx: MsgContext = { + Body: "", + MediaPath: filePath, + // Attempt to inject via MIME type with quotes - normalization should strip this + MediaType: 'text/plain" onclick="alert(1)', + }; + const cfg: MoltbotConfig = { + tools: { + media: { + audio: { enabled: false }, + image: { enabled: false }, + video: { enabled: false }, + }, + }, + }; + + const result = await applyMediaUnderstanding({ ctx, cfg }); + + expect(result.appliedFile).toBe(true); + // MIME normalization strips everything after first ; or " - verify injection is blocked + expect(ctx.Body).not.toContain("onclick="); + expect(ctx.Body).not.toContain("alert(1)"); + // Verify the MIME type is normalized to just "text/plain" + expect(ctx.Body).toContain('mime="text/plain"'); + }); + + it("handles path traversal attempts in filenames safely", async () => { + const { applyMediaUnderstanding } = await loadApply(); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); + // Even if a file somehow got a path-like name, it should be handled safely + const filePath = path.join(dir, "normal.txt"); + await fs.writeFile(filePath, "legitimate content"); + + const ctx: MsgContext = { + Body: "", + MediaPath: filePath, + MediaType: "text/plain", + }; + const cfg: MoltbotConfig = { + tools: { + media: { + audio: { enabled: false }, + image: { enabled: false }, + video: { enabled: false }, + }, + }, + }; + + const result = await applyMediaUnderstanding({ ctx, cfg }); + + expect(result.appliedFile).toBe(true); + // Verify the file was processed and output contains expected structure + expect(ctx.Body).toContain(' { + const { applyMediaUnderstanding } = await loadApply(); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); + const filePath = path.join(dir, "文档.txt"); + await fs.writeFile(filePath, "中文内容"); + + const ctx: MsgContext = { + Body: "", + MediaPath: filePath, + MediaType: "text/plain", + }; + const cfg: MoltbotConfig = { + tools: { + media: { + audio: { enabled: false }, + image: { enabled: false }, + video: { enabled: false }, + }, + }, + }; + + const result = await applyMediaUnderstanding({ ctx, cfg }); + + expect(result.appliedFile).toBe(true); + expect(ctx.Body).toContain("中文内容"); + }); }); diff --git a/src/media-understanding/apply.ts b/src/media-understanding/apply.ts index dab640789..7c2a18006 100644 --- a/src/media-understanding/apply.ts +++ b/src/media-understanding/apply.ts @@ -1,6 +1,22 @@ +import path from "node:path"; + import type { MoltbotConfig } from "../config/config.js"; import type { MsgContext } from "../auto-reply/templating.js"; import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; +import { logVerbose, shouldLogVerbose } from "../globals.js"; +import { + DEFAULT_INPUT_FILE_MAX_BYTES, + DEFAULT_INPUT_FILE_MAX_CHARS, + DEFAULT_INPUT_FILE_MIMES, + DEFAULT_INPUT_MAX_REDIRECTS, + DEFAULT_INPUT_PDF_MAX_PAGES, + DEFAULT_INPUT_PDF_MAX_PIXELS, + DEFAULT_INPUT_PDF_MIN_TEXT_CHARS, + DEFAULT_INPUT_TIMEOUT_MS, + extractFileContentFromSource, + normalizeMimeList, + normalizeMimeType, +} from "../media/input-files.js"; import { extractMediaUserText, formatAudioTranscripts, @@ -14,6 +30,7 @@ import type { } from "./types.js"; import { runWithConcurrency } from "./concurrency.js"; import { resolveConcurrency } from "./resolve.js"; +import { resolveAttachmentKind } from "./attachments.js"; import { type ActiveMediaModel, buildProviderRegistry, @@ -28,9 +45,279 @@ export type ApplyMediaUnderstandingResult = { appliedImage: boolean; appliedAudio: boolean; appliedVideo: boolean; + appliedFile: boolean; }; const CAPABILITY_ORDER: MediaUnderstandingCapability[] = ["image", "audio", "video"]; +const EXTRA_TEXT_MIMES = [ + "application/xml", + "text/xml", + "application/x-yaml", + "text/yaml", + "application/yaml", + "application/javascript", + "text/javascript", + "text/tab-separated-values", +]; +const TEXT_EXT_MIME = new Map([ + [".csv", "text/csv"], + [".tsv", "text/tab-separated-values"], + [".txt", "text/plain"], + [".md", "text/markdown"], + [".log", "text/plain"], + [".ini", "text/plain"], + [".cfg", "text/plain"], + [".conf", "text/plain"], + [".env", "text/plain"], + [".json", "application/json"], + [".yaml", "text/yaml"], + [".yml", "text/yaml"], + [".xml", "application/xml"], +]); + +const XML_ESCAPE_MAP: Record = { + "<": "<", + ">": ">", + "&": "&", + '"': """, + "'": "'", +}; + +/** + * Escapes special XML characters in attribute values to prevent injection. + */ +function xmlEscapeAttr(value: string): string { + return value.replace(/[<>&"']/g, (char) => XML_ESCAPE_MAP[char] ?? char); +} + +function resolveFileLimits(cfg: MoltbotConfig) { + const files = cfg.gateway?.http?.endpoints?.responses?.files; + return { + allowUrl: files?.allowUrl ?? true, + allowedMimes: normalizeMimeList(files?.allowedMimes, DEFAULT_INPUT_FILE_MIMES), + maxBytes: files?.maxBytes ?? DEFAULT_INPUT_FILE_MAX_BYTES, + maxChars: files?.maxChars ?? DEFAULT_INPUT_FILE_MAX_CHARS, + maxRedirects: files?.maxRedirects ?? DEFAULT_INPUT_MAX_REDIRECTS, + timeoutMs: files?.timeoutMs ?? DEFAULT_INPUT_TIMEOUT_MS, + pdf: { + maxPages: files?.pdf?.maxPages ?? DEFAULT_INPUT_PDF_MAX_PAGES, + maxPixels: files?.pdf?.maxPixels ?? DEFAULT_INPUT_PDF_MAX_PIXELS, + minTextChars: files?.pdf?.minTextChars ?? DEFAULT_INPUT_PDF_MIN_TEXT_CHARS, + }, + }; +} + +function appendFileBlocks(body: string | undefined, blocks: string[]): string { + if (!blocks || blocks.length === 0) { + return body ?? ""; + } + const base = typeof body === "string" ? body.trim() : ""; + const suffix = blocks.join("\n\n").trim(); + if (!base) { + return suffix; + } + return `${base}\n\n${suffix}`.trim(); +} + +function resolveUtf16Charset(buffer?: Buffer): "utf-16le" | "utf-16be" | undefined { + if (!buffer || buffer.length < 2) return undefined; + const b0 = buffer[0]; + const b1 = buffer[1]; + if (b0 === 0xff && b1 === 0xfe) { + return "utf-16le"; + } + if (b0 === 0xfe && b1 === 0xff) { + return "utf-16be"; + } + const sampleLen = Math.min(buffer.length, 2048); + let zeroCount = 0; + for (let i = 0; i < sampleLen; i += 1) { + if (buffer[i] === 0) zeroCount += 1; + } + if (zeroCount / sampleLen > 0.2) { + return "utf-16le"; + } + return undefined; +} + +function looksLikeUtf8Text(buffer?: Buffer): boolean { + if (!buffer || buffer.length === 0) return false; + const sampleLen = Math.min(buffer.length, 4096); + let printable = 0; + let other = 0; + for (let i = 0; i < sampleLen; i += 1) { + const byte = buffer[i]; + if (byte === 0) { + other += 1; + continue; + } + if (byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte <= 126)) { + printable += 1; + } else { + other += 1; + } + } + const total = printable + other; + if (total === 0) return false; + return printable / total > 0.85; +} + +function decodeTextSample(buffer?: Buffer): string { + if (!buffer || buffer.length === 0) return ""; + const sample = buffer.subarray(0, Math.min(buffer.length, 8192)); + const utf16Charset = resolveUtf16Charset(sample); + if (utf16Charset === "utf-16be") { + const swapped = Buffer.alloc(sample.length); + for (let i = 0; i + 1 < sample.length; i += 2) { + swapped[i] = sample[i + 1]; + swapped[i + 1] = sample[i]; + } + return new TextDecoder("utf-16le").decode(swapped); + } + if (utf16Charset === "utf-16le") { + return new TextDecoder("utf-16le").decode(sample); + } + return new TextDecoder("utf-8").decode(sample); +} + +function guessDelimitedMime(text: string): string | undefined { + if (!text) return undefined; + const line = text.split(/\r?\n/)[0] ?? ""; + const tabs = (line.match(/\t/g) ?? []).length; + const commas = (line.match(/,/g) ?? []).length; + if (commas > 0) { + return "text/csv"; + } + if (tabs > 0) { + return "text/tab-separated-values"; + } + return undefined; +} + +function resolveTextMimeFromName(name?: string): string | undefined { + if (!name) return undefined; + const ext = path.extname(name).toLowerCase(); + return TEXT_EXT_MIME.get(ext); +} + +async function extractFileBlocks(params: { + attachments: ReturnType; + cache: ReturnType; + limits: ReturnType; +}): Promise { + const { attachments, cache, limits } = params; + if (!attachments || attachments.length === 0) { + return []; + } + const blocks: string[] = []; + for (const attachment of attachments) { + if (!attachment) { + continue; + } + const forcedTextMime = resolveTextMimeFromName(attachment.path ?? attachment.url ?? ""); + const kind = forcedTextMime ? "document" : resolveAttachmentKind(attachment); + if (!forcedTextMime && (kind === "image" || kind === "video")) { + continue; + } + if (!limits.allowUrl && attachment.url && !attachment.path) { + if (shouldLogVerbose()) { + logVerbose(`media: file attachment skipped (url disabled) index=${attachment.index}`); + } + continue; + } + let bufferResult: Awaited>; + try { + bufferResult = await cache.getBuffer({ + attachmentIndex: attachment.index, + maxBytes: limits.maxBytes, + timeoutMs: limits.timeoutMs, + }); + } catch (err) { + if (shouldLogVerbose()) { + logVerbose(`media: file attachment skipped (buffer): ${String(err)}`); + } + continue; + } + const nameHint = bufferResult?.fileName ?? attachment.path ?? attachment.url; + const forcedTextMimeResolved = forcedTextMime ?? resolveTextMimeFromName(nameHint ?? ""); + const utf16Charset = resolveUtf16Charset(bufferResult?.buffer); + const textSample = decodeTextSample(bufferResult?.buffer); + const textLike = Boolean(utf16Charset) || looksLikeUtf8Text(bufferResult?.buffer); + if (!forcedTextMimeResolved && kind === "audio" && !textLike) { + continue; + } + const guessedDelimited = textLike ? guessDelimitedMime(textSample) : undefined; + const textHint = + forcedTextMimeResolved ?? guessedDelimited ?? (textLike ? "text/plain" : undefined); + const rawMime = bufferResult?.mime ?? attachment.mime; + const mimeType = textHint ?? normalizeMimeType(rawMime); + // Log when MIME type is overridden from non-text to text for auditability + if (textHint && rawMime && !rawMime.startsWith("text/")) { + logVerbose( + `media: MIME override from "${rawMime}" to "${textHint}" for index=${attachment.index}`, + ); + } + if (!mimeType) { + if (shouldLogVerbose()) { + logVerbose(`media: file attachment skipped (unknown mime) index=${attachment.index}`); + } + continue; + } + const allowedMimes = new Set(limits.allowedMimes); + for (const extra of EXTRA_TEXT_MIMES) { + allowedMimes.add(extra); + } + if (mimeType.startsWith("text/")) { + allowedMimes.add(mimeType); + } + if (!allowedMimes.has(mimeType)) { + if (shouldLogVerbose()) { + logVerbose( + `media: file attachment skipped (unsupported mime ${mimeType}) index=${attachment.index}`, + ); + } + continue; + } + let extracted: Awaited>; + try { + const mediaType = utf16Charset ? `${mimeType}; charset=${utf16Charset}` : mimeType; + extracted = await extractFileContentFromSource({ + source: { + type: "base64", + data: bufferResult.buffer.toString("base64"), + mediaType, + filename: bufferResult.fileName, + }, + limits: { + ...limits, + allowedMimes, + }, + }); + } catch (err) { + if (shouldLogVerbose()) { + logVerbose(`media: file attachment skipped (extract): ${String(err)}`); + } + continue; + } + const text = extracted?.text?.trim() ?? ""; + let blockText = text; + if (!blockText) { + if (extracted?.images && extracted.images.length > 0) { + blockText = "[PDF content rendered to images; images not forwarded to model]"; + } else { + blockText = "[No extractable text]"; + } + } + const safeName = (bufferResult.fileName ?? `file-${attachment.index + 1}`) + .replace(/[\r\n\t]+/g, " ") + .trim(); + // Escape XML special characters in attributes to prevent injection + blocks.push( + `\n${blockText}\n`, + ); + } + return blocks; +} export async function applyMediaUnderstanding(params: { ctx: MsgContext; @@ -51,6 +338,12 @@ export async function applyMediaUnderstanding(params: { const cache = createMediaAttachmentCache(attachments); try { + const fileBlocks = await extractFileBlocks({ + attachments, + cache, + limits: resolveFileLimits(cfg), + }); + const tasks = CAPABILITY_ORDER.map((capability) => async () => { const config = cfg.tools?.media?.[capability]; return await runCapability({ @@ -99,7 +392,15 @@ export async function applyMediaUnderstanding(params: { ctx.RawBody = originalUserText; } ctx.MediaUnderstanding = [...(ctx.MediaUnderstanding ?? []), ...outputs]; - finalizeInboundContext(ctx, { forceBodyForAgent: true, forceBodyForCommands: true }); + } + if (fileBlocks.length > 0) { + ctx.Body = appendFileBlocks(ctx.Body, fileBlocks); + } + if (outputs.length > 0 || fileBlocks.length > 0) { + finalizeInboundContext(ctx, { + forceBodyForAgent: true, + forceBodyForCommands: outputs.length > 0, + }); } return { @@ -108,6 +409,7 @@ export async function applyMediaUnderstanding(params: { appliedImage: outputs.some((output) => output.kind === "image.description"), appliedAudio: outputs.some((output) => output.kind === "audio.transcription"), appliedVideo: outputs.some((output) => output.kind === "video.description"), + appliedFile: fileBlocks.length > 0, }; } finally { await cache.cleanup(); diff --git a/src/memory/index.test.ts b/src/memory/index.test.ts index 58a98e580..cccd1fa49 100644 --- a/src/memory/index.test.ts +++ b/src/memory/index.test.ts @@ -412,4 +412,52 @@ describe("memory index", () => { manager = result.manager; await expect(result.manager.readFile({ relPath: "NOTES.md" })).rejects.toThrow("path required"); }); + + it("allows reading from additional memory paths and blocks symlinks", async () => { + const extraDir = path.join(workspaceDir, "extra"); + await fs.mkdir(extraDir, { recursive: true }); + await fs.writeFile(path.join(extraDir, "extra.md"), "Extra content."); + + const cfg = { + agents: { + defaults: { + workspace: workspaceDir, + memorySearch: { + provider: "openai", + model: "mock-embed", + store: { path: indexPath }, + sync: { watch: false, onSessionStart: false, onSearch: true }, + extraPaths: [extraDir], + }, + }, + list: [{ id: "main", default: true }], + }, + }; + const result = await getMemorySearchManager({ cfg, agentId: "main" }); + expect(result.manager).not.toBeNull(); + if (!result.manager) throw new Error("manager missing"); + manager = result.manager; + await expect(result.manager.readFile({ relPath: "extra/extra.md" })).resolves.toEqual({ + path: "extra/extra.md", + text: "Extra content.", + }); + + const linkPath = path.join(extraDir, "linked.md"); + let symlinkOk = true; + try { + await fs.symlink(path.join(extraDir, "extra.md"), linkPath, "file"); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "EPERM" || code === "EACCES") { + symlinkOk = false; + } else { + throw err; + } + } + if (symlinkOk) { + await expect(result.manager.readFile({ relPath: "extra/linked.md" })).rejects.toThrow( + "path required", + ); + } + }); }); diff --git a/src/memory/internal.test.ts b/src/memory/internal.test.ts index 29c698779..7530d8e44 100644 --- a/src/memory/internal.test.ts +++ b/src/memory/internal.test.ts @@ -1,6 +1,117 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; -import { chunkMarkdown } from "./internal.js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { chunkMarkdown, listMemoryFiles, normalizeExtraMemoryPaths } from "./internal.js"; + +describe("normalizeExtraMemoryPaths", () => { + it("trims, resolves, and dedupes paths", () => { + const workspaceDir = path.join(os.tmpdir(), "memory-test-workspace"); + const absPath = path.resolve(path.sep, "shared-notes"); + const result = normalizeExtraMemoryPaths(workspaceDir, [ + " notes ", + "./notes", + absPath, + absPath, + "", + ]); + expect(result).toEqual([path.resolve(workspaceDir, "notes"), absPath]); + }); +}); + +describe("listMemoryFiles", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-test-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("includes files from additional paths (directory)", async () => { + await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); + const extraDir = path.join(tmpDir, "extra-notes"); + await fs.mkdir(extraDir, { recursive: true }); + await fs.writeFile(path.join(extraDir, "note1.md"), "# Note 1"); + await fs.writeFile(path.join(extraDir, "note2.md"), "# Note 2"); + await fs.writeFile(path.join(extraDir, "ignore.txt"), "Not a markdown file"); + + const files = await listMemoryFiles(tmpDir, [extraDir]); + expect(files).toHaveLength(3); + expect(files.some((file) => file.endsWith("MEMORY.md"))).toBe(true); + expect(files.some((file) => file.endsWith("note1.md"))).toBe(true); + expect(files.some((file) => file.endsWith("note2.md"))).toBe(true); + expect(files.some((file) => file.endsWith("ignore.txt"))).toBe(false); + }); + + it("includes files from additional paths (single file)", async () => { + await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); + const singleFile = path.join(tmpDir, "standalone.md"); + await fs.writeFile(singleFile, "# Standalone"); + + const files = await listMemoryFiles(tmpDir, [singleFile]); + expect(files).toHaveLength(2); + expect(files.some((file) => file.endsWith("standalone.md"))).toBe(true); + }); + + it("handles relative paths in additional paths", async () => { + await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); + const extraDir = path.join(tmpDir, "subdir"); + await fs.mkdir(extraDir, { recursive: true }); + await fs.writeFile(path.join(extraDir, "nested.md"), "# Nested"); + + const files = await listMemoryFiles(tmpDir, ["subdir"]); + expect(files).toHaveLength(2); + expect(files.some((file) => file.endsWith("nested.md"))).toBe(true); + }); + + it("ignores non-existent additional paths", async () => { + await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); + + const files = await listMemoryFiles(tmpDir, ["/does/not/exist"]); + expect(files).toHaveLength(1); + }); + + it("ignores symlinked files and directories", async () => { + await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); + const extraDir = path.join(tmpDir, "extra"); + await fs.mkdir(extraDir, { recursive: true }); + await fs.writeFile(path.join(extraDir, "note.md"), "# Note"); + + const targetFile = path.join(tmpDir, "target.md"); + await fs.writeFile(targetFile, "# Target"); + const linkFile = path.join(extraDir, "linked.md"); + + const targetDir = path.join(tmpDir, "target-dir"); + await fs.mkdir(targetDir, { recursive: true }); + await fs.writeFile(path.join(targetDir, "nested.md"), "# Nested"); + const linkDir = path.join(tmpDir, "linked-dir"); + + let symlinksOk = true; + try { + await fs.symlink(targetFile, linkFile, "file"); + await fs.symlink(targetDir, linkDir, "dir"); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "EPERM" || code === "EACCES") { + symlinksOk = false; + } else { + throw err; + } + } + + const files = await listMemoryFiles(tmpDir, [extraDir, linkDir]); + expect(files.some((file) => file.endsWith("note.md"))).toBe(true); + if (symlinksOk) { + expect(files.some((file) => file.endsWith("linked.md"))).toBe(false); + expect(files.some((file) => file.endsWith("nested.md"))).toBe(false); + } + }); +}); describe("chunkMarkdown", () => { it("splits overly long lines into max-sized chunks", () => { diff --git a/src/memory/internal.ts b/src/memory/internal.ts index b68570c35..b2ab8c0a4 100644 --- a/src/memory/internal.ts +++ b/src/memory/internal.ts @@ -30,6 +30,17 @@ export function normalizeRelPath(value: string): string { return trimmed.replace(/\\/g, "/"); } +export function normalizeExtraMemoryPaths(workspaceDir: string, extraPaths?: string[]): string[] { + if (!extraPaths?.length) return []; + const resolved = extraPaths + .map((value) => value.trim()) + .filter(Boolean) + .map((value) => + path.isAbsolute(value) ? path.resolve(value) : path.resolve(workspaceDir, value), + ); + return Array.from(new Set(resolved)); +} + export function isMemoryPath(relPath: string): boolean { const normalized = normalizeRelPath(relPath); if (!normalized) return false; @@ -37,19 +48,11 @@ export function isMemoryPath(relPath: string): boolean { return normalized.startsWith("memory/"); } -async function exists(filePath: string): Promise { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } -} - async function walkDir(dir: string, files: string[]) { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const full = path.join(dir, entry.name); + if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) { await walkDir(full, files); continue; @@ -60,15 +63,48 @@ async function walkDir(dir: string, files: string[]) { } } -export async function listMemoryFiles(workspaceDir: string): Promise { +export async function listMemoryFiles( + workspaceDir: string, + extraPaths?: string[], +): Promise { const result: string[] = []; const memoryFile = path.join(workspaceDir, "MEMORY.md"); const altMemoryFile = path.join(workspaceDir, "memory.md"); - if (await exists(memoryFile)) result.push(memoryFile); - if (await exists(altMemoryFile)) result.push(altMemoryFile); const memoryDir = path.join(workspaceDir, "memory"); - if (await exists(memoryDir)) { - await walkDir(memoryDir, result); + + const addMarkdownFile = async (absPath: string) => { + try { + const stat = await fs.lstat(absPath); + if (stat.isSymbolicLink() || !stat.isFile()) return; + if (!absPath.endsWith(".md")) return; + result.push(absPath); + } catch {} + }; + + await addMarkdownFile(memoryFile); + await addMarkdownFile(altMemoryFile); + try { + const dirStat = await fs.lstat(memoryDir); + if (!dirStat.isSymbolicLink() && dirStat.isDirectory()) { + await walkDir(memoryDir, result); + } + } catch {} + + const normalizedExtraPaths = normalizeExtraMemoryPaths(workspaceDir, extraPaths); + if (normalizedExtraPaths.length > 0) { + for (const inputPath of normalizedExtraPaths) { + try { + const stat = await fs.lstat(inputPath); + if (stat.isSymbolicLink()) continue; + if (stat.isDirectory()) { + await walkDir(inputPath, result); + continue; + } + if (stat.isFile() && inputPath.endsWith(".md")) { + result.push(inputPath); + } + } catch {} + } } if (result.length <= 1) return result; const seen = new Set(); diff --git a/src/memory/manager-cache-key.ts b/src/memory/manager-cache-key.ts index 9fbe3e436..d143a9057 100644 --- a/src/memory/manager-cache-key.ts +++ b/src/memory/manager-cache-key.ts @@ -13,6 +13,7 @@ export function computeMemoryManagerCacheKey(params: { JSON.stringify({ enabled: settings.enabled, sources: [...settings.sources].sort((a, b) => a.localeCompare(b)), + extraPaths: [...settings.extraPaths].sort((a, b) => a.localeCompare(b)), provider: settings.provider, model: settings.model, fallback: settings.fallback, diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 9a9991d10..a799a5e0f 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; @@ -35,9 +36,9 @@ import { hashText, isMemoryPath, listMemoryFiles, + normalizeExtraMemoryPaths, type MemoryChunk, type MemoryFileEntry, - normalizeRelPath, parseEmbedding, } from "./internal.js"; import { bm25RankToScore, buildFtsQuery, mergeHybridResults } from "./hybrid.js"; @@ -396,13 +397,52 @@ export class MemoryIndexManager { from?: number; lines?: number; }): Promise<{ text: string; path: string }> { - const relPath = normalizeRelPath(params.relPath); - if (!relPath || !isMemoryPath(relPath)) { + const rawPath = params.relPath.trim(); + if (!rawPath) { throw new Error("path required"); } - const absPath = path.resolve(this.workspaceDir, relPath); - if (!absPath.startsWith(this.workspaceDir)) { - throw new Error("path escapes workspace"); + const absPath = path.isAbsolute(rawPath) + ? path.resolve(rawPath) + : path.resolve(this.workspaceDir, rawPath); + const relPath = path.relative(this.workspaceDir, absPath).replace(/\\/g, "/"); + const inWorkspace = + relPath.length > 0 && !relPath.startsWith("..") && !path.isAbsolute(relPath); + const allowedWorkspace = inWorkspace && isMemoryPath(relPath); + let allowedAdditional = false; + if (!allowedWorkspace && this.settings.extraPaths.length > 0) { + const additionalPaths = normalizeExtraMemoryPaths( + this.workspaceDir, + this.settings.extraPaths, + ); + for (const additionalPath of additionalPaths) { + try { + const stat = await fs.lstat(additionalPath); + if (stat.isSymbolicLink()) continue; + if (stat.isDirectory()) { + if (absPath === additionalPath || absPath.startsWith(`${additionalPath}${path.sep}`)) { + allowedAdditional = true; + break; + } + continue; + } + if (stat.isFile()) { + if (absPath === additionalPath && absPath.endsWith(".md")) { + allowedAdditional = true; + break; + } + } + } catch {} + } + } + if (!allowedWorkspace && !allowedAdditional) { + throw new Error("path required"); + } + if (!absPath.endsWith(".md")) { + throw new Error("path required"); + } + const stat = await fs.lstat(absPath); + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error("path required"); } const content = await fs.readFile(absPath, "utf-8"); if (!params.from && !params.lines) { @@ -425,6 +465,7 @@ export class MemoryIndexManager { model: string; requestedProvider: string; sources: MemorySource[]; + extraPaths: string[]; sourceCounts: Array<{ source: MemorySource; files: number; chunks: number }>; cache?: { enabled: boolean; entries?: number; maxEntries?: number }; fts?: { enabled: boolean; available: boolean; error?: string }; @@ -498,6 +539,7 @@ export class MemoryIndexManager { model: this.provider.model, requestedProvider: this.requestedProvider, sources: Array.from(this.sources), + extraPaths: this.settings.extraPaths, sourceCounts, cache: this.cache.enabled ? { @@ -769,11 +811,23 @@ export class MemoryIndexManager { private ensureWatcher() { if (!this.sources.has("memory") || !this.settings.sync.watch || this.watcher) return; - const watchPaths = [ + const additionalPaths = normalizeExtraMemoryPaths(this.workspaceDir, this.settings.extraPaths) + .map((entry) => { + try { + const stat = fsSync.lstatSync(entry); + return stat.isSymbolicLink() ? null : entry; + } catch { + return null; + } + }) + .filter((entry): entry is string => Boolean(entry)); + const watchPaths = new Set([ path.join(this.workspaceDir, "MEMORY.md"), + path.join(this.workspaceDir, "memory.md"), path.join(this.workspaceDir, "memory"), - ]; - this.watcher = chokidar.watch(watchPaths, { + ...additionalPaths, + ]); + this.watcher = chokidar.watch(Array.from(watchPaths), { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: this.settings.sync.watchDebounceMs, @@ -975,7 +1029,7 @@ export class MemoryIndexManager { needsFullReindex: boolean; progress?: MemorySyncProgressState; }) { - const files = await listMemoryFiles(this.workspaceDir); + const files = await listMemoryFiles(this.workspaceDir, this.settings.extraPaths); const fileEntries = await Promise.all( files.map(async (file) => buildFileEntry(file, this.workspaceDir)), ); diff --git a/src/memory/sync-memory-files.ts b/src/memory/sync-memory-files.ts index 53fed7ebe..c5073dc50 100644 --- a/src/memory/sync-memory-files.ts +++ b/src/memory/sync-memory-files.ts @@ -14,6 +14,7 @@ type ProgressState = { export async function syncMemoryFiles(params: { workspaceDir: string; + extraPaths?: string[]; db: DatabaseSync; needsFullReindex: boolean; progress?: ProgressState; @@ -27,7 +28,7 @@ export async function syncMemoryFiles(params: { ftsAvailable: boolean; model: string; }) { - const files = await listMemoryFiles(params.workspaceDir); + const files = await listMemoryFiles(params.workspaceDir, params.extraPaths); const fileEntries = await Promise.all( files.map(async (file) => buildFileEntry(file, params.workspaceDir)), ); diff --git a/src/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 d1b950af0..b1302dbf9 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -336,6 +336,7 @@ export const buildTelegramMessageContext = async ({ let placeholder = ""; if (msg.photo) placeholder = ""; else if (msg.video) placeholder = ""; + else if (msg.video_note) placeholder = ""; else if (msg.audio || msg.voice) placeholder = ""; else if (msg.document) placeholder = ""; else if (msg.sticker) placeholder = ""; diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index cead0628a..ea006e316 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -21,6 +21,8 @@ import { createTelegramDraftStream } from "./draft-stream.js"; import { cacheSticker, describeStickerImage } from "./sticker-cache.js"; import { resolveAgentDir } from "../agents/agent-scope.js"; +const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; + async function resolveStickerVisionSupport(cfg, agentId) { try { const catalog = await loadModelCatalog({ config: cfg }); @@ -198,6 +200,15 @@ export const dispatchTelegramMessage = async ({ } } + const replyQuoteText = + ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody + ? ctxPayload.ReplyToBody.trim() || undefined + : undefined; + const deliveryState = { + delivered: false, + skippedNonSilent: 0, + }; + const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg, @@ -209,12 +220,7 @@ export const dispatchTelegramMessage = async ({ await flushDraft(); draftStream?.stop(); } - - const replyQuoteText = - ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody - ? ctxPayload.ReplyToBody.trim() || undefined - : undefined; - await deliverReplies({ + const result = await deliverReplies({ replies: [payload], chatId: String(chatId), token: opts.token, @@ -229,6 +235,12 @@ export const dispatchTelegramMessage = async ({ linkPreview: telegramCfg.linkPreview, replyQuoteText, }); + if (result.delivered) { + deliveryState.delivered = true; + } + }, + onSkip: (_payload, info) => { + if (info.reason !== "silent") deliveryState.skippedNonSilent += 1; }, onError: (err, info) => { runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`)); @@ -260,7 +272,27 @@ export const dispatchTelegramMessage = async ({ }, }); draftStream?.stop(); - if (!queuedFinal) { + let sentFallback = false; + if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) { + const result = await deliverReplies({ + replies: [{ text: EMPTY_RESPONSE_FALLBACK }], + chatId: String(chatId), + token: opts.token, + runtime, + bot, + replyToMode, + textLimit, + messageThreadId: resolvedThreadId, + tableMode, + chunkMode, + linkPreview: telegramCfg.linkPreview, + replyQuoteText, + }); + sentFallback = result.delivered; + } + + const hasFinalResponse = queuedFinal || sentFallback; + if (!hasFinalResponse) { if (isGroup && historyKey) { clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit }); } diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 3415ea927..59f109a1f 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -50,6 +50,8 @@ import { import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js"; import { readTelegramAllowFromStore } from "./pairing-store.js"; +const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; + type TelegramNativeCommandContext = Context & { match?: string }; type TelegramCommandAuthResult = { @@ -468,6 +470,7 @@ export const registerTelegramNativeCommands = ({ CommandAuthorized: commandAuthorized, CommandSource: "native" as const, SessionKey: `telegram:slash:${senderId || chatId}`, + AccountId: route.accountId, CommandTargetSessionKey: sessionKey, MessageThreadId: threadIdForSend, IsForum: isForum, @@ -482,13 +485,18 @@ export const registerTelegramNativeCommands = ({ : undefined; const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId); + const deliveryState = { + delivered: false, + skippedNonSilent: 0, + }; + await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg, dispatcherOptions: { responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, - deliver: async (payload) => { - await deliverReplies({ + deliver: async (payload, _info) => { + const result = await deliverReplies({ replies: [payload], chatId: String(chatId), token: opts.token, @@ -501,6 +509,12 @@ export const registerTelegramNativeCommands = ({ chunkMode, linkPreview: telegramCfg.linkPreview, }); + if (result.delivered) { + deliveryState.delivered = true; + } + }, + onSkip: (_payload, info) => { + if (info.reason !== "silent") deliveryState.skippedNonSilent += 1; }, onError: (err, info) => { runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`)); @@ -511,6 +525,21 @@ export const registerTelegramNativeCommands = ({ disableBlockStreaming, }, }); + if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) { + await deliverReplies({ + replies: [{ text: EMPTY_RESPONSE_FALLBACK }], + chatId: String(chatId), + token: opts.token, + runtime, + bot, + replyToMode, + textLimit, + messageThreadId: threadIdForSend, + tableMode, + chunkMode, + linkPreview: telegramCfg.linkPreview, + }); + } }); } diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index 8bfe1fdd3..03aaeebd7 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -212,7 +212,7 @@ describe("createTelegramBot", () => { ); }); - it("skips group messages when another user is explicitly mentioned", async () => { + it("accepts group messages when mentionPatterns match even if another user is mentioned", async () => { onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); @@ -249,7 +249,8 @@ describe("createTelegramBot", () => { getFile: async () => ({ download: async () => new Uint8Array() }), }); - expect(replySpy).not.toHaveBeenCalled(); + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy.mock.calls[0][0].WasMentioned).toBe(true); }); it("keeps group envelope headers stable (sender identity is separate)", async () => { diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 4f45f9997..669340b20 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -44,7 +44,7 @@ export async function deliverReplies(params: { linkPreview?: boolean; /** Optional quote text for Telegram reply_parameters. */ replyQuoteText?: string; -}) { +}): Promise<{ delivered: boolean }> { const { replies, chatId, @@ -58,6 +58,10 @@ export async function deliverReplies(params: { } = params; const chunkMode = params.chunkMode ?? "length"; let hasReplied = false; + let hasDelivered = false; + const markDelivered = () => { + hasDelivered = true; + }; const chunkText = (markdown: string) => { const markdownChunks = chunkMode === "newline" @@ -114,6 +118,7 @@ export async function deliverReplies(params: { linkPreview, replyMarkup: shouldAttachButtons ? replyMarkup : undefined, }); + markDelivered(); if (replyToId && !hasReplied) { hasReplied = true; } @@ -165,18 +170,21 @@ export async function deliverReplies(params: { runtime, fn: () => bot.api.sendAnimation(chatId, file, { ...mediaParams }), }); + markDelivered(); } else if (kind === "image") { await withTelegramApiErrorLogging({ operation: "sendPhoto", runtime, fn: () => bot.api.sendPhoto(chatId, file, { ...mediaParams }), }); + markDelivered(); } else if (kind === "video") { await withTelegramApiErrorLogging({ operation: "sendVideo", runtime, fn: () => bot.api.sendVideo(chatId, file, { ...mediaParams }), }); + markDelivered(); } else if (kind === "audio") { const { useVoice } = resolveTelegramVoiceSend({ wantsVoice: reply.audioAsVoice === true, // default false (backward compatible) @@ -195,6 +203,7 @@ export async function deliverReplies(params: { shouldLog: (err) => !isVoiceMessagesForbidden(err), fn: () => bot.api.sendVoice(chatId, file, { ...mediaParams }), }); + markDelivered(); } catch (voiceErr) { // Fall back to text if voice messages are forbidden in this chat. // This happens when the recipient has Telegram Premium privacy settings @@ -221,6 +230,7 @@ export async function deliverReplies(params: { replyMarkup, replyQuoteText, }); + markDelivered(); // Skip this media item; continue with next. continue; } @@ -233,6 +243,7 @@ export async function deliverReplies(params: { runtime, fn: () => bot.api.sendAudio(chatId, file, { ...mediaParams }), }); + markDelivered(); } } else { await withTelegramApiErrorLogging({ @@ -240,6 +251,7 @@ export async function deliverReplies(params: { runtime, fn: () => bot.api.sendDocument(chatId, file, { ...mediaParams }), }); + markDelivered(); } if (replyToId && !hasReplied) { hasReplied = true; @@ -260,6 +272,7 @@ export async function deliverReplies(params: { linkPreview, replyMarkup: i === 0 ? replyMarkup : undefined, }); + markDelivered(); if (replyToId && !hasReplied) { hasReplied = true; } @@ -268,6 +281,8 @@ export async function deliverReplies(params: { } } } + + return { delivered: hasDelivered }; } export async function resolveMedia( @@ -310,7 +325,14 @@ export async function resolveMedia( fetchImpl, filePathHint: file.file_path, }); - const saved = await saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes); + const originalName = fetched.fileName ?? file.file_path; + const saved = await saveMediaBuffer( + fetched.buffer, + fetched.contentType, + "inbound", + maxBytes, + originalName, + ); // Check sticker cache for existing description const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null; @@ -361,7 +383,12 @@ export async function resolveMedia( } const m = - msg.photo?.[msg.photo.length - 1] ?? msg.video ?? msg.document ?? msg.audio ?? msg.voice; + msg.photo?.[msg.photo.length - 1] ?? + msg.video ?? + msg.video_note ?? + msg.document ?? + msg.audio ?? + msg.voice; if (!m?.file_id) return null; const file = await ctx.getFile(); if (!file.file_path) { @@ -377,10 +404,18 @@ export async function resolveMedia( fetchImpl, filePathHint: file.file_path, }); - const saved = await saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes); + const originalName = fetched.fileName ?? file.file_path; + const saved = await saveMediaBuffer( + fetched.buffer, + fetched.contentType, + "inbound", + maxBytes, + originalName, + ); let placeholder = ""; if (msg.photo) placeholder = ""; else if (msg.video) placeholder = ""; + else if (msg.video_note) placeholder = ""; else if (msg.audio || msg.voice) placeholder = ""; return { path: saved.path, contentType: saved.contentType, placeholder }; } diff --git a/src/telegram/download.ts b/src/telegram/download.ts index 1b3c61e22..31f431db0 100644 --- a/src/telegram/download.ts +++ b/src/telegram/download.ts @@ -40,7 +40,7 @@ export async function downloadTelegramFile( filePath: info.file_path, }); // save with inbound subdir - const saved = await saveMediaBuffer(array, mime, "inbound", maxBytes); + const saved = await saveMediaBuffer(array, mime, "inbound", maxBytes, info.file_path); // Ensure extension matches mime if possible if (!saved.contentType && mime) saved.contentType = mime; return saved; diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 77149f9ad..0b35fb445 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -21,6 +21,7 @@ type ChatHost = { basePath: string; hello: GatewayHelloOk | null; chatAvatarUrl: string | null; + refreshSessionsAfterChat: boolean; }; export function isChatBusy(host: ChatHost) { @@ -41,6 +42,14 @@ export function isChatStopCommand(text: string) { ); } +function isChatResetCommand(text: string) { + const trimmed = text.trim(); + if (!trimmed) return false; + const normalized = trimmed.toLowerCase(); + if (normalized === "/new" || normalized === "/reset") return true; + return normalized.startsWith("/new ") || normalized.startsWith("/reset "); +} + export async function handleAbortChat(host: ChatHost) { if (!host.connected) return; host.chatMessage = ""; @@ -71,6 +80,7 @@ async function sendChatMessageNow( attachments?: ChatAttachment[]; previousAttachments?: ChatAttachment[]; restoreAttachments?: boolean; + refreshSessions?: boolean; }, ) { resetToolStream(host as unknown as Parameters[0]); @@ -94,6 +104,9 @@ async function sendChatMessageNow( if (ok && !host.chatRunId) { void flushChatQueue(host); } + if (ok && opts?.refreshSessions) { + host.refreshSessionsAfterChat = true; + } return ok; } @@ -132,6 +145,7 @@ export async function handleSendChat( return; } + const refreshSessions = isChatResetCommand(message); if (messageOverride == null) { host.chatMessage = ""; // Clear attachments when sending @@ -149,13 +163,14 @@ export async function handleSendChat( attachments: hasAttachments ? attachmentsToSend : undefined, previousAttachments: messageOverride == null ? attachments : undefined, restoreAttachments: Boolean(messageOverride && opts?.restoreDraft), + refreshSessions, }); } export async function refreshChat(host: ChatHost) { await Promise.all([ loadChatHistory(host as unknown as MoltbotApp), - loadSessions(host as unknown as MoltbotApp), + loadSessions(host as unknown as MoltbotApp, { activeMinutes: 0 }), refreshChatAvatar(host), ]); scheduleChatScroll(host as unknown as Parameters[0], true); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index b2355709c..ba1df61e1 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -26,6 +26,7 @@ import { import type { MoltbotApp } from "./app"; import type { ExecApprovalRequest } from "./controllers/exec-approval"; import { loadAssistantIdentity } from "./controllers/assistant-identity"; +import { loadSessions } from "./controllers/sessions"; type GatewayHost = { settings: UiSettings; @@ -50,6 +51,7 @@ type GatewayHost = { assistantAgentId: string | null; sessionKey: string; chatRunId: string | null; + refreshSessionsAfterChat: boolean; execApprovalQueue: ExecApprovalRequest[]; execApprovalError: string | null; }; @@ -194,6 +196,12 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { void flushChatQueueForEvent( host as unknown as Parameters[0], ); + if (host.refreshSessionsAfterChat) { + host.refreshSessionsAfterChat = false; + if (state === "final") { + void loadSessions(host as unknown as MoltbotApp, { activeMinutes: 0 }); + } + } } if (state === "final") void loadChatHistory(host as unknown as MoltbotApp); return; diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 71af9d202..cf5214250 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -35,6 +35,9 @@ type LifecycleHost = { export function handleConnected(host: LifecycleHost) { host.basePath = inferBasePath(); + applySettingsFromUrl( + host as unknown as Parameters[0], + ); syncTabWithLocation( host as unknown as Parameters[0], true, @@ -46,9 +49,6 @@ export function handleConnected(host: LifecycleHost) { host as unknown as Parameters[0], ); window.addEventListener("popstate", host.popStateHandler); - applySettingsFromUrl( - host as unknown as Parameters[0], - ); connectGateway(host as unknown as Parameters[0]); startNodesPolling(host as unknown as Parameters[0]); if (host.tab === "logs") { diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 22f8d90db..c2190e1c9 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -5,6 +5,7 @@ import type { AppViewState } from "./app-view-state"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation"; import { icons } from "./icons"; import { loadChatHistory } from "./controllers/chat"; +import { refreshChat } from "./app-chat"; import { syncUrlWithSessionKey } from "./app-settings"; import type { SessionsListResult } from "./types"; import type { ThemeMode } from "./theme"; @@ -39,7 +40,12 @@ export function renderTab(state: AppViewState, tab: Tab) { } export function renderChatControls(state: AppViewState) { - const sessionOptions = resolveSessionOptions(state.sessionKey, state.sessionsResult); + const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult); + const sessionOptions = resolveSessionOptions( + state.sessionKey, + state.sessionsResult, + mainSessionKey, + ); const disableThinkingToggle = state.onboarding; const disableFocusToggle = state.onboarding; const showThinking = state.onboarding ? false : state.settings.chatShowThinking; @@ -87,9 +93,9 @@ export function renderChatControls(state: AppViewState) { ?disabled=${state.chatLoading || !state.connected} @click=${() => { state.resetToolStream(); - void loadChatHistory(state); + void refreshChat(state as unknown as Parameters[0]); }} - title="Refresh chat history" + title="Refresh chat data" > ${refreshIcon} @@ -132,15 +138,47 @@ export function renderChatControls(state: AppViewState) { `; } -function resolveSessionOptions(sessionKey: string, sessions: SessionsListResult | null) { +type SessionDefaultsSnapshot = { + mainSessionKey?: string; + mainKey?: string; +}; + +function resolveMainSessionKey( + hello: AppViewState["hello"], + sessions: SessionsListResult | null, +): string | null { + const snapshot = hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined; + const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim(); + if (mainSessionKey) return mainSessionKey; + const mainKey = snapshot?.sessionDefaults?.mainKey?.trim(); + if (mainKey) return mainKey; + if (sessions?.sessions?.some((row) => row.key === "main")) return "main"; + return null; +} + +function resolveSessionOptions( + sessionKey: string, + sessions: SessionsListResult | null, + mainSessionKey?: string | null, +) { const seen = new Set(); const options: Array<{ key: string; displayName?: string }> = []; + const resolvedMain = + mainSessionKey && sessions?.sessions?.find((s) => s.key === mainSessionKey); const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey); - // Add current session key first - seen.add(sessionKey); - options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName }); + // Add main session key first + if (mainSessionKey) { + seen.add(mainSessionKey); + options.push({ key: mainSessionKey, displayName: resolvedMain?.displayName }); + } + + // Add current session key next + if (!seen.has(sessionKey)) { + seen.add(sessionKey); + options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName }); + } // Add sessions from the result if (sessions?.sessions) { diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 26f4a5836..50ffcdf76 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -258,6 +258,7 @@ export class MoltbotApp extends LitElement { private logsScrollFrame: number | null = null; private toolStreamById = new Map(); private toolStreamOrder: string[] = []; + refreshSessionsAfterChat = false; basePath = ""; private popStateHandler = () => onPopStateInternal( diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index 5c5077037..7e87f1911 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -14,18 +14,29 @@ export type SessionsState = { sessionsIncludeUnknown: boolean; }; -export async function loadSessions(state: SessionsState) { +export async function loadSessions( + state: SessionsState, + overrides?: { + activeMinutes?: number; + limit?: number; + includeGlobal?: boolean; + includeUnknown?: boolean; + }, +) { if (!state.client || !state.connected) return; if (state.sessionsLoading) return; state.sessionsLoading = true; state.sessionsError = null; try { + const includeGlobal = overrides?.includeGlobal ?? state.sessionsIncludeGlobal; + const includeUnknown = overrides?.includeUnknown ?? state.sessionsIncludeUnknown; + const activeMinutes = + overrides?.activeMinutes ?? toNumber(state.sessionsFilterActive, 0); + const limit = overrides?.limit ?? toNumber(state.sessionsFilterLimit, 0); const params: Record = { - includeGlobal: state.sessionsIncludeGlobal, - includeUnknown: state.sessionsIncludeUnknown, + includeGlobal, + includeUnknown, }; - const activeMinutes = toNumber(state.sessionsFilterActive, 0); - const limit = toNumber(state.sessionsFilterLimit, 0); if (activeMinutes > 0) params.activeMinutes = activeMinutes; if (limit > 0) params.limit = limit; const res = (await state.client.request("sessions.list", params)) as