import crypto from "node:crypto"; import type { ClawdisConfig } from "../../config/config.js"; import { buildGroupDisplayName, DEFAULT_IDLE_MINUTES, DEFAULT_RESET_TRIGGERS, type GroupKeyResolution, loadSessionStore, resolveGroupSessionKey, resolveSessionKey, resolveStorePath, type SessionEntry, saveSessionStore, } from "../../config/sessions.js"; import type { MsgContext, TemplateContext } from "../templating.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; export type SessionInitResult = { sessionCtx: TemplateContext; sessionEntry: SessionEntry; sessionStore: Record; sessionKey: string; sessionId: string; isNewSession: boolean; systemSent: boolean; abortedLastRun: boolean; storePath: string; sessionScope: string; groupResolution?: GroupKeyResolution; isGroup: boolean; bodyStripped?: string; triggerBodyNormalized: string; }; export async function initSessionState(params: { ctx: MsgContext; cfg: ClawdisConfig; }): Promise { const { ctx, cfg } = params; const sessionCfg = cfg.session; const mainKey = sessionCfg?.mainKey ?? "main"; 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); const sessionStore: Record = loadSessionStore(storePath); let sessionKey: string | undefined; let sessionEntry: SessionEntry | undefined; let sessionId: string | undefined; let isNewSession = false; let bodyStripped: string | undefined; let systemSent = false; let abortedLastRun = false; let persistedThinking: string | undefined; let persistedVerbose: string | undefined; let persistedModelOverride: string | undefined; let persistedProviderOverride: string | undefined; const groupResolution = resolveGroupSessionKey(ctx); const isGroup = ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution); const triggerBodyNormalized = stripStructuralPrefixes(ctx.Body ?? "") .trim() .toLowerCase(); const rawBody = ctx.Body ?? ""; const trimmedBody = rawBody.trim(); // Timestamp/message prefixes (e.g. "[Dec 4 17:35] ") are added by the // web inbox before we get here. They prevented reset triggers like "/new" // from matching, so strip structural wrappers when checking for resets. const strippedForReset = isGroup ? stripMentions(triggerBodyNormalized, ctx, cfg) : triggerBodyNormalized; for (const trigger of resetTriggers) { if (!trigger) continue; if (trimmedBody === trigger || strippedForReset === trigger) { isNewSession = true; bodyStripped = ""; break; } const triggerPrefix = `${trigger} `; if ( trimmedBody.startsWith(triggerPrefix) || strippedForReset.startsWith(triggerPrefix) ) { isNewSession = true; bodyStripped = strippedForReset.slice(trigger.length).trimStart(); break; } } sessionKey = resolveSessionKey(sessionScope, ctx, mainKey); if (groupResolution?.legacyKey && groupResolution.legacyKey !== sessionKey) { const legacyEntry = sessionStore[groupResolution.legacyKey]; if (legacyEntry && !sessionStore[sessionKey]) { sessionStore[sessionKey] = legacyEntry; delete sessionStore[groupResolution.legacyKey]; } } const entry = sessionStore[sessionKey]; const idleMs = idleMinutes * 60_000; const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs; if (!isNewSession && freshEntry) { sessionId = entry.sessionId; systemSent = entry.systemSent ?? false; abortedLastRun = entry.abortedLastRun ?? false; persistedThinking = entry.thinkingLevel; persistedVerbose = entry.verboseLevel; persistedModelOverride = entry.modelOverride; persistedProviderOverride = entry.providerOverride; } else { sessionId = crypto.randomUUID(); isNewSession = true; systemSent = false; abortedLastRun = false; } const baseEntry = !isNewSession && freshEntry ? entry : undefined; sessionEntry = { ...baseEntry, sessionId, updatedAt: Date.now(), systemSent, abortedLastRun, // Persist previously stored thinking/verbose levels when present. thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel, verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel, modelOverride: persistedModelOverride ?? baseEntry?.modelOverride, providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride, sendPolicy: baseEntry?.sendPolicy, queueMode: baseEntry?.queueMode, queueDebounceMs: baseEntry?.queueDebounceMs, queueCap: baseEntry?.queueCap, queueDrop: baseEntry?.queueDrop, displayName: baseEntry?.displayName, chatType: baseEntry?.chatType, surface: baseEntry?.surface, subject: baseEntry?.subject, room: baseEntry?.room, space: baseEntry?.space, }; if (groupResolution?.surface) { const surface = groupResolution.surface; const subject = ctx.GroupSubject?.trim(); const space = ctx.GroupSpace?.trim(); const explicitRoom = ctx.GroupRoom?.trim(); const isRoomSurface = surface === "discord" || surface === "slack"; const nextRoom = explicitRoom ?? (isRoomSurface && subject && subject.startsWith("#") ? subject : undefined); const nextSubject = nextRoom ? undefined : subject; sessionEntry.chatType = groupResolution.chatType ?? "group"; sessionEntry.surface = surface; if (nextSubject) sessionEntry.subject = nextSubject; if (nextRoom) sessionEntry.room = nextRoom; if (space) sessionEntry.space = space; sessionEntry.displayName = buildGroupDisplayName({ surface: sessionEntry.surface, subject: sessionEntry.subject, room: sessionEntry.room, space: sessionEntry.space, id: groupResolution.id, key: sessionKey, }); } else if (!sessionEntry.chatType) { sessionEntry.chatType = "direct"; } sessionStore[sessionKey] = sessionEntry; await saveSessionStore(storePath, sessionStore); const sessionCtx: TemplateContext = { ...ctx, BodyStripped: bodyStripped ?? ctx.Body, SessionId: sessionId, IsNewSession: isNewSession ? "true" : "false", }; return { sessionCtx, sessionEntry, sessionStore, sessionKey, sessionId: sessionId ?? crypto.randomUUID(), isNewSession, systemSent, abortedLastRun, storePath, sessionScope, groupResolution, isGroup, bodyStripped, triggerBodyNormalized, }; }