From c415ccaed5180f8ff8e871584515e002f3fa42e2 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 21 Jan 2026 13:10:31 -0600 Subject: [PATCH] feat(sessions): add channelIdleMinutes config for per-channel session idle durations (#1353) * feat(sessions): add channelIdleMinutes config for per-channel session idle durations Add new `channelIdleMinutes` config option to allow different session idle timeouts per channel. For example, Discord sessions can now be configured to last 7 days (10080 minutes) while other channels use shorter defaults. Config example: sessions: channelIdleMinutes: discord: 10080 # 7 days The channel-specific idle is passed as idleMinutesOverride to the existing resolveSessionResetPolicy, integrating cleanly with the new reset policy architecture. * fix * feat: add per-channel session reset overrides (#1353) (thanks @cash-echo-bot) --------- Co-authored-by: Cash Williams Co-authored-by: Peter Steinberger --- CHANGELOG.md | 2 +- docs/concepts/session.md | 4 ++ docs/gateway/configuration-examples.md | 4 +- docs/gateway/configuration.md | 5 ++- .../pi-embedded-runner/run/attempt.test.ts | 4 +- src/auto-reply/reply/session.test.ts | 39 +++++++++++++++++++ src/auto-reply/reply/session.ts | 17 +++++++- src/commands/agent/session.ts | 11 +++++- src/config/sessions/reset.ts | 30 +++++++++----- src/config/types.base.ts | 3 +- src/config/zod-schema.session.ts | 2 +- src/infra/fetch.ts | 5 ++- src/web/auto-reply/session-snapshot.test.ts | 14 ++++--- src/web/auto-reply/session-snapshot.ts | 11 ++++-- 14 files changed, 123 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e09aa25b9..a55377fff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Docs: https://docs.clawd.bot - Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution. - CLI: flatten node service commands under `clawdbot node` and remove `service node` docs. - CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability. -- Queue: allow per-channel debounce overrides and plugin defaults. (#1190) Thanks @cheeeee. +- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot. ### Fixes - Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell. diff --git a/docs/concepts/session.md b/docs/concepts/session.md index c88e2ddfe..48c85af09 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -60,6 +60,7 @@ the workspace is writable. See [Memory](/concepts/memory) and - Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session. - Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility. - Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector). +- Per-channel overrides (optional): `resetByChannel` overrides the reset policy for a channel (applies to all session types for that channel and takes precedence over `reset`/`resetByType`). - Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. `/new ` accepts a model alias, `provider/model`, or provider name (fuzzy match) to set the new session model. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset. - Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them. - Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse). @@ -109,6 +110,9 @@ Send these as standalone messages so they register. dm: { mode: "idle", idleMinutes: 240 }, group: { mode: "idle", idleMinutes: 120 } }, + resetByChannel: { + discord: { mode: "idle", idleMinutes: 10080 } + }, resetTriggers: ["/new", "/reset"], store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json", mainKey: "main", diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index 793ece412..91b40beac 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -151,7 +151,9 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number. atHour: 4, idleMinutes: 60 }, - heartbeatIdleMinutes: 120, + resetByChannel: { + discord: { mode: "idle", idleMinutes: 10080 } + }, resetTriggers: ["/new", "/reset"], store: "~/.clawdbot/agents/default/sessions/sessions.json", typingIntervalSeconds: 5, diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index a17308344..9eddda3f8 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2453,6 +2453,9 @@ Controls session scoping, reset policy, reset triggers, and where the session st dm: { mode: "idle", idleMinutes: 240 }, group: { mode: "idle", idleMinutes: 120 } }, + resetByChannel: { + discord: { mode: "idle", idleMinutes: 10080 } + }, resetTriggers: ["/new", "/reset"], // Default is already per-agent under ~/.clawdbot/agents//sessions/sessions.json // You can override with {agentId} templating: @@ -2488,7 +2491,7 @@ Fields: - `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins. - `resetByType`: per-session overrides for `dm`, `group`, and `thread`. - If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility. -- `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled). +- `resetByChannel`: channel-specific reset policy overrides (keyed by channel id, applies to all session types for that channel; overrides `reset`/`resetByType`). - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5). - `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. - `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index d87cabd1d..af1d97828 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -36,7 +36,9 @@ describe("injectHistoryImagesIntoMessages", () => { const didMutate = injectHistoryImagesIntoMessages(messages, new Map([[0, [image]]])); expect(didMutate).toBe(false); - expect((messages[0]?.content as unknown[]).length).toBe(2); + const content = messages[0]?.content as unknown[] | undefined; + expect(content).toBeDefined(); + expect(content).toHaveLength(2); }); it("ignores non-user messages and out-of-range indices", () => { diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index dca2ae1c8..af548ecb0 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -436,3 +436,42 @@ describe("initSessionState reset policy", () => { } }); }); + +describe("initSessionState channel reset overrides", () => { + it("uses channel-specific reset policy when configured", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-channel-idle-")); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:discord:dm:123"; + const sessionId = "session-override"; + const updatedAt = Date.now() - (10080 - 1) * 60_000; + + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId, + updatedAt, + }, + }); + + const cfg = { + session: { + store: storePath, + idleMinutes: 60, + resetByType: { dm: { mode: "idle", idleMinutes: 10 } }, + resetByChannel: { discord: { mode: "idle", idleMinutes: 10080 } }, + }, + } as ClawdbotConfig; + + const result = await initSessionState({ + ctx: { + Body: "Hello", + SessionKey: sessionKey, + Provider: "discord", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(false); + expect(result.sessionEntry.sessionId).toBe(sessionId); + }); +}); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 590b9b7d6..da8ca8acf 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -11,6 +11,7 @@ import { evaluateSessionFreshness, type GroupKeyResolution, loadSessionStore, + resolveChannelResetConfig, resolveThreadFlag, resolveSessionResetPolicy, resolveSessionResetType, @@ -106,6 +107,7 @@ export async function initSessionState(params: { sessionKey: sessionCtxForState.SessionKey, config: cfg, }); + const groupResolution = resolveGroupSessionKey(sessionCtxForState) ?? undefined; const resetTriggers = sessionCfg?.resetTriggers?.length ? sessionCfg.resetTriggers : DEFAULT_RESET_TRIGGERS; @@ -129,7 +131,6 @@ export async function initSessionState(params: { let persistedModelOverride: string | undefined; let persistedProviderOverride: string | undefined; - const groupResolution = resolveGroupSessionKey(sessionCtxForState) ?? undefined; const normalizedChatType = normalizeChatType(ctx.ChatType); const isGroup = normalizedChatType != null && normalizedChatType !== "direct" ? true : Boolean(groupResolution); @@ -195,7 +196,19 @@ export async function initSessionState(params: { parentSessionKey: ctx.ParentSessionKey, }); const resetType = resolveSessionResetType({ sessionKey, isGroup, isThread }); - const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType }); + const channelReset = resolveChannelResetConfig({ + sessionCfg, + channel: + groupResolution?.channel ?? + (ctx.OriginatingChannel as string | undefined) ?? + ctx.Surface ?? + ctx.Provider, + }); + const resetPolicy = resolveSessionResetPolicy({ + sessionCfg, + resetType, + resetOverride: channelReset, + }); const freshEntry = entry ? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }).fresh : false; diff --git a/src/commands/agent/session.ts b/src/commands/agent/session.ts index ab9da524d..60860f0ec 100644 --- a/src/commands/agent/session.ts +++ b/src/commands/agent/session.ts @@ -12,6 +12,7 @@ import { evaluateSessionFreshness, loadSessionStore, resolveAgentIdFromSessionKey, + resolveChannelResetConfig, resolveExplicitAgentSessionKey, resolveSessionResetPolicy, resolveSessionResetType, @@ -99,7 +100,15 @@ export function resolveSession(opts: { const sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined; const resetType = resolveSessionResetType({ sessionKey }); - const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType }); + const channelReset = resolveChannelResetConfig({ + sessionCfg, + channel: sessionEntry?.lastChannel ?? sessionEntry?.channel, + }); + const resetPolicy = resolveSessionResetPolicy({ + sessionCfg, + resetType, + resetOverride: channelReset, + }); const fresh = sessionEntry ? evaluateSessionFreshness({ updatedAt: sessionEntry.updatedAt, now, policy: resetPolicy }) .fresh diff --git a/src/config/sessions/reset.ts b/src/config/sessions/reset.ts index 3355e4052..45d54ad4f 100644 --- a/src/config/sessions/reset.ts +++ b/src/config/sessions/reset.ts @@ -1,5 +1,6 @@ -import type { SessionConfig } from "../types.base.js"; +import type { SessionConfig, SessionResetConfig } from "../types.base.js"; import { DEFAULT_IDLE_MINUTES } from "./types.js"; +import { normalizeMessageChannel } from "../../utils/message-channel.js"; export type SessionResetMode = "daily" | "idle"; export type SessionResetType = "dm" | "group" | "thread"; @@ -67,13 +68,13 @@ export function resolveDailyResetAtMs(now: number, atHour: number): number { export function resolveSessionResetPolicy(params: { sessionCfg?: SessionConfig; resetType: SessionResetType; - idleMinutesOverride?: number; + resetOverride?: SessionResetConfig; }): SessionResetPolicy { const sessionCfg = params.sessionCfg; - const baseReset = sessionCfg?.reset; - const typeReset = sessionCfg?.resetByType?.[params.resetType]; + const baseReset = params.resetOverride ?? sessionCfg?.reset; + const typeReset = params.resetOverride ? undefined : sessionCfg?.resetByType?.[params.resetType]; const hasExplicitReset = Boolean(baseReset || sessionCfg?.resetByType); - const legacyIdleMinutes = sessionCfg?.idleMinutes; + const legacyIdleMinutes = params.resetOverride ? undefined : sessionCfg?.idleMinutes; const mode = typeReset?.mode ?? baseReset?.mode ?? @@ -81,11 +82,7 @@ export function resolveSessionResetPolicy(params: { const atHour = normalizeResetAtHour( typeReset?.atHour ?? baseReset?.atHour ?? DEFAULT_RESET_AT_HOUR, ); - const idleMinutesRaw = - params.idleMinutesOverride ?? - typeReset?.idleMinutes ?? - baseReset?.idleMinutes ?? - legacyIdleMinutes; + const idleMinutesRaw = typeReset?.idleMinutes ?? baseReset?.idleMinutes ?? legacyIdleMinutes; let idleMinutes: number | undefined; if (idleMinutesRaw != null) { @@ -100,6 +97,19 @@ export function resolveSessionResetPolicy(params: { return { mode, atHour, idleMinutes }; } +export function resolveChannelResetConfig(params: { + sessionCfg?: SessionConfig; + channel?: string | null; +}): SessionResetConfig | undefined { + const resetByChannel = params.sessionCfg?.resetByChannel; + if (!resetByChannel) return undefined; + const normalized = normalizeMessageChannel(params.channel); + const fallback = params.channel?.trim().toLowerCase(); + const key = normalized ?? fallback; + if (!key) return undefined; + return resetByChannel[key] ?? resetByChannel[key.toLowerCase()]; +} + export function evaluateSessionFreshness(params: { updatedAt: number; now: number; diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 0796fa64c..8d7613936 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -77,9 +77,10 @@ export type SessionConfig = { identityLinks?: Record; resetTriggers?: string[]; idleMinutes?: number; - heartbeatIdleMinutes?: number; reset?: SessionResetConfig; resetByType?: SessionResetByTypeConfig; + /** Channel-specific reset overrides (e.g. { discord: { mode: "idle", idleMinutes: 10080 } }). */ + resetByChannel?: Record; store?: string; typingIntervalSeconds?: number; typingMode?: TypingMode; diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 6a8ad114a..6cc3084d6 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -24,7 +24,6 @@ export const SessionSchema = z identityLinks: z.record(z.string(), z.array(z.string())).optional(), resetTriggers: z.array(z.string()).optional(), idleMinutes: z.number().int().positive().optional(), - heartbeatIdleMinutes: z.number().int().positive().optional(), reset: SessionResetConfigSchema.optional(), resetByType: z .object({ @@ -34,6 +33,7 @@ export const SessionSchema = z }) .strict() .optional(), + resetByChannel: z.record(z.string(), SessionResetConfigSchema).optional(), store: z.string().optional(), typingIntervalSeconds: z.number().int().positive().optional(), typingMode: z diff --git a/src/infra/fetch.ts b/src/infra/fetch.ts index 9cd10c25f..2f9993c7a 100644 --- a/src/infra/fetch.ts +++ b/src/infra/fetch.ts @@ -31,8 +31,11 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch return response; }) as FetchWithPreconnect; + const fetchWithPreconnect = fetchImpl as FetchWithPreconnect; wrapped.preconnect = - typeof fetchImpl.preconnect === "function" ? fetchImpl.preconnect.bind(fetchImpl) : () => {}; + typeof fetchWithPreconnect.preconnect === "function" + ? fetchWithPreconnect.preconnect.bind(fetchWithPreconnect) + : () => {}; return Object.assign(wrapped, fetchImpl); } diff --git a/src/web/auto-reply/session-snapshot.test.ts b/src/web/auto-reply/session-snapshot.test.ts index 82fa5dbf1..e6cf013c0 100644 --- a/src/web/auto-reply/session-snapshot.test.ts +++ b/src/web/auto-reply/session-snapshot.test.ts @@ -8,7 +8,7 @@ import { saveSessionStore } from "../../config/sessions.js"; import { getSessionSnapshot } from "./session-snapshot.js"; describe("getSessionSnapshot", () => { - it("uses heartbeat idle override while daily reset still applies", async () => { + it("uses channel reset overrides when configured", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); try { @@ -20,6 +20,7 @@ describe("getSessionSnapshot", () => { [sessionKey]: { sessionId: "snapshot-session", updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(), + lastChannel: "whatsapp", }, }); @@ -27,7 +28,9 @@ describe("getSessionSnapshot", () => { session: { store: storePath, reset: { mode: "daily", atHour: 4, idleMinutes: 240 }, - heartbeatIdleMinutes: 30, + resetByChannel: { + whatsapp: { mode: "idle", idleMinutes: 360 }, + }, }, } as Parameters[0]; @@ -35,9 +38,10 @@ describe("getSessionSnapshot", () => { sessionKey, }); - expect(snapshot.resetPolicy.idleMinutes).toBe(30); - expect(snapshot.fresh).toBe(false); - expect(snapshot.dailyResetAt).toBe(new Date(2026, 0, 18, 4, 0, 0).getTime()); + expect(snapshot.resetPolicy.mode).toBe("idle"); + expect(snapshot.resetPolicy.idleMinutes).toBe(360); + expect(snapshot.fresh).toBe(true); + expect(snapshot.dailyResetAt).toBeUndefined(); } finally { vi.useRealTimers(); } diff --git a/src/web/auto-reply/session-snapshot.ts b/src/web/auto-reply/session-snapshot.ts index 051c29972..12a5619e6 100644 --- a/src/web/auto-reply/session-snapshot.ts +++ b/src/web/auto-reply/session-snapshot.ts @@ -2,6 +2,7 @@ import type { loadConfig } from "../../config/config.js"; import { evaluateSessionFreshness, loadSessionStore, + resolveChannelResetConfig, resolveThreadFlag, resolveSessionResetPolicy, resolveSessionResetType, @@ -13,7 +14,7 @@ import { normalizeMainKey } from "../../routing/session-key.js"; export function getSessionSnapshot( cfg: ReturnType, from: string, - isHeartbeat = false, + _isHeartbeat = false, ctx?: { sessionKey?: string | null; isGroup?: boolean; @@ -34,6 +35,7 @@ export function getSessionSnapshot( ); const store = loadSessionStore(resolveStorePath(sessionCfg?.store)); const entry = store[key]; + const isThread = resolveThreadFlag({ sessionKey: key, messageThreadId: ctx?.messageThreadId ?? null, @@ -42,11 +44,14 @@ export function getSessionSnapshot( parentSessionKey: ctx?.parentSessionKey ?? null, }); const resetType = resolveSessionResetType({ sessionKey: key, isGroup: ctx?.isGroup, isThread }); - const idleMinutesOverride = isHeartbeat ? sessionCfg?.heartbeatIdleMinutes : undefined; + const channelReset = resolveChannelResetConfig({ + sessionCfg, + channel: entry?.lastChannel ?? entry?.channel, + }); const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType, - idleMinutesOverride, + resetOverride: channelReset, }); const now = Date.now(); const freshness = entry