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 <cashwilliams@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 <model>` 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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/<agentId>/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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -77,9 +77,10 @@ export type SessionConfig = {
|
||||
identityLinks?: Record<string, string[]>;
|
||||
resetTriggers?: string[];
|
||||
idleMinutes?: number;
|
||||
heartbeatIdleMinutes?: number;
|
||||
reset?: SessionResetConfig;
|
||||
resetByType?: SessionResetByTypeConfig;
|
||||
/** Channel-specific reset overrides (e.g. { discord: { mode: "idle", idleMinutes: 10080 } }). */
|
||||
resetByChannel?: Record<string, SessionResetConfig>;
|
||||
store?: string;
|
||||
typingIntervalSeconds?: number;
|
||||
typingMode?: TypingMode;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<typeof getSessionSnapshot>[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();
|
||||
}
|
||||
|
||||
@@ -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<typeof loadConfig>,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user