From 367826f6e4e91a1d7e73f8ee15e1ff409195ebb6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 06:37:30 +0000 Subject: [PATCH] feat(session): add daily reset policy Co-authored-by: Austin Mudd --- CHANGELOG.md | 1 + docs/concepts/session.md | 21 ++- docs/gateway/configuration-examples.md | 6 +- docs/gateway/configuration.md | 30 +++- docs/gateway/troubleshooting.md | 8 +- .../session-management-compaction.md | 3 +- docs/start/clawd.md | 6 +- docs/start/faq.md | 13 +- src/auto-reply/reply/session.test.ts | 140 +++++++++++++++++- src/auto-reply/reply/session.ts | 20 ++- src/commands/agent/session.ts | 12 +- src/config/sessions.ts | 1 + src/config/sessions/reset.ts | 116 +++++++++++++++ src/config/types.base.ts | 16 ++ src/config/zod-schema.session.ts | 32 ++++ src/web/auto-reply/heartbeat-runner.ts | 6 +- src/web/auto-reply/session-snapshot.ts | 32 ++-- 17 files changed, 425 insertions(+), 38 deletions(-) create mode 100644 src/config/sessions/reset.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c7267f72b..e928c80cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.clawd.bot - macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle. - macOS: add approvals socket UI server + node exec lifecycle events. - Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639. +- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911. - Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals ### Fixes diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 3608e0a33..3a586f860 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -54,8 +54,12 @@ the workspace is writable. See [Memory](/concepts/memory) and - Webhooks: `hook:` (unless explicitly set by the hook) - Node bridge runs: `node-` -## Lifecyle -- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message. +## Lifecycle +- Reset policy: sessions are reused until they expire, and expiry is evaluated on the next inbound message. +- Daily reset: defaults to **4:00 AM local time on the gateway host**. A session is stale once its last update is earlier than the most recent daily reset time. +- 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). - Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. 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). @@ -93,7 +97,18 @@ Send these as standalone messages so they register. identityLinks: { alice: ["telegram:123456789", "discord:987654321012345678"] }, - idleMinutes: 120, + reset: { + // Defaults: mode=daily, atHour=4 (gateway host local time). + // If you also set idleMinutes, whichever expires first wins. + mode: "daily", + atHour: 4, + idleMinutes: 120 + }, + resetByType: { + thread: { mode: "daily", atHour: 4 }, + dm: { mode: "idle", idleMinutes: 240 }, + group: { mode: "idle", idleMinutes: 120 } + }, 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 cf95229c3..1075064b6 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -146,7 +146,11 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number. // Session behavior session: { scope: "per-sender", - idleMinutes: 60, + reset: { + mode: "daily", + atHour: 4, + idleMinutes: 60 + }, heartbeatIdleMinutes: 120, resetTriggers: ["/new", "/reset"], store: "~/.clawdbot/agents/default/sessions/sessions.json", diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index b0b7da1e9..0b3d9b830 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2416,7 +2416,7 @@ Notes: ### `session` -Controls session scoping, idle expiry, reset triggers, and where the session store is written. +Controls session scoping, reset policy, reset triggers, and where the session store is written. ```json5 { @@ -2426,7 +2426,16 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto identityLinks: { alice: ["telegram:123456789", "discord:987654321012345678"] }, - idleMinutes: 60, + reset: { + mode: "daily", + atHour: 4, + idleMinutes: 60 + }, + resetByType: { + thread: { mode: "daily", atHour: 4 }, + dm: { mode: "idle", idleMinutes: 240 }, + group: { mode: "idle", idleMinutes: 120 } + }, resetTriggers: ["/new", "/reset"], // Default is already per-agent under ~/.clawdbot/agents//sessions/sessions.json // You can override with {agentId} templating: @@ -2437,12 +2446,12 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto // Max ping-pong reply turns between requester/target (0–5). maxPingPongTurns: 5 }, - sendPolicy: { - rules: [ + sendPolicy: { + rules: [ { action: "deny", match: { channel: "discord", chatType: "group" } } - ], - default: "allow" - } + ], + default: "allow" + } } } ``` @@ -2456,6 +2465,13 @@ Fields: - `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes). - `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`. - Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`. +- `reset`: primary reset policy. Defaults to daily resets at 4:00 AM local time on the gateway host. + - `mode`: `daily` or `idle` (default: `daily` when `reset` is present). + - `atHour`: local hour (0-23) for the daily reset boundary. + - `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). - `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/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index f0021bb47..ebe52050a 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -239,11 +239,15 @@ Known issue: When you send an image with ONLY a mention (no other text), WhatsAp ls -la ~/.clawdbot/agents//sessions/ ``` -**Check 2:** Is `idleMinutes` too short? +**Check 2:** Is the reset window too short? ```json { "session": { - "idleMinutes": 10080 // 7 days + "reset": { + "mode": "daily", + "atHour": 4, + "idleMinutes": 10080 // 7 days + } } } ``` diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 776441227..948e48591 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -82,7 +82,8 @@ Each `sessionKey` points at a current `sessionId` (the transcript file that cont Rules of thumb: - **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`. -- **Idle expiry** (`session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. +- **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary. +- **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins. Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`. diff --git a/docs/start/clawd.md b/docs/start/clawd.md index f57b8dc0d..0ea039cc2 100644 --- a/docs/start/clawd.md +++ b/docs/start/clawd.md @@ -160,7 +160,11 @@ Example: session: { scope: "per-sender", resetTriggers: ["/new", "/reset"], - idleMinutes: 10080 + reset: { + mode: "daily", + atHour: 4, + idleMinutes: 10080 + } } } ``` diff --git a/docs/start/faq.md b/docs/start/faq.md index 49c2a527a..9f185b0e4 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -880,14 +880,19 @@ Send `/new` or `/reset` as a standalone message. See [Session management](/conce ### Do sessions reset automatically if I never send `/new`? -Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next** -message starts a fresh session id for that chat key. This does not delete -transcripts — it just starts a new session. +Yes. By default sessions reset daily at **4:00 AM local time** on the gateway host. +You can also add an idle window; when both daily and idle resets are configured, +whichever expires first starts a new session id on the next message. This does +not delete transcripts — it just starts a new session. ```json5 { session: { - idleMinutes: 240 + reset: { + mode: "daily", + atHour: 4, + idleMinutes: 240 + } } } ``` diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 2a3a7d132..d6ad30ee2 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../../config/config.js"; import { saveSessionStore } from "../../config/sessions.js"; @@ -170,3 +170,141 @@ describe("initSessionState RawBody", () => { expect(result.triggerBodyNormalized).toBe("/status"); }); }); + +describe("initSessionState reset policy", () => { + it("defaults to daily reset at 4am local time", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); + try { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-daily-")); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:S1"; + const existingSessionId = "daily-session-id"; + + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), + }, + }); + + const cfg = { session: { store: storePath } } as ClawdbotConfig; + const result = await initSessionState({ + ctx: { Body: "hello", SessionKey: sessionKey }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + } finally { + vi.useRealTimers(); + } + }); + + it("expires sessions when idle timeout wins over daily reset", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0)); + try { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-idle-")); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:S2"; + const existingSessionId = "idle-session-id"; + + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(), + }, + }); + + const cfg = { + session: { + store: storePath, + reset: { mode: "daily", atHour: 4, idleMinutes: 30 }, + }, + } as ClawdbotConfig; + const result = await initSessionState({ + ctx: { Body: "hello", SessionKey: sessionKey }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + } finally { + vi.useRealTimers(); + } + }); + + it("uses per-type overrides for thread sessions", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); + try { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-thread-")); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:slack:channel:C1:thread:123"; + const existingSessionId = "thread-session-id"; + + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), + }, + }); + + const cfg = { + session: { + store: storePath, + reset: { mode: "daily", atHour: 4 }, + resetByType: { thread: { mode: "idle", idleMinutes: 180 } }, + }, + } as ClawdbotConfig; + const result = await initSessionState({ + ctx: { Body: "reply", SessionKey: sessionKey, ThreadLabel: "Slack thread" }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe(existingSessionId); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps legacy idleMinutes behavior without reset config", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); + try { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-legacy-")); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:S3"; + const existingSessionId = "legacy-session-id"; + + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(), + }, + }); + + const cfg = { + session: { + store: storePath, + idleMinutes: 240, + }, + } as ClawdbotConfig; + const result = await initSessionState({ + ctx: { Body: "hello", SessionKey: sessionKey }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe(existingSessionId); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 7215c8979..bef1b2f1b 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -6,11 +6,14 @@ import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { - DEFAULT_IDLE_MINUTES, DEFAULT_RESET_TRIGGERS, deriveSessionMetaPatch, + evaluateSessionFreshness, + isThreadSessionKey, type GroupKeyResolution, loadSessionStore, + resolveSessionResetPolicy, + resolveSessionResetType, resolveGroupSessionKey, resolveSessionFilePath, resolveSessionKey, @@ -105,7 +108,6 @@ export async function initSessionState(params: { const resetTriggers = sessionCfg?.resetTriggers?.length ? sessionCfg.resetTriggers : DEFAULT_RESET_TRIGGERS; - const idleMinutes = Math.max(sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1); const sessionScope = sessionCfg?.scope ?? "per-sender"; const storePath = resolveStorePath(sessionCfg?.store, { agentId }); @@ -170,8 +172,18 @@ export async function initSessionState(params: { sessionKey = resolveSessionKey(sessionScope, sessionCtxForState, mainKey); const entry = sessionStore[sessionKey]; const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined; - const idleMs = idleMinutes * 60_000; - const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs; + const now = Date.now(); + const isThread = + ctx.MessageThreadId != null || + Boolean(ctx.ThreadLabel?.trim()) || + Boolean(ctx.ThreadStarterBody?.trim()) || + Boolean(ctx.ParentSessionKey?.trim()) || + isThreadSessionKey(sessionKey); + const resetType = resolveSessionResetType({ sessionKey, isGroup, isThread }); + const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType }); + const freshEntry = entry + ? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }).fresh + : false; if (!isNewSession && freshEntry) { sessionId = entry.sessionId; diff --git a/src/commands/agent/session.ts b/src/commands/agent/session.ts index c1ac0a853..078148174 100644 --- a/src/commands/agent/session.ts +++ b/src/commands/agent/session.ts @@ -9,9 +9,11 @@ import { } from "../../auto-reply/thinking.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { - DEFAULT_IDLE_MINUTES, + evaluateSessionFreshness, loadSessionStore, resolveAgentIdFromSessionKey, + resolveSessionResetPolicy, + resolveSessionResetType, resolveSessionKey, resolveStorePath, type SessionEntry, @@ -38,8 +40,6 @@ export function resolveSession(opts: { const sessionCfg = opts.cfg.session; const scope = sessionCfg?.scope ?? "per-sender"; const mainKey = normalizeMainKey(sessionCfg?.mainKey); - const idleMinutes = Math.max(sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1); - const idleMs = idleMinutes * 60_000; const explicitSessionKey = opts.sessionKey?.trim(); const storeAgentId = resolveAgentIdFromSessionKey(explicitSessionKey); const storePath = resolveStorePath(sessionCfg?.store, { @@ -68,7 +68,11 @@ export function resolveSession(opts: { } } - const fresh = sessionEntry && sessionEntry.updatedAt >= now - idleMs; + const resetType = resolveSessionResetType({ sessionKey }); + const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType }); + const fresh = sessionEntry + ? evaluateSessionFreshness({ updatedAt: sessionEntry.updatedAt, now, policy: resetPolicy }).fresh + : false; const sessionId = opts.sessionId?.trim() || (fresh ? sessionEntry?.sessionId : undefined) || crypto.randomUUID(); const isNewSession = !fresh && !opts.sessionId; diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 3fa3014d5..20de39409 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -2,6 +2,7 @@ export * from "./sessions/group.js"; export * from "./sessions/metadata.js"; export * from "./sessions/main-session.js"; export * from "./sessions/paths.js"; +export * from "./sessions/reset.js"; export * from "./sessions/session-key.js"; export * from "./sessions/store.js"; export * from "./sessions/types.js"; diff --git a/src/config/sessions/reset.ts b/src/config/sessions/reset.ts new file mode 100644 index 000000000..eb40b659c --- /dev/null +++ b/src/config/sessions/reset.ts @@ -0,0 +1,116 @@ +import type { SessionConfig } from "../types.base.js"; +import { DEFAULT_IDLE_MINUTES } from "./types.js"; + +export type SessionResetMode = "daily" | "idle"; +export type SessionResetType = "dm" | "group" | "thread"; + +export type SessionResetPolicy = { + mode: SessionResetMode; + atHour: number; + idleMinutes?: number; +}; + +export type SessionFreshness = { + fresh: boolean; + dailyResetAt?: number; + idleExpiresAt?: number; +}; + +export const DEFAULT_RESET_MODE: SessionResetMode = "daily"; +export const DEFAULT_RESET_AT_HOUR = 4; + +const THREAD_SESSION_MARKERS = [":thread:", ":topic:"]; +const GROUP_SESSION_MARKERS = [":group:", ":channel:"]; + +export function isThreadSessionKey(sessionKey?: string | null): boolean { + const normalized = (sessionKey ?? "").toLowerCase(); + if (!normalized) return false; + return THREAD_SESSION_MARKERS.some((marker) => normalized.includes(marker)); +} + +export function resolveSessionResetType(params: { + sessionKey?: string | null; + isGroup?: boolean; + isThread?: boolean; +}): SessionResetType { + if (params.isThread || isThreadSessionKey(params.sessionKey)) return "thread"; + if (params.isGroup) return "group"; + const normalized = (params.sessionKey ?? "").toLowerCase(); + if (GROUP_SESSION_MARKERS.some((marker) => normalized.includes(marker))) return "group"; + return "dm"; +} + +export function resolveDailyResetAtMs(now: number, atHour: number): number { + const normalizedAtHour = normalizeResetAtHour(atHour); + const resetAt = new Date(now); + resetAt.setHours(normalizedAtHour, 0, 0, 0); + if (now < resetAt.getTime()) { + resetAt.setDate(resetAt.getDate() - 1); + } + return resetAt.getTime(); +} + +export function resolveSessionResetPolicy(params: { + sessionCfg?: SessionConfig; + resetType: SessionResetType; + idleMinutesOverride?: number; +}): SessionResetPolicy { + const sessionCfg = params.sessionCfg; + const baseReset = sessionCfg?.reset; + const typeReset = sessionCfg?.resetByType?.[params.resetType]; + const hasExplicitReset = Boolean(baseReset || sessionCfg?.resetByType); + const legacyIdleMinutes = sessionCfg?.idleMinutes; + const mode = + typeReset?.mode ?? + baseReset?.mode ?? + (!hasExplicitReset && legacyIdleMinutes != null ? "idle" : DEFAULT_RESET_MODE); + const atHour = normalizeResetAtHour(typeReset?.atHour ?? baseReset?.atHour ?? DEFAULT_RESET_AT_HOUR); + const idleMinutesRaw = + params.idleMinutesOverride ?? + typeReset?.idleMinutes ?? + baseReset?.idleMinutes ?? + legacyIdleMinutes; + + let idleMinutes: number | undefined; + if (idleMinutesRaw != null) { + const normalized = Math.floor(idleMinutesRaw); + if (Number.isFinite(normalized)) { + idleMinutes = Math.max(normalized, 1); + } + } else if (mode === "idle") { + idleMinutes = DEFAULT_IDLE_MINUTES; + } + + return { mode, atHour, idleMinutes }; +} + +export function evaluateSessionFreshness(params: { + updatedAt: number; + now: number; + policy: SessionResetPolicy; +}): SessionFreshness { + const dailyResetAt = + params.policy.mode === "daily" + ? resolveDailyResetAtMs(params.now, params.policy.atHour) + : undefined; + const idleExpiresAt = + params.policy.idleMinutes != null + ? params.updatedAt + params.policy.idleMinutes * 60_000 + : undefined; + const staleDaily = dailyResetAt != null && params.updatedAt < dailyResetAt; + const staleIdle = idleExpiresAt != null && params.now > idleExpiresAt; + return { + fresh: !(staleDaily || staleIdle), + dailyResetAt, + idleExpiresAt, + }; +} + +function normalizeResetAtHour(value: number | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_RESET_AT_HOUR; + const normalized = Math.floor(value); + if (!Number.isFinite(normalized)) return DEFAULT_RESET_AT_HOUR; + if (normalized < 0) return 0; + if (normalized > 23) return 23; + return normalized; +} diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 98cfa0d4b..1f6c3ec6b 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -55,6 +55,20 @@ export type SessionSendPolicyConfig = { rules?: SessionSendPolicyRule[]; }; +export type SessionResetMode = "daily" | "idle"; +export type SessionResetConfig = { + mode?: SessionResetMode; + /** Local hour (0-23) for the daily reset boundary. */ + atHour?: number; + /** Sliding idle window (minutes). When set with daily mode, whichever expires first wins. */ + idleMinutes?: number; +}; +export type SessionResetByTypeConfig = { + dm?: SessionResetConfig; + group?: SessionResetConfig; + thread?: SessionResetConfig; +}; + export type SessionConfig = { scope?: SessionScope; /** DM session scoping (default: "main"). */ @@ -64,6 +78,8 @@ export type SessionConfig = { resetTriggers?: string[]; idleMinutes?: number; heartbeatIdleMinutes?: number; + reset?: SessionResetConfig; + resetByType?: SessionResetByTypeConfig; store?: string; typingIntervalSeconds?: number; typingMode?: TypingMode; diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 34965def9..5b70e83bb 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -17,6 +17,38 @@ export const SessionSchema = z resetTriggers: z.array(z.string()).optional(), idleMinutes: z.number().int().positive().optional(), heartbeatIdleMinutes: z.number().int().positive().optional(), + reset: z + .object({ + mode: z.union([z.literal("daily"), z.literal("idle")]).optional(), + atHour: z.number().int().min(0).max(23).optional(), + idleMinutes: z.number().int().positive().optional(), + }) + .optional(), + resetByType: z + .object({ + dm: z + .object({ + mode: z.union([z.literal("daily"), z.literal("idle")]).optional(), + atHour: z.number().int().min(0).max(23).optional(), + idleMinutes: z.number().int().positive().optional(), + }) + .optional(), + group: z + .object({ + mode: z.union([z.literal("daily"), z.literal("idle")]).optional(), + atHour: z.number().int().min(0).max(23).optional(), + idleMinutes: z.number().int().positive().optional(), + }) + .optional(), + thread: z + .object({ + mode: z.union([z.literal("daily"), z.literal("idle")]).optional(), + atHour: z.number().int().min(0).max(23).optional(), + idleMinutes: z.number().int().positive().optional(), + }) + .optional(), + }) + .optional(), store: z.string().optional(), typingIntervalSeconds: z.number().int().positive().optional(), typingMode: z diff --git a/src/web/auto-reply/heartbeat-runner.ts b/src/web/auto-reply/heartbeat-runner.ts index e6ee3d1d9..34becfb80 100644 --- a/src/web/auto-reply/heartbeat-runner.ts +++ b/src/web/auto-reply/heartbeat-runner.ts @@ -89,7 +89,11 @@ export async function runWebHeartbeatOnce(opts: { sessionKey: sessionSnapshot.key, sessionId: sessionId ?? sessionSnapshot.entry?.sessionId ?? null, sessionFresh: sessionSnapshot.fresh, - idleMinutes: sessionSnapshot.idleMinutes, + resetMode: sessionSnapshot.resetPolicy.mode, + resetAtHour: sessionSnapshot.resetPolicy.atHour, + idleMinutes: sessionSnapshot.resetPolicy.idleMinutes ?? null, + dailyResetAt: sessionSnapshot.dailyResetAt ?? null, + idleExpiresAt: sessionSnapshot.idleExpiresAt ?? null, }, "heartbeat session snapshot", ); diff --git a/src/web/auto-reply/session-snapshot.ts b/src/web/auto-reply/session-snapshot.ts index d5e1fd44b..94072e451 100644 --- a/src/web/auto-reply/session-snapshot.ts +++ b/src/web/auto-reply/session-snapshot.ts @@ -1,7 +1,9 @@ import type { loadConfig } from "../../config/config.js"; import { - DEFAULT_IDLE_MINUTES, + evaluateSessionFreshness, loadSessionStore, + resolveSessionResetPolicy, + resolveSessionResetType, resolveSessionKey, resolveStorePath, } from "../../config/sessions.js"; @@ -21,12 +23,24 @@ export function getSessionSnapshot( ); const store = loadSessionStore(resolveStorePath(sessionCfg?.store)); const entry = store[key]; - const idleMinutes = Math.max( - (isHeartbeat - ? (sessionCfg?.heartbeatIdleMinutes ?? sessionCfg?.idleMinutes) - : sessionCfg?.idleMinutes) ?? DEFAULT_IDLE_MINUTES, - 1, - ); - const fresh = !!(entry && Date.now() - entry.updatedAt <= idleMinutes * 60_000); - return { key, entry, fresh, idleMinutes }; + const resetType = resolveSessionResetType({ sessionKey: key }); + const idleMinutesOverride = isHeartbeat ? sessionCfg?.heartbeatIdleMinutes : undefined; + const resetPolicy = resolveSessionResetPolicy({ + sessionCfg, + resetType, + idleMinutesOverride, + }); + const now = Date.now(); + const freshness = entry + ? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }) + : { fresh: false }; + return { + key, + entry, + fresh: freshness.fresh, + resetPolicy, + resetType, + dailyResetAt: freshness.dailyResetAt, + idleExpiresAt: freshness.idleExpiresAt, + }; }