import crypto from "node:crypto"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; import type { OpenClawConfig } from "../../config/config.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"; import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; export { drainFormattedSystemEvents } from "./session-system-events.js"; async function persistSessionEntryUpdate(params: { sessionStore?: Record; sessionKey?: string; storePath?: string; nextEntry: SessionEntry; }) { if (!params.sessionStore || !params.sessionKey) { return; } params.sessionStore[params.sessionKey] = { ...params.sessionStore[params.sessionKey], ...params.nextEntry, }; if (!params.storePath) { return; } await updateSessionStore(params.storePath, (store) => { store[params.sessionKey!] = { ...store[params.sessionKey!], ...params.nextEntry }; }); } export async function ensureSkillSnapshot(params: { sessionEntry?: SessionEntry; sessionStore?: Record; sessionKey?: string; storePath?: string; sessionId?: string; isFirstTurnInSession: boolean; workspaceDir: string; cfg: OpenClawConfig; /** If provided, only load skills with these names (for per-channel skill filtering) */ skillFilter?: string[]; }): Promise<{ sessionEntry?: SessionEntry; skillsSnapshot?: SessionEntry["skillsSnapshot"]; systemSent: boolean; }> { if (process.env.OPENCLAW_TEST_FAST === "1") { // In fast unit-test runs we skip filesystem scanning, watchers, and session-store writes. // Dedicated skills tests cover snapshot generation behavior. return { sessionEntry: params.sessionEntry, skillsSnapshot: params.sessionEntry?.skillsSnapshot, systemSent: params.sessionEntry?.systemSent ?? false, }; } const { sessionEntry, sessionStore, sessionKey, storePath, sessionId, isFirstTurnInSession, workspaceDir, cfg, skillFilter, } = params; let nextEntry = sessionEntry; let systemSent = sessionEntry?.systemSent ?? false; const remoteEligibility = getRemoteSkillEligibility(); const snapshotVersion = getSkillsSnapshotVersion(workspaceDir); ensureSkillsWatcher({ workspaceDir, config: cfg }); const shouldRefreshSnapshot = snapshotVersion > 0 && (nextEntry?.skillsSnapshot?.version ?? 0) < snapshotVersion; if (isFirstTurnInSession && sessionStore && sessionKey) { const current = nextEntry ?? sessionStore[sessionKey] ?? { sessionId: sessionId ?? crypto.randomUUID(), updatedAt: Date.now(), }; const skillSnapshot = isFirstTurnInSession || !current.skillsSnapshot || shouldRefreshSnapshot ? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg, skillFilter, eligibility: { remote: remoteEligibility }, snapshotVersion, }) : current.skillsSnapshot; nextEntry = { ...current, sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(), updatedAt: Date.now(), systemSent: true, skillsSnapshot: skillSnapshot, }; await persistSessionEntryUpdate({ sessionStore, sessionKey, storePath, nextEntry }); systemSent = true; } const skillsSnapshot = shouldRefreshSnapshot ? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg, skillFilter, eligibility: { remote: remoteEligibility }, snapshotVersion, }) : (nextEntry?.skillsSnapshot ?? (isFirstTurnInSession ? undefined : buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg, skillFilter, eligibility: { remote: remoteEligibility }, snapshotVersion, }))); if ( skillsSnapshot && sessionStore && sessionKey && !isFirstTurnInSession && (!nextEntry?.skillsSnapshot || shouldRefreshSnapshot) ) { const current = nextEntry ?? { sessionId: sessionId ?? crypto.randomUUID(), updatedAt: Date.now(), }; nextEntry = { ...current, sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(), updatedAt: Date.now(), skillsSnapshot, }; await persistSessionEntryUpdate({ sessionStore, sessionKey, storePath, nextEntry }); } return { sessionEntry: nextEntry, skillsSnapshot, systemSent }; } export async function incrementCompactionCount(params: { sessionEntry?: SessionEntry; sessionStore?: Record; sessionKey?: string; storePath?: string; now?: number; amount?: number; /** Token count after compaction - if provided, updates session token counts */ tokensAfter?: number; }): Promise { const { sessionEntry, sessionStore, sessionKey, storePath, now = Date.now(), amount = 1, tokensAfter, } = params; if (!sessionStore || !sessionKey) { return undefined; } const entry = sessionStore[sessionKey] ?? sessionEntry; if (!entry) { return undefined; } const incrementBy = Math.max(0, amount); const nextCount = (entry.compactionCount ?? 0) + incrementBy; // Build update payload with compaction count and optionally updated token counts const updates: Partial = { compactionCount: nextCount, updatedAt: now, }; // If tokensAfter is provided, update the cached token counts to reflect post-compaction state if (tokensAfter != null && tokensAfter > 0) { updates.totalTokens = tokensAfter; updates.totalTokensFresh = true; // Clear input/output breakdown since we only have the total estimate after compaction updates.inputTokens = undefined; updates.outputTokens = undefined; updates.cacheRead = undefined; updates.cacheWrite = undefined; } sessionStore[sessionKey] = { ...entry, ...updates, }; if (storePath) { await updateSessionStore(storePath, (store) => { store[sessionKey] = { ...store[sessionKey], ...updates, }; }); } return nextCount; }