From 19697bbc033cd0a60bede82e0fe0e39c2ac904b2 Mon Sep 17 00:00:00 2001 From: scoootscooob Date: Mon, 6 Apr 2026 14:47:50 -0700 Subject: [PATCH] Gateway: add compaction checkpoints --- src/agents/pi-embedded-runner/compact.ts | 81 ++++ src/config/sessions/types.ts | 28 ++ src/gateway/method-scopes.ts | 4 + src/gateway/protocol/index.ts | 24 + .../protocol/schema/protocol-schemas.ts | 18 + src/gateway/protocol/schema/sessions.ts | 118 +++++ src/gateway/protocol/schema/types.ts | 9 + src/gateway/server-methods-list.ts | 4 + src/gateway/server-methods/agent.ts | 2 + src/gateway/server-methods/sessions.ts | 430 +++++++++++++++++- src/gateway/server.impl.ts | 4 + ...sessions.gateway-server-sessions-a.test.ts | 244 ++++++++++ src/gateway/session-compaction-checkpoints.ts | 207 +++++++++ src/gateway/session-reset-service.ts | 2 + src/gateway/session-utils.ts | 15 + src/gateway/session-utils.types.ts | 3 + src/gateway/test-helpers.mocks.ts | 2 + src/gateway/test-helpers.server.ts | 22 + ui/src/ui/app-render.ts | 29 +- ui/src/ui/app-view-state.ts | 5 + ui/src/ui/app.ts | 6 + ui/src/ui/chat/slash-command-executor.ts | 20 +- ui/src/ui/controllers/sessions.ts | 116 ++++- ui/src/ui/types.ts | 64 +++ ui/src/ui/views/sessions.ts | 207 +++++++-- 25 files changed, 1616 insertions(+), 48 deletions(-) create mode 100644 src/gateway/session-compaction-checkpoints.ts diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index b768b33b007..cba7a9d200b 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -15,6 +15,13 @@ import { ensureContextEnginesInitialized, resolveContextEngine, } from "../../context-engine/index.js"; +import { + captureCompactionCheckpointSnapshot, + cleanupCompactionCheckpointSnapshot, + persistSessionCompactionCheckpoint, + resolveSessionCompactionCheckpointReason, + type CapturedCompactionCheckpointSnapshot, +} from "../../gateway/session-compaction-checkpoints.js"; import { resolveHeartbeatSummaryForAgent } from "../../infra/heartbeat-summary.js"; import { getMachineDisplayName } from "../../infra/machine-name.js"; import { generateSecureToken } from "../../infra/secure-random.js"; @@ -415,6 +422,8 @@ export async function compactEmbeddedPiSessionDirect( let restoreSkillEnv: (() => void) | undefined; let compactionSessionManager: unknown = null; + let checkpointSnapshot: CapturedCompactionCheckpointSnapshot | null = null; + let checkpointSnapshotRetained = false; try { const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({ workspaceDir: effectiveWorkspace, @@ -730,6 +739,10 @@ export async function compactEmbeddedPiSessionDirect( allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults, allowedToolNames, }); + checkpointSnapshot = captureCompactionCheckpointSnapshot({ + sessionManager, + sessionFile: params.sessionFile, + }); compactionSessionManager = sessionManager; trackSessionManagerAccess(params.sessionFile); const settingsManager = createPreparedEmbeddedPiSettingsManager({ @@ -969,6 +982,33 @@ export async function compactEmbeddedPiSessionDirect( }); const messageCountAfter = session.messages.length; const compactedCount = Math.max(0, messageCountCompactionInput - messageCountAfter); + if (params.config && params.sessionKey && checkpointSnapshot) { + try { + const postCompactionLeafId = sessionManager.getLeafId() ?? undefined; + const storedCheckpoint = await persistSessionCompactionCheckpoint({ + cfg: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + reason: resolveSessionCompactionCheckpointReason({ + trigger: params.trigger, + }), + snapshot: checkpointSnapshot, + summary: result.summary, + firstKeptEntryId: result.firstKeptEntryId, + tokensBefore: observedTokenCount ?? result.tokensBefore, + tokensAfter, + postSessionFile: params.sessionFile, + postLeafId: postCompactionLeafId, + postEntryId: postCompactionLeafId, + createdAt: compactStartedAt, + }); + checkpointSnapshotRetained = storedCheckpoint !== null; + } catch (err) { + log.warn("failed to persist compaction checkpoint", { + errorMessage: err instanceof Error ? err.message : String(err), + }); + } + } const postMetrics = diagEnabled ? summarizeCompactionMessages(session.messages) : undefined; @@ -1091,6 +1131,9 @@ export async function compactEmbeddedPiSessionDirect( }); return fail(reason); } finally { + if (!checkpointSnapshotRetained) { + await cleanupCompactionCheckpointSnapshot(checkpointSnapshot); + } restoreSkillEnv?.(); } } @@ -1116,6 +1159,8 @@ export async function compactEmbeddedPiSession( }); ensureContextEnginesInitialized(); const contextEngine = await resolveContextEngine(params.config); + let checkpointSnapshot: CapturedCompactionCheckpointSnapshot | null = null; + let checkpointSnapshotRetained = false; try { const agentDir = params.agentDir ?? resolveOpenClawAgentDir(); const resolvedCompactionTarget = resolveEmbeddedCompactionTarget({ @@ -1150,6 +1195,12 @@ export async function compactEmbeddedPiSession( // Fire before_compaction / after_compaction hooks here so plugin subscribers // are notified regardless of which engine is active. const engineOwnsCompaction = contextEngine.info.ownsCompaction === true; + checkpointSnapshot = engineOwnsCompaction + ? captureCompactionCheckpointSnapshot({ + sessionManager: SessionManager.open(params.sessionFile), + sessionFile: params.sessionFile, + }) + : null; const hookRunner = engineOwnsCompaction ? asCompactionHookRunner(getGlobalHookRunner()) : null; @@ -1222,6 +1273,33 @@ export async function compactEmbeddedPiSession( runtimeContext, }); if (result.ok && result.compacted) { + if (params.config && params.sessionKey && checkpointSnapshot) { + try { + const postCompactionSession = SessionManager.open(params.sessionFile); + const postLeafId = postCompactionSession.getLeafId() ?? undefined; + const storedCheckpoint = await persistSessionCompactionCheckpoint({ + cfg: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + reason: resolveSessionCompactionCheckpointReason({ + trigger: params.trigger, + }), + snapshot: checkpointSnapshot, + summary: result.result?.summary, + firstKeptEntryId: result.result?.firstKeptEntryId, + tokensBefore: result.result?.tokensBefore, + tokensAfter: result.result?.tokensAfter, + postSessionFile: params.sessionFile, + postLeafId, + postEntryId: postLeafId, + }); + checkpointSnapshotRetained = storedCheckpoint !== null; + } catch (err) { + log.warn("failed to persist compaction checkpoint", { + errorMessage: err instanceof Error ? err.message : String(err), + }); + } + } await runContextEngineMaintenance({ contextEngine, sessionId: params.sessionId, @@ -1275,6 +1353,9 @@ export async function compactEmbeddedPiSession( : undefined, }; } finally { + if (!checkpointSnapshotRetained) { + await cleanupCompactionCheckpointSnapshot(checkpointSnapshot); + } await contextEngine.dispose?.(); } }), diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index bc37ee4899f..c57d31ec8ed 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -75,6 +75,33 @@ export type CliSessionBinding = { mcpConfigHash?: string; }; +export type SessionCompactionCheckpointReason = + | "manual" + | "auto-threshold" + | "overflow-retry" + | "timeout-retry"; + +export type SessionCompactionTranscriptReference = { + sessionId: string; + sessionFile?: string; + leafId?: string; + entryId?: string; +}; + +export type SessionCompactionCheckpoint = { + checkpointId: string; + sessionKey: string; + sessionId: string; + createdAt: number; + reason: SessionCompactionCheckpointReason; + tokensBefore?: number; + tokensAfter?: number; + summary?: string; + firstKeptEntryId?: string; + preCompaction: SessionCompactionTranscriptReference; + postCompaction: SessionCompactionTranscriptReference; +}; + export type SessionEntry = { /** * Last delivered heartbeat payload (used to suppress duplicate heartbeat notifications). @@ -182,6 +209,7 @@ export type SessionEntry = { fallbackNoticeReason?: string; contextTokens?: number; compactionCount?: number; + compactionCheckpoints?: SessionCompactionCheckpoint[]; memoryFlushAt?: number; memoryFlushCompactionCount?: number; memoryFlushContextHash?: string; diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index ac2abc11049..64c3702502f 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -87,6 +87,8 @@ const METHOD_SCOPE_GROUPS: Record = { "sessions.get", "sessions.preview", "sessions.resolve", + "sessions.compaction.list", + "sessions.compaction.get", "sessions.subscribe", "sessions.unsubscribe", "sessions.messages.subscribe", @@ -129,6 +131,7 @@ const METHOD_SCOPE_GROUPS: Record = { "sessions.send", "sessions.steer", "sessions.abort", + "sessions.compaction.branch", "push.test", "node.pending.enqueue", ], @@ -149,6 +152,7 @@ const METHOD_SCOPE_GROUPS: Record = { "sessions.reset", "sessions.delete", "sessions.compact", + "sessions.compaction.restore", "connect", "chat.inject", "web.login.start", diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index e18b91df561..692745d12b7 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -200,6 +200,14 @@ import { SessionsAbortParamsSchema, type SessionsCompactParams, SessionsCompactParamsSchema, + type SessionsCompactionBranchParams, + SessionsCompactionBranchParamsSchema, + type SessionsCompactionGetParams, + SessionsCompactionGetParamsSchema, + type SessionsCompactionListParams, + SessionsCompactionListParamsSchema, + type SessionsCompactionRestoreParams, + SessionsCompactionRestoreParamsSchema, type SessionsCreateParams, SessionsCreateParamsSchema, type SessionsDeleteParams, @@ -376,6 +384,18 @@ export const validateSessionsDeleteParams = ajv.compile( export const validateSessionsCompactParams = ajv.compile( SessionsCompactParamsSchema, ); +export const validateSessionsCompactionListParams = ajv.compile( + SessionsCompactionListParamsSchema, +); +export const validateSessionsCompactionGetParams = ajv.compile( + SessionsCompactionGetParamsSchema, +); +export const validateSessionsCompactionBranchParams = ajv.compile( + SessionsCompactionBranchParamsSchema, +); +export const validateSessionsCompactionRestoreParams = ajv.compile( + SessionsCompactionRestoreParamsSchema, +); export const validateSessionsUsageParams = ajv.compile(SessionsUsageParamsSchema); export const validateConfigGetParams = ajv.compile(ConfigGetParamsSchema); @@ -551,6 +571,10 @@ export { SessionsListParamsSchema, SessionsPreviewParamsSchema, SessionsResolveParamsSchema, + SessionsCompactionListParamsSchema, + SessionsCompactionGetParamsSchema, + SessionsCompactionBranchParamsSchema, + SessionsCompactionRestoreParamsSchema, SessionsCreateParamsSchema, SessionsSendParamsSchema, SessionsAbortParamsSchema, diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 534dcaac816..e04c18b9bbb 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -155,6 +155,15 @@ import { import { SessionsAbortParamsSchema, SessionsCompactParamsSchema, + SessionsCompactionBranchParamsSchema, + SessionsCompactionBranchResultSchema, + SessionsCompactionGetParamsSchema, + SessionsCompactionGetResultSchema, + SessionsCompactionListParamsSchema, + SessionsCompactionListResultSchema, + SessionsCompactionRestoreParamsSchema, + SessionsCompactionRestoreResultSchema, + SessionCompactionCheckpointSchema, SessionsCreateParamsSchema, SessionsDeleteParamsSchema, SessionsListParamsSchema, @@ -224,6 +233,15 @@ export const ProtocolSchemas = { SessionsListParams: SessionsListParamsSchema, SessionsPreviewParams: SessionsPreviewParamsSchema, SessionsResolveParams: SessionsResolveParamsSchema, + SessionCompactionCheckpoint: SessionCompactionCheckpointSchema, + SessionsCompactionListParams: SessionsCompactionListParamsSchema, + SessionsCompactionGetParams: SessionsCompactionGetParamsSchema, + SessionsCompactionBranchParams: SessionsCompactionBranchParamsSchema, + SessionsCompactionRestoreParams: SessionsCompactionRestoreParamsSchema, + SessionsCompactionListResult: SessionsCompactionListResultSchema, + SessionsCompactionGetResult: SessionsCompactionGetResultSchema, + SessionsCompactionBranchResult: SessionsCompactionBranchResultSchema, + SessionsCompactionRestoreResult: SessionsCompactionRestoreResultSchema, SessionsCreateParams: SessionsCreateParamsSchema, SessionsSendParams: SessionsSendParamsSchema, SessionsMessagesSubscribeParams: SessionsMessagesSubscribeParamsSchema, diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 5252e7c72cf..7d8945ec1cd 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -1,6 +1,40 @@ import { Type } from "@sinclair/typebox"; import { NonEmptyString, SessionLabelString } from "./primitives.js"; +export const SessionCompactionCheckpointReasonSchema = Type.Union([ + Type.Literal("manual"), + Type.Literal("auto-threshold"), + Type.Literal("overflow-retry"), + Type.Literal("timeout-retry"), +]); + +export const SessionCompactionTranscriptReferenceSchema = Type.Object( + { + sessionId: NonEmptyString, + sessionFile: Type.Optional(NonEmptyString), + leafId: Type.Optional(NonEmptyString), + entryId: Type.Optional(NonEmptyString), + }, + { additionalProperties: false }, +); + +export const SessionCompactionCheckpointSchema = Type.Object( + { + checkpointId: NonEmptyString, + sessionKey: NonEmptyString, + sessionId: NonEmptyString, + createdAt: Type.Integer({ minimum: 0 }), + reason: SessionCompactionCheckpointReasonSchema, + tokensBefore: Type.Optional(Type.Integer({ minimum: 0 })), + tokensAfter: Type.Optional(Type.Integer({ minimum: 0 })), + summary: Type.Optional(Type.String()), + firstKeptEntryId: Type.Optional(NonEmptyString), + preCompaction: SessionCompactionTranscriptReferenceSchema, + postCompaction: SessionCompactionTranscriptReferenceSchema, + }, + { additionalProperties: false }, +); + export const SessionsListParamsSchema = Type.Object( { limit: Type.Optional(Type.Integer({ minimum: 1 })), @@ -163,6 +197,90 @@ export const SessionsCompactParamsSchema = Type.Object( { additionalProperties: false }, ); +export const SessionsCompactionListParamsSchema = Type.Object( + { + key: NonEmptyString, + }, + { additionalProperties: false }, +); + +export const SessionsCompactionGetParamsSchema = Type.Object( + { + key: NonEmptyString, + checkpointId: NonEmptyString, + }, + { additionalProperties: false }, +); + +export const SessionsCompactionBranchParamsSchema = Type.Object( + { + key: NonEmptyString, + checkpointId: NonEmptyString, + }, + { additionalProperties: false }, +); + +export const SessionsCompactionRestoreParamsSchema = Type.Object( + { + key: NonEmptyString, + checkpointId: NonEmptyString, + }, + { additionalProperties: false }, +); + +export const SessionsCompactionListResultSchema = Type.Object( + { + ok: Type.Literal(true), + key: NonEmptyString, + checkpoints: Type.Array(SessionCompactionCheckpointSchema), + }, + { additionalProperties: false }, +); + +export const SessionsCompactionGetResultSchema = Type.Object( + { + ok: Type.Literal(true), + key: NonEmptyString, + checkpoint: SessionCompactionCheckpointSchema, + }, + { additionalProperties: false }, +); + +export const SessionsCompactionBranchResultSchema = Type.Object( + { + ok: Type.Literal(true), + sourceKey: NonEmptyString, + key: NonEmptyString, + sessionId: NonEmptyString, + checkpoint: SessionCompactionCheckpointSchema, + entry: Type.Object( + { + sessionId: NonEmptyString, + updatedAt: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: true }, + ), + }, + { additionalProperties: false }, +); + +export const SessionsCompactionRestoreResultSchema = Type.Object( + { + ok: Type.Literal(true), + key: NonEmptyString, + sessionId: NonEmptyString, + checkpoint: SessionCompactionCheckpointSchema, + entry: Type.Object( + { + sessionId: NonEmptyString, + updatedAt: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: true }, + ), + }, + { additionalProperties: false }, +); + export const SessionsUsageParamsSchema = Type.Object( { /** Specific session key to analyze; if omitted returns all sessions. */ diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index f3eb9725093..0515ca140f6 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -41,6 +41,15 @@ export type PushTestResult = SchemaType<"PushTestResult">; export type SessionsListParams = SchemaType<"SessionsListParams">; export type SessionsPreviewParams = SchemaType<"SessionsPreviewParams">; export type SessionsResolveParams = SchemaType<"SessionsResolveParams">; +export type SessionCompactionCheckpoint = SchemaType<"SessionCompactionCheckpoint">; +export type SessionsCompactionListParams = SchemaType<"SessionsCompactionListParams">; +export type SessionsCompactionGetParams = SchemaType<"SessionsCompactionGetParams">; +export type SessionsCompactionBranchParams = SchemaType<"SessionsCompactionBranchParams">; +export type SessionsCompactionRestoreParams = SchemaType<"SessionsCompactionRestoreParams">; +export type SessionsCompactionListResult = SchemaType<"SessionsCompactionListResult">; +export type SessionsCompactionGetResult = SchemaType<"SessionsCompactionGetResult">; +export type SessionsCompactionBranchResult = SchemaType<"SessionsCompactionBranchResult">; +export type SessionsCompactionRestoreResult = SchemaType<"SessionsCompactionRestoreResult">; export type SessionsCreateParams = SchemaType<"SessionsCreateParams">; export type SessionsSendParams = SchemaType<"SessionsSendParams">; export type SessionsMessagesSubscribeParams = SchemaType<"SessionsMessagesSubscribeParams">; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index ecc6a02d122..0176150f9f8 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -68,6 +68,10 @@ const BASE_METHODS = [ "sessions.messages.subscribe", "sessions.messages.unsubscribe", "sessions.preview", + "sessions.compaction.list", + "sessions.compaction.get", + "sessions.compaction.branch", + "sessions.compaction.restore", "sessions.create", "sessions.send", "sessions.abort", diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 2d3658f09bc..285002ea7bd 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -177,6 +177,8 @@ function emitSessionsChanged( startedAt: sessionRow.startedAt, endedAt: sessionRow.endedAt, runtimeMs: sessionRow.runtimeMs, + compactionCheckpointCount: sessionRow.compactionCheckpointCount, + latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint, } : {}), }, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index c865fc7131c..ed1d1fad6d3 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -1,13 +1,14 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { abortEmbeddedPiRun, isEmbeddedPiRunActive, waitForEmbeddedPiRunEnd, } from "../../agents/pi-embedded-runner/runs.js"; +import { compactEmbeddedPiSession } from "../../agents/pi-embedded.js"; import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js"; import { loadConfig } from "../../config/config.js"; import { @@ -36,6 +37,10 @@ import { errorShape, validateSessionsAbortParams, validateSessionsCompactParams, + validateSessionsCompactionBranchParams, + validateSessionsCompactionGetParams, + validateSessionsCompactionListParams, + validateSessionsCompactionRestoreParams, validateSessionsCreateParams, validateSessionsDeleteParams, validateSessionsListParams, @@ -47,6 +52,10 @@ import { validateSessionsResolveParams, validateSessionsSendParams, } from "../protocol/index.js"; +import { + getSessionCompactionCheckpoint, + listSessionCompactionCheckpoints, +} from "../session-compaction-checkpoints.js"; import { reactivateCompletedSubagentSession } from "../session-subagent-reactivation.js"; import { archiveFileOnDisk, @@ -185,6 +194,8 @@ function emitSessionsChanged( startedAt: sessionRow.startedAt, endedAt: sessionRow.endedAt, runtimeMs: sessionRow.runtimeMs, + compactionCheckpointCount: sessionRow.compactionCheckpointCount, + latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint, } : {}), }, @@ -220,6 +231,47 @@ function buildDashboardSessionKey(agentId: string): string { return `agent:${agentId}:dashboard:${randomUUID()}`; } +function cloneCheckpointSessionEntry(params: { + currentEntry: SessionEntry; + nextSessionId: string; + nextSessionFile: string; + label?: string; + parentSessionKey?: string; + totalTokens?: number; + preserveCompactionCheckpoints?: boolean; +}): SessionEntry { + return { + ...params.currentEntry, + sessionId: params.nextSessionId, + sessionFile: params.nextSessionFile, + updatedAt: Date.now(), + systemSent: false, + abortedLastRun: false, + startedAt: undefined, + endedAt: undefined, + runtimeMs: undefined, + status: undefined, + inputTokens: undefined, + outputTokens: undefined, + cacheRead: undefined, + cacheWrite: undefined, + estimatedCostUsd: undefined, + totalTokens: + typeof params.totalTokens === "number" && Number.isFinite(params.totalTokens) + ? params.totalTokens + : undefined, + totalTokensFresh: + typeof params.totalTokens === "number" && Number.isFinite(params.totalTokens) + ? true + : undefined, + label: params.label ?? params.currentEntry.label, + parentSessionKey: params.parentSessionKey ?? params.currentEntry.parentSessionKey, + compactionCheckpoints: params.preserveCompactionCheckpoints + ? params.currentEntry.compactionCheckpoints + : undefined, + }; +} + function ensureSessionTranscriptFile(params: { sessionId: string; storePath: string; @@ -644,6 +696,74 @@ export const sessionsHandlers: GatewayRequestHandlers = { } respond(true, { ok: true, key: resolved.key }, undefined); }, + "sessions.compaction.list": ({ params, respond }) => { + if ( + !assertValidParams( + params, + validateSessionsCompactionListParams, + "sessions.compaction.list", + respond, + ) + ) { + return; + } + const key = requireSessionKey((params as { key?: unknown }).key, respond); + if (!key) { + return; + } + const { entry, canonicalKey } = loadSessionEntry(key); + respond( + true, + { + ok: true, + key: canonicalKey, + checkpoints: listSessionCompactionCheckpoints(entry), + }, + undefined, + ); + }, + "sessions.compaction.get": ({ params, respond }) => { + if ( + !assertValidParams( + params, + validateSessionsCompactionGetParams, + "sessions.compaction.get", + respond, + ) + ) { + return; + } + const p = params; + const key = requireSessionKey(p.key, respond); + if (!key) { + return; + } + const checkpointId = + typeof p.checkpointId === "string" && p.checkpointId.trim() ? p.checkpointId.trim() : ""; + if (!checkpointId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "checkpointId required")); + return; + } + const { entry, canonicalKey } = loadSessionEntry(key); + const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId }); + if (!checkpoint) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), + ); + return; + } + respond( + true, + { + ok: true, + key: canonicalKey, + checkpoint, + }, + undefined, + ); + }, "sessions.create": async ({ req, params, respond, context, client, isWebchatConnect }) => { if (!assertValidParams(params, validateSessionsCreateParams, "sessions.create", respond)) { return; @@ -831,6 +951,228 @@ export const sessionsHandlers: GatewayRequestHandlers = { }); } }, + "sessions.compaction.branch": async ({ params, respond, context }) => { + if ( + !assertValidParams( + params, + validateSessionsCompactionBranchParams, + "sessions.compaction.branch", + respond, + ) + ) { + return; + } + const p = params; + const key = requireSessionKey(p.key, respond); + if (!key) { + return; + } + const checkpointId = + typeof p.checkpointId === "string" && p.checkpointId.trim() ? p.checkpointId.trim() : ""; + if (!checkpointId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "checkpointId required")); + return; + } + const loaded = loadSessionEntry(key); + const { cfg, entry, canonicalKey } = loaded; + const target = resolveGatewaySessionStoreTarget({ cfg, key: canonicalKey }); + if (!entry?.sessionId) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), + ); + return; + } + const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId }); + if (!checkpoint?.preCompaction.sessionFile) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), + ); + return; + } + if (!fs.existsSync(checkpoint.preCompaction.sessionFile)) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "checkpoint snapshot transcript is missing"), + ); + return; + } + + const snapshotSession = SessionManager.open( + checkpoint.preCompaction.sessionFile, + path.dirname(checkpoint.preCompaction.sessionFile), + ); + const branchedSession = SessionManager.forkFrom( + checkpoint.preCompaction.sessionFile, + snapshotSession.getCwd(), + path.dirname(checkpoint.preCompaction.sessionFile), + ); + const branchedSessionFile = branchedSession.getSessionFile(); + if (!branchedSessionFile) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "failed to create checkpoint branch transcript"), + ); + return; + } + const nextKey = buildDashboardSessionKey(target.agentId); + const label = entry.label?.trim() ? `${entry.label.trim()} (checkpoint)` : "Checkpoint branch"; + const nextEntry = cloneCheckpointSessionEntry({ + currentEntry: entry, + nextSessionId: branchedSession.getSessionId(), + nextSessionFile: branchedSessionFile, + label, + parentSessionKey: canonicalKey, + totalTokens: checkpoint.tokensBefore, + }); + + await updateSessionStore(target.storePath, (store) => { + store[nextKey] = nextEntry; + }); + + respond( + true, + { + ok: true, + sourceKey: canonicalKey, + key: nextKey, + sessionId: nextEntry.sessionId, + checkpoint, + entry: nextEntry, + }, + undefined, + ); + emitSessionsChanged(context, { + sessionKey: canonicalKey, + reason: "checkpoint-branch", + }); + emitSessionsChanged(context, { + sessionKey: nextKey, + reason: "checkpoint-branch", + }); + }, + "sessions.compaction.restore": async ({ + req, + params, + respond, + context, + client, + isWebchatConnect, + }) => { + if ( + !assertValidParams( + params, + validateSessionsCompactionRestoreParams, + "sessions.compaction.restore", + respond, + ) + ) { + return; + } + const p = params; + const key = requireSessionKey(p.key, respond); + if (!key) { + return; + } + const checkpointId = + typeof p.checkpointId === "string" && p.checkpointId.trim() ? p.checkpointId.trim() : ""; + if (!checkpointId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "checkpointId required")); + return; + } + const loaded = loadSessionEntry(key); + const { entry, canonicalKey, storePath } = loaded; + if (!entry?.sessionId) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), + ); + return; + } + const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId }); + if (!checkpoint?.preCompaction.sessionFile) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), + ); + return; + } + if (!fs.existsSync(checkpoint.preCompaction.sessionFile)) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "checkpoint snapshot transcript is missing"), + ); + return; + } + + const interruptResult = await interruptSessionRunIfActive({ + req, + context, + client, + isWebchatConnect, + requestedKey: key, + canonicalKey, + sessionId: entry.sessionId, + }); + if (interruptResult.error) { + respond(false, undefined, interruptResult.error); + return; + } + + const snapshotSession = SessionManager.open( + checkpoint.preCompaction.sessionFile, + path.dirname(checkpoint.preCompaction.sessionFile), + ); + const restoredSession = SessionManager.forkFrom( + checkpoint.preCompaction.sessionFile, + snapshotSession.getCwd(), + path.dirname(checkpoint.preCompaction.sessionFile), + ); + const restoredSessionFile = restoredSession.getSessionFile(); + if (!restoredSessionFile) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "failed to restore checkpoint transcript"), + ); + return; + } + const nextEntry = cloneCheckpointSessionEntry({ + currentEntry: entry, + nextSessionId: restoredSession.getSessionId(), + nextSessionFile: restoredSessionFile, + totalTokens: checkpoint.tokensBefore, + preserveCompactionCheckpoints: true, + }); + + await updateSessionStore(storePath, (store) => { + store[canonicalKey] = nextEntry; + }); + + respond( + true, + { + ok: true, + key: canonicalKey, + sessionId: nextEntry.sessionId, + checkpoint, + entry: nextEntry, + }, + undefined, + ); + emitSessionsChanged(context, { + sessionKey: canonicalKey, + reason: "checkpoint-restore", + }); + }, "sessions.send": async ({ req, params, respond, context, client, isWebchatConnect }) => { await handleSessionSend({ method: "sessions.send", @@ -1135,7 +1477,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { const maxLines = typeof p.maxLines === "number" && Number.isFinite(p.maxLines) ? Math.max(1, Math.floor(p.maxLines)) - : 400; + : undefined; const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey(key); // Lock + read in a short critical section; transcript work happens outside. @@ -1179,6 +1521,88 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } + if (maxLines === undefined) { + const interruptResult = await interruptSessionRunIfActive({ + req: undefined, + context, + client: null, + isWebchatConnect: () => false, + requestedKey: key, + canonicalKey: target.canonicalKey, + sessionId, + }); + if (interruptResult.error) { + respond(false, undefined, interruptResult.error); + return; + } + + const resolvedModel = resolveSessionModelRef(cfg, entry, target.agentId); + const workspaceDir = + entry?.spawnedWorkspaceDir?.trim() || resolveAgentWorkspaceDir(cfg, target.agentId); + const result = await compactEmbeddedPiSession({ + sessionId, + sessionKey: target.canonicalKey, + allowGatewaySubagentBinding: true, + sessionFile: filePath, + workspaceDir, + config: cfg, + provider: resolvedModel.provider, + model: resolvedModel.model, + thinkLevel: entry?.thinkingLevel, + reasoningLevel: entry?.reasoningLevel, + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + trigger: "manual", + }); + + if (result.ok && result.compacted) { + await updateSessionStore(storePath, (store) => { + const entryKey = compactTarget.primaryKey; + const entryToUpdate = store[entryKey]; + if (!entryToUpdate) { + return; + } + entryToUpdate.updatedAt = Date.now(); + entryToUpdate.compactionCount = Math.max(0, entryToUpdate.compactionCount ?? 0) + 1; + delete entryToUpdate.inputTokens; + delete entryToUpdate.outputTokens; + if ( + typeof result.result?.tokensAfter === "number" && + Number.isFinite(result.result.tokensAfter) + ) { + entryToUpdate.totalTokens = result.result.tokensAfter; + entryToUpdate.totalTokensFresh = true; + } else { + delete entryToUpdate.totalTokens; + delete entryToUpdate.totalTokensFresh; + } + }); + } + + respond( + true, + { + ok: result.ok, + key: target.canonicalKey, + compacted: result.compacted, + reason: result.reason, + result: result.result, + }, + undefined, + ); + if (result.ok) { + emitSessionsChanged(context, { + sessionKey: target.canonicalKey, + reason: "compact", + compacted: result.compacted, + }); + } + return; + } + const raw = fs.readFileSync(filePath, "utf-8"); const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0); if (lines.length <= maxLines) { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index cd2688d9db7..040493a22ff 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -1059,6 +1059,8 @@ export async function startGatewayServer( startedAt: sessionRow.startedAt, endedAt: sessionRow.endedAt, runtimeMs: sessionRow.runtimeMs, + compactionCheckpointCount: sessionRow.compactionCheckpointCount, + latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint, } : {}; const message = attachOpenClawTranscriptMeta(update.message, { @@ -1160,6 +1162,8 @@ export async function startGatewayServer( startedAt: sessionRow.startedAt, endedAt: sessionRow.endedAt, runtimeMs: sessionRow.runtimeMs, + compactionCheckpointCount: sessionRow.compactionCheckpointCount, + latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint, } : {}), }, diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index 0187529449a..cc1ec6d9243 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -1,6 +1,8 @@ +import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; @@ -221,6 +223,43 @@ async function writeSingleLineSession(dir: string, sessionId: string, content: s ); } +function createCheckpointFixture(dir: string) { + const session = SessionManager.create(dir, dir); + session.appendMessage({ role: "user", content: "before compaction" }); + session.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "working on it" }], + }); + const preCompactionLeafId = session.getLeafId(); + if (!preCompactionLeafId) { + throw new Error("expected persisted session leaf before compaction"); + } + const sessionFile = session.getSessionFile(); + if (!sessionFile) { + throw new Error("expected persisted session file"); + } + const preCompactionSessionFile = path.join( + dir, + `${path.parse(sessionFile).name}.checkpoint-test.jsonl`, + ); + fsSync.copyFileSync(sessionFile, preCompactionSessionFile); + const preCompactionSession = SessionManager.open(preCompactionSessionFile, dir); + session.appendCompaction("checkpoint summary", preCompactionLeafId, 123, { ok: true }); + const postCompactionLeafId = session.getLeafId(); + if (!postCompactionLeafId) { + throw new Error("expected post-compaction leaf"); + } + return { + session, + sessionId: session.getSessionId(), + sessionFile, + preCompactionSession, + preCompactionSessionFile, + preCompactionLeafId, + postCompactionLeafId, + }; +} + async function seedActiveMainSession() { const { dir, storePath } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-main", "hello"); @@ -1227,6 +1266,211 @@ describe("gateway server sessions", () => { ws.close(); }); + test("sessions.compaction.* lists checkpoints and branches or restores from pre-compaction snapshots", async () => { + const { dir, storePath } = await createSessionStoreDir(); + const fixture = createCheckpointFixture(dir); + await writeSessionStore({ + entries: { + main: { + sessionId: fixture.sessionId, + sessionFile: fixture.sessionFile, + updatedAt: Date.now(), + compactionCheckpoints: [ + { + checkpointId: "checkpoint-1", + sessionKey: "agent:main:main", + sessionId: fixture.sessionId, + createdAt: Date.now(), + reason: "manual", + tokensBefore: 123, + tokensAfter: 45, + summary: "checkpoint summary", + firstKeptEntryId: fixture.preCompactionLeafId, + preCompaction: { + sessionId: fixture.preCompactionSession.getSessionId(), + sessionFile: fixture.preCompactionSessionFile, + leafId: fixture.preCompactionLeafId, + }, + postCompaction: { + sessionId: fixture.sessionId, + sessionFile: fixture.sessionFile, + leafId: fixture.postCompactionLeafId, + entryId: fixture.postCompactionLeafId, + }, + }, + ], + }, + }, + }); + + const { ws } = await openClient(); + + const listedSessions = await rpcReq<{ + sessions: Array<{ + key: string; + compactionCheckpointCount?: number; + latestCompactionCheckpoint?: { + checkpointId: string; + reason: string; + tokensBefore?: number; + tokensAfter?: number; + }; + }>; + }>(ws, "sessions.list", {}); + expect(listedSessions.ok).toBe(true); + const main = listedSessions.payload?.sessions.find( + (session) => session.key === "agent:main:main", + ); + expect(main?.compactionCheckpointCount).toBe(1); + expect(main?.latestCompactionCheckpoint?.checkpointId).toBe("checkpoint-1"); + expect(main?.latestCompactionCheckpoint?.reason).toBe("manual"); + + const listedCheckpoints = await rpcReq<{ + ok: true; + key: string; + checkpoints: Array<{ checkpointId: string; summary?: string; tokensBefore?: number }>; + }>(ws, "sessions.compaction.list", { key: "main" }); + expect(listedCheckpoints.ok).toBe(true); + expect(listedCheckpoints.payload?.key).toBe("agent:main:main"); + expect(listedCheckpoints.payload?.checkpoints).toHaveLength(1); + expect(listedCheckpoints.payload?.checkpoints[0]).toMatchObject({ + checkpointId: "checkpoint-1", + summary: "checkpoint summary", + tokensBefore: 123, + }); + + const checkpoint = await rpcReq<{ + ok: true; + key: string; + checkpoint: { checkpointId: string; preCompaction: { sessionFile: string } }; + }>(ws, "sessions.compaction.get", { + key: "main", + checkpointId: "checkpoint-1", + }); + expect(checkpoint.ok).toBe(true); + expect(checkpoint.payload?.checkpoint.checkpointId).toBe("checkpoint-1"); + expect(checkpoint.payload?.checkpoint.preCompaction.sessionFile).toBe( + fixture.preCompactionSessionFile, + ); + + const branched = await rpcReq<{ + ok: true; + sourceKey: string; + key: string; + entry: { sessionId: string; sessionFile?: string; parentSessionKey?: string }; + }>(ws, "sessions.compaction.branch", { + key: "main", + checkpointId: "checkpoint-1", + }); + expect(branched.ok).toBe(true); + expect(branched.payload?.sourceKey).toBe("agent:main:main"); + expect(branched.payload?.entry.parentSessionKey).toBe("agent:main:main"); + const branchedSessionFile = branched.payload?.entry.sessionFile; + expect(branchedSessionFile).toBeTruthy(); + const branchedSession = SessionManager.open(branchedSessionFile!, dir); + expect(branchedSession.getEntries()).toHaveLength( + fixture.preCompactionSession.getEntries().length, + ); + + const storeAfterBranch = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< + string, + { + parentSessionKey?: string; + compactionCheckpoints?: unknown[]; + sessionId?: string; + } + >; + const branchedEntry = storeAfterBranch[branched.payload!.key]; + expect(branchedEntry?.parentSessionKey).toBe("agent:main:main"); + expect(branchedEntry?.compactionCheckpoints).toBeUndefined(); + + const restored = await rpcReq<{ + ok: true; + key: string; + sessionId: string; + entry: { sessionId: string; sessionFile?: string; compactionCheckpoints?: unknown[] }; + }>(ws, "sessions.compaction.restore", { + key: "main", + checkpointId: "checkpoint-1", + }); + expect(restored.ok).toBe(true); + expect(restored.payload?.key).toBe("agent:main:main"); + expect(restored.payload?.sessionId).not.toBe(fixture.sessionId); + expect(restored.payload?.entry.compactionCheckpoints).toHaveLength(1); + const restoredSessionFile = restored.payload?.entry.sessionFile; + expect(restoredSessionFile).toBeTruthy(); + const restoredSession = SessionManager.open(restoredSessionFile!, dir); + expect(restoredSession.getEntries()).toHaveLength( + fixture.preCompactionSession.getEntries().length, + ); + + const storeAfterRestore = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< + string, + { compactionCheckpoints?: unknown[]; sessionId?: string } + >; + expect(storeAfterRestore["agent:main:main"]?.sessionId).toBe(restored.payload?.sessionId); + expect(storeAfterRestore["agent:main:main"]?.compactionCheckpoints).toHaveLength(1); + + ws.close(); + }); + + test("sessions.compact without maxLines runs embedded manual compaction for checkpoint-capable flows", async () => { + const { dir, storePath } = await createSessionStoreDir(); + await fs.writeFile( + path.join(dir, "sess-main.jsonl"), + `${JSON.stringify({ role: "user", content: "hello" })}\n`, + "utf-8", + ); + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-main", + updatedAt: Date.now(), + thinkingLevel: "medium", + reasoningLevel: "stream", + }, + }, + }); + + const { ws } = await openClient(); + const compacted = await rpcReq<{ + ok: true; + key: string; + compacted: boolean; + result?: { tokensAfter?: number }; + }>(ws, "sessions.compact", { + key: "main", + }); + + expect(compacted.ok).toBe(true); + expect(compacted.payload?.key).toBe("agent:main:main"); + expect(compacted.payload?.compacted).toBe(true); + expect(embeddedRunMock.compactEmbeddedPiSession).toHaveBeenCalledTimes(1); + expect(embeddedRunMock.compactEmbeddedPiSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "sess-main", + sessionKey: "agent:main:main", + sessionFile: expect.stringMatching(/sess-main\.jsonl$/), + config: expect.any(Object), + provider: expect.any(String), + model: expect.any(String), + thinkLevel: "medium", + reasoningLevel: "stream", + trigger: "manual", + }), + ); + + const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< + string, + { compactionCount?: number; totalTokens?: number; totalTokensFresh?: boolean } + >; + expect(store["agent:main:main"]?.compactionCount).toBe(1); + expect(store["agent:main:main"]?.totalTokens).toBe(80); + expect(store["agent:main:main"]?.totalTokensFresh).toBe(true); + + ws.close(); + }); + test("sessions.preview returns transcript previews", async () => { const { dir } = await createSessionStoreDir(); const sessionId = "sess-preview"; diff --git a/src/gateway/session-compaction-checkpoints.ts b/src/gateway/session-compaction-checkpoints.ts new file mode 100644 index 00000000000..12fd1268408 --- /dev/null +++ b/src/gateway/session-compaction-checkpoints.ts @@ -0,0 +1,207 @@ +import { randomUUID } from "node:crypto"; +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; +import type { OpenClawConfig } from "../config/config.js"; +import { updateSessionStore } from "../config/sessions.js"; +import type { + SessionCompactionCheckpoint, + SessionCompactionCheckpointReason, + SessionEntry, +} from "../config/sessions.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { resolveGatewaySessionStoreTarget } from "./session-utils.js"; + +const log = createSubsystemLogger("gateway/session-compaction-checkpoints"); +const MAX_COMPACTION_CHECKPOINTS_PER_SESSION = 25; + +export type CapturedCompactionCheckpointSnapshot = { + sessionId: string; + sessionFile: string; + leafId: string; +}; + +function trimSessionCheckpoints( + checkpoints: SessionCompactionCheckpoint[] | undefined, +): SessionCompactionCheckpoint[] | undefined { + if (!Array.isArray(checkpoints) || checkpoints.length === 0) { + return undefined; + } + return checkpoints.slice(-MAX_COMPACTION_CHECKPOINTS_PER_SESSION); +} + +function sessionStoreCheckpoints( + entry: Pick | undefined, +): SessionCompactionCheckpoint[] { + return Array.isArray(entry?.compactionCheckpoints) ? [...entry.compactionCheckpoints] : []; +} + +export function resolveSessionCompactionCheckpointReason(params: { + trigger?: "budget" | "overflow" | "manual"; + timedOut?: boolean; +}): SessionCompactionCheckpointReason { + if (params.trigger === "manual") { + return "manual"; + } + if (params.timedOut) { + return "timeout-retry"; + } + if (params.trigger === "overflow") { + return "overflow-retry"; + } + return "auto-threshold"; +} + +export function captureCompactionCheckpointSnapshot(params: { + sessionManager: Pick; + sessionFile: string; +}): CapturedCompactionCheckpointSnapshot | null { + const getLeafId = + params.sessionManager && typeof params.sessionManager.getLeafId === "function" + ? params.sessionManager.getLeafId.bind(params.sessionManager) + : null; + const sessionFile = params.sessionFile.trim(); + if (!getLeafId || !sessionFile) { + return null; + } + const leafId = getLeafId(); + if (!leafId) { + return null; + } + const parsedSessionFile = path.parse(sessionFile); + const snapshotFile = path.join( + parsedSessionFile.dir, + `${parsedSessionFile.name}.checkpoint.${randomUUID()}${parsedSessionFile.ext || ".jsonl"}`, + ); + try { + fsSync.copyFileSync(sessionFile, snapshotFile); + } catch { + return null; + } + let snapshotSession: SessionManager; + try { + snapshotSession = SessionManager.open(snapshotFile, path.dirname(snapshotFile)); + } catch { + try { + fsSync.unlinkSync(snapshotFile); + } catch { + // Best-effort cleanup if the copied transcript cannot be reopened. + } + return null; + } + const getSessionId = + snapshotSession && typeof snapshotSession.getSessionId === "function" + ? snapshotSession.getSessionId.bind(snapshotSession) + : null; + if (!getSessionId) { + return null; + } + return { + sessionId: getSessionId(), + sessionFile, + leafId, + }; +} + +export async function cleanupCompactionCheckpointSnapshot( + snapshot: CapturedCompactionCheckpointSnapshot | null | undefined, +): Promise { + if (!snapshot?.sessionFile) { + return; + } + try { + await fs.unlink(snapshot.sessionFile); + } catch { + // Best-effort cleanup; retained snapshots are harmless and easier to debug. + } +} + +export async function persistSessionCompactionCheckpoint(params: { + cfg: OpenClawConfig; + sessionKey: string; + sessionId: string; + reason: SessionCompactionCheckpointReason; + snapshot: CapturedCompactionCheckpointSnapshot; + summary?: string; + firstKeptEntryId?: string; + tokensBefore?: number; + tokensAfter?: number; + postSessionFile?: string; + postLeafId?: string; + postEntryId?: string; + createdAt?: number; +}): Promise { + const target = resolveGatewaySessionStoreTarget({ + cfg: params.cfg, + key: params.sessionKey, + }); + const createdAt = params.createdAt ?? Date.now(); + const checkpoint: SessionCompactionCheckpoint = { + checkpointId: randomUUID(), + sessionKey: target.canonicalKey, + sessionId: params.sessionId, + createdAt, + reason: params.reason, + ...(typeof params.tokensBefore === "number" ? { tokensBefore: params.tokensBefore } : {}), + ...(typeof params.tokensAfter === "number" ? { tokensAfter: params.tokensAfter } : {}), + ...(params.summary?.trim() ? { summary: params.summary.trim() } : {}), + ...(params.firstKeptEntryId?.trim() + ? { firstKeptEntryId: params.firstKeptEntryId.trim() } + : {}), + preCompaction: { + sessionId: params.snapshot.sessionId, + sessionFile: params.snapshot.sessionFile, + leafId: params.snapshot.leafId, + }, + postCompaction: { + sessionId: params.sessionId, + ...(params.postSessionFile?.trim() ? { sessionFile: params.postSessionFile.trim() } : {}), + ...(params.postLeafId?.trim() ? { leafId: params.postLeafId.trim() } : {}), + ...(params.postEntryId?.trim() ? { entryId: params.postEntryId.trim() } : {}), + }, + }; + + let stored = false; + await updateSessionStore(target.storePath, (store) => { + const existing = store[target.canonicalKey]; + if (!existing?.sessionId) { + return; + } + const checkpoints = sessionStoreCheckpoints(existing); + checkpoints.push(checkpoint); + store[target.canonicalKey] = { + ...existing, + updatedAt: Math.max(existing.updatedAt ?? 0, createdAt), + compactionCheckpoints: trimSessionCheckpoints(checkpoints), + }; + stored = true; + }); + + if (!stored) { + log.warn("skipping compaction checkpoint persist: session not found", { + sessionKey: params.sessionKey, + }); + return null; + } + return checkpoint; +} + +export function listSessionCompactionCheckpoints( + entry: Pick | undefined, +): SessionCompactionCheckpoint[] { + return sessionStoreCheckpoints(entry).toSorted((a, b) => b.createdAt - a.createdAt); +} + +export function getSessionCompactionCheckpoint(params: { + entry: Pick | undefined; + checkpointId: string; +}): SessionCompactionCheckpoint | undefined { + const checkpointId = params.checkpointId.trim(); + if (!checkpointId) { + return undefined; + } + return listSessionCompactionCheckpoints(params.entry).find( + (checkpoint) => checkpoint.checkpointId === checkpointId, + ); +} diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 93e5f61e451..bb9cb83e33b 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -472,6 +472,8 @@ export async function performGatewaySessionReset(params: { model: resolvedModel.model, modelProvider: resolvedModel.provider, contextTokens: resetEntry?.contextTokens, + compactionCount: currentEntry?.compactionCount, + compactionCheckpoints: currentEntry?.compactionCheckpoints, sendPolicy: currentEntry?.sendPolicy, queueMode: currentEntry?.queueMode, queueDebounceMs: currentEntry?.queueDebounceMs, diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 2ce224a47eb..d844d3a4ddd 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -213,6 +213,18 @@ function resolveNonNegativeNumber(value: number | null | undefined): number | un return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; } +function resolveLatestCompactionCheckpoint( + entry?: Pick | null, +): NonNullable[number] | undefined { + const checkpoints = entry?.compactionCheckpoints; + if (!Array.isArray(checkpoints) || checkpoints.length === 0) { + return undefined; + } + return checkpoints.reduce((latest, checkpoint) => + !latest || checkpoint.createdAt > latest.createdAt ? checkpoint : latest, + ); +} + function resolveEstimatedSessionCostUsd(params: { cfg: OpenClawConfig; provider?: string; @@ -1268,6 +1280,7 @@ export function buildGatewaySessionRow(params: { ? true : transcriptUsage?.totalTokensFresh === true; const childSessions = resolveChildSessionKeys(key, store); + const latestCompactionCheckpoint = resolveLatestCompactionCheckpoint(entry); const estimatedCostUsd = resolveEstimatedSessionCostUsd({ cfg, @@ -1354,6 +1367,8 @@ export function buildGatewaySessionRow(params: { lastTo: deliveryFields.lastTo ?? entry?.lastTo, lastAccountId: deliveryFields.lastAccountId ?? entry?.lastAccountId, lastThreadId: deliveryFields.lastThreadId ?? entry?.lastThreadId, + compactionCheckpointCount: entry?.compactionCheckpoints?.length, + latestCompactionCheckpoint, }; } diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 5c1d255edee..893e3876ec3 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -1,5 +1,6 @@ import type { ChatType } from "../channels/chat-type.js"; import type { SessionEntry } from "../config/sessions.js"; +import type { SessionCompactionCheckpoint } from "../config/sessions.js"; import type { GatewayAgentRow as SharedGatewayAgentRow, SessionsListResultBase, @@ -64,6 +65,8 @@ export type GatewaySessionRow = { lastTo?: string; lastAccountId?: string; lastThreadId?: SessionEntry["lastThreadId"]; + compactionCheckpointCount?: number; + latestCompactionCheckpoint?: SessionCompactionCheckpoint; }; export type GatewayAgentRow = SharedGatewayAgentRow; diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index d44e543351e..68222bdeeea 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -54,6 +54,8 @@ const gatewayTestHoisted = getGatewayTestHoistedState(); function createEmbeddedRunMockExports() { return { + compactEmbeddedPiSession: (...args: unknown[]) => + embeddedRunMock.compactEmbeddedPiSession(...args), isEmbeddedPiRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId), abortEmbeddedPiRun: (sessionId: string) => { embeddedRunMock.abortCalls.push(sessionId); diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 21897620a6e..6e3d0866db8 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -332,6 +332,17 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { embeddedRunMock.abortCalls = []; embeddedRunMock.waitCalls = []; embeddedRunMock.waitResults.clear(); + embeddedRunMock.compactEmbeddedPiSession.mockReset(); + embeddedRunMock.compactEmbeddedPiSession.mockResolvedValue({ + ok: true, + compacted: true, + result: { + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 120, + tokensAfter: 80, + }, + }); for (const sessionKey of resolveGatewayTestMainSessionKeys()) { drainSystemEvents(sessionKey); } @@ -411,6 +422,17 @@ async function resetGatewayTestRuntimeOnly() { embeddedRunMock.abortCalls = []; embeddedRunMock.waitCalls = []; embeddedRunMock.waitResults.clear(); + embeddedRunMock.compactEmbeddedPiSession.mockReset(); + embeddedRunMock.compactEmbeddedPiSession.mockResolvedValue({ + ok: true, + compacted: true, + result: { + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 120, + tokensAfter: 80, + }, + }); clearSessionStoreCacheForTest(); await persistTestSessionConfig(); for (const sessionKey of resolveGatewayTestMainSessionKeys()) { diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index f5addb5c466..6298cd680fd 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -79,7 +79,14 @@ import { import { loadLogs } from "./controllers/logs.ts"; import { loadNodes } from "./controllers/nodes.ts"; import { loadPresence } from "./controllers/presence.ts"; -import { deleteSessionsAndRefresh, loadSessions, patchSession } from "./controllers/sessions.ts"; +import { + branchSessionFromCheckpoint, + deleteSessionsAndRefresh, + loadSessions, + patchSession, + restoreSessionFromCheckpoint, + toggleSessionCompactionCheckpoints, +} from "./controllers/sessions.ts"; import { closeClawHubDetail, installFromClawHub, @@ -840,6 +847,11 @@ export function renderApp(state: AppViewState) { page: state.sessionsPage, pageSize: state.sessionsPageSize, selectedKeys: state.sessionsSelectedKeys, + expandedCheckpointKey: state.sessionsExpandedCheckpointKey, + checkpointItemsByKey: state.sessionsCheckpointItemsByKey, + checkpointLoadingKey: state.sessionsCheckpointLoadingKey, + checkpointBusyKey: state.sessionsCheckpointBusyKey, + checkpointErrorByKey: state.sessionsCheckpointErrorByKey, onFiltersChange: (next) => { state.sessionsFilterActive = next.activeMinutes; state.sessionsFilterLimit = next.limit; @@ -905,6 +917,21 @@ export function renderApp(state: AppViewState) { switchChatSession(state, sessionKey); state.setTab("chat" as import("./navigation.ts").Tab); }, + onToggleCheckpointDetails: (sessionKey) => + toggleSessionCompactionCheckpoints(state, sessionKey), + onBranchFromCheckpoint: async (sessionKey, checkpointId) => { + const nextKey = await branchSessionFromCheckpoint( + state, + sessionKey, + checkpointId, + ); + if (nextKey) { + switchChatSession(state, nextKey); + state.setTab("chat" as import("./navigation.ts").Tab); + } + }, + onRestoreCheckpoint: (sessionKey, checkpointId) => + restoreSessionFromCheckpoint(state, sessionKey, checkpointId), }), ) : nothing} diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index d80270fc77d..bf159357015 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -209,6 +209,11 @@ export type AppViewState = { sessionsPage: number; sessionsPageSize: number; sessionsSelectedKeys: Set; + sessionsExpandedCheckpointKey: string | null; + sessionsCheckpointItemsByKey: Record; + sessionsCheckpointLoadingKey: string | null; + sessionsCheckpointBusyKey: string | null; + sessionsCheckpointErrorByKey: Record; usageLoading: boolean; usageResult: SessionsUsageResult | null; usageCostSummary: CostUsageSummary | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 236812755c3..a0959d27584 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -89,6 +89,7 @@ import type { ModelCatalogEntry, PresenceEntry, ChannelsStatusSnapshot, + SessionCompactionCheckpoint, SessionsListResult, SkillStatusReport, StatusSummary, @@ -312,6 +313,11 @@ export class OpenClawApp extends LitElement { @state() sessionsPage = 0; @state() sessionsPageSize = 25; @state() sessionsSelectedKeys: Set = new Set(); + @state() sessionsExpandedCheckpointKey: string | null = null; + @state() sessionsCheckpointItemsByKey: Record = {}; + @state() sessionsCheckpointLoadingKey: string | null = null; + @state() sessionsCheckpointBusyKey: string | null = null; + @state() sessionsCheckpointErrorByKey: Record = {}; @state() usageLoading = false; @state() usageResult: import("./types.js").SessionsUsageResult | null = null; diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index 49f13614f6f..7534ca43186 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -146,8 +146,24 @@ async function executeCompact( sessionKey: string, ): Promise { try { - await client.request("sessions.compact", { key: sessionKey }); - return { content: "Context compacted successfully.", action: "refresh" }; + const result = await client.request<{ + compacted?: boolean; + reason?: string; + result?: { tokensBefore?: number; tokensAfter?: number }; + }>("sessions.compact", { key: sessionKey }); + if (result?.compacted) { + const before = result.result?.tokensBefore; + const after = result.result?.tokensAfter; + const tokenSummary = + typeof before === "number" && typeof after === "number" + ? ` (${before.toLocaleString()} -> ${after.toLocaleString()} tokens)` + : ""; + return { content: `Context compacted successfully${tokenSummary}.`, action: "refresh" }; + } + if (typeof result?.reason === "string" && result.reason.trim()) { + return { content: `Compaction skipped: ${result.reason}`, action: "refresh" }; + } + return { content: "Compaction skipped.", action: "refresh" }; } catch (err) { return { content: `Compaction failed: ${String(err)}` }; } diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index 4821f521b31..a435630b507 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -1,6 +1,12 @@ import { toNumber } from "../format.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; -import type { SessionsListResult } from "../types.ts"; +import type { + SessionCompactionCheckpoint, + SessionsCompactionBranchResult, + SessionsCompactionListResult, + SessionsCompactionRestoreResult, + SessionsListResult, +} from "../types.ts"; import { formatMissingOperatorReadScopeMessage, isMissingOperatorReadScopeError, @@ -16,6 +22,11 @@ export type SessionsState = { sessionsFilterLimit: string; sessionsIncludeGlobal: boolean; sessionsIncludeUnknown: boolean; + sessionsExpandedCheckpointKey: string | null; + sessionsCheckpointItemsByKey: Record; + sessionsCheckpointLoadingKey: string | null; + sessionsCheckpointBusyKey: string | null; + sessionsCheckpointErrorByKey: Record; }; export async function subscribeSessions(state: SessionsState) { @@ -156,3 +167,106 @@ export async function deleteSessionsAndRefresh( } return deleted; } + +export async function toggleSessionCompactionCheckpoints(state: SessionsState, key: string) { + const trimmedKey = key.trim(); + if (!trimmedKey) { + return; + } + if (state.sessionsExpandedCheckpointKey === trimmedKey) { + state.sessionsExpandedCheckpointKey = null; + return; + } + state.sessionsExpandedCheckpointKey = trimmedKey; + if (state.sessionsCheckpointItemsByKey[trimmedKey]) { + return; + } + state.sessionsCheckpointLoadingKey = trimmedKey; + state.sessionsCheckpointErrorByKey = { + ...state.sessionsCheckpointErrorByKey, + [trimmedKey]: "", + }; + try { + const result = await state.client?.request( + "sessions.compaction.list", + { key: trimmedKey }, + ); + if (result) { + state.sessionsCheckpointItemsByKey = { + ...state.sessionsCheckpointItemsByKey, + [trimmedKey]: result.checkpoints ?? [], + }; + } + } catch (err) { + state.sessionsCheckpointErrorByKey = { + ...state.sessionsCheckpointErrorByKey, + [trimmedKey]: String(err), + }; + } finally { + if (state.sessionsCheckpointLoadingKey === trimmedKey) { + state.sessionsCheckpointLoadingKey = null; + } + } +} + +export async function branchSessionFromCheckpoint( + state: SessionsState, + key: string, + checkpointId: string, +): Promise { + if (!state.client || !state.connected) { + return null; + } + const confirmed = window.confirm( + "Create a new child session from this pre-compaction checkpoint?", + ); + if (!confirmed) { + return null; + } + state.sessionsCheckpointBusyKey = checkpointId; + try { + const result = await state.client.request( + "sessions.compaction.branch", + { key, checkpointId }, + ); + await loadSessions(state); + return result?.key ?? null; + } catch (err) { + state.sessionsError = String(err); + return null; + } finally { + if (state.sessionsCheckpointBusyKey === checkpointId) { + state.sessionsCheckpointBusyKey = null; + } + } +} + +export async function restoreSessionFromCheckpoint( + state: SessionsState, + key: string, + checkpointId: string, +) { + if (!state.client || !state.connected) { + return; + } + const confirmed = window.confirm( + "Restore this session to the selected pre-compaction checkpoint?\n\nThis replaces the current active transcript for the session key.", + ); + if (!confirmed) { + return; + } + state.sessionsCheckpointBusyKey = checkpointId; + try { + await state.client.request("sessions.compaction.restore", { + key, + checkpointId, + }); + await loadSessions(state); + } catch (err) { + state.sessionsError = String(err); + } finally { + if (state.sessionsCheckpointBusyKey === checkpointId) { + state.sessionsCheckpointBusyKey = null; + } + } +} diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 4b7ce526ab5..c65e0c2086f 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -369,6 +369,33 @@ export type AgentsFilesSetResult = { export type SessionRunStatus = "running" | "done" | "failed" | "killed" | "timeout"; +export type SessionCompactionCheckpointReason = + | "manual" + | "auto-threshold" + | "overflow-retry" + | "timeout-retry"; + +export type SessionCompactionTranscriptReference = { + sessionId: string; + sessionFile?: string; + leafId?: string; + entryId?: string; +}; + +export type SessionCompactionCheckpoint = { + checkpointId: string; + sessionKey: string; + sessionId: string; + createdAt: number; + reason: SessionCompactionCheckpointReason; + tokensBefore?: number; + tokensAfter?: number; + summary?: string; + firstKeptEntryId?: string; + preCompaction: SessionCompactionTranscriptReference; + postCompaction: SessionCompactionTranscriptReference; +}; + export type GatewaySessionRow = { key: string; spawnedBy?: string; @@ -400,10 +427,47 @@ export type GatewaySessionRow = { model?: string; modelProvider?: string; contextTokens?: number; + compactionCheckpointCount?: number; + latestCompactionCheckpoint?: SessionCompactionCheckpoint; }; export type SessionsListResult = SessionsListResultBase; +export type SessionsCompactionListResult = { + ok: true; + key: string; + checkpoints: SessionCompactionCheckpoint[]; +}; + +export type SessionsCompactionGetResult = { + ok: true; + key: string; + checkpoint: SessionCompactionCheckpoint; +}; + +export type SessionsCompactionBranchResult = { + ok: true; + sourceKey: string; + key: string; + sessionId: string; + checkpoint: SessionCompactionCheckpoint; + entry: { + sessionId: string; + updatedAt: number; + } & Record; +}; + +export type SessionsCompactionRestoreResult = { + ok: true; + key: string; + sessionId: string; + checkpoint: SessionCompactionCheckpoint; + entry: { + sessionId: string; + updatedAt: number; + } & Record; +}; + export type SessionsPatchResult = SessionsPatchResultBase<{ sessionId: string; updatedAt?: number; diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 5c8fc956898..7b5f73368ba 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -4,7 +4,11 @@ import { formatRelativeTimestamp } from "../format.ts"; import { icons } from "../icons.ts"; import { pathForTab } from "../navigation.ts"; import { formatSessionTokens } from "../presenter.ts"; -import type { GatewaySessionRow, SessionsListResult } from "../types.ts"; +import type { + GatewaySessionRow, + SessionCompactionCheckpoint, + SessionsListResult, +} from "../types.ts"; export type SessionsProps = { loading: boolean; @@ -21,6 +25,11 @@ export type SessionsProps = { page: number; pageSize: number; selectedKeys: Set; + expandedCheckpointKey: string | null; + checkpointItemsByKey: Record; + checkpointLoadingKey: string | null; + checkpointBusyKey: string | null; + checkpointErrorByKey: Record; onFiltersChange: (next: { activeMinutes: string; limit: string; @@ -48,6 +57,9 @@ export type SessionsProps = { onDeselectAll: () => void; onDeleteSelected: () => void; onNavigateToChat?: (sessionKey: string) => void; + onToggleCheckpointDetails: (sessionKey: string) => void; + onBranchFromCheckpoint: (sessionKey: string, checkpointId: string) => void | Promise; + onRestoreCheckpoint: (sessionKey: string, checkpointId: string) => void | Promise; }; const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high", "xhigh"] as const; @@ -182,6 +194,36 @@ function paginateRows(rows: T[], page: number, pageSize: number): T[] { return rows.slice(start, start + pageSize); } +function formatCheckpointReason(reason: SessionCompactionCheckpoint["reason"]): string { + switch (reason) { + case "manual": + return "manual"; + case "auto-threshold": + return "auto-threshold"; + case "overflow-retry": + return "overflow retry"; + case "timeout-retry": + return "timeout retry"; + default: + return reason; + } +} + +function formatCheckpointDelta(checkpoint: SessionCompactionCheckpoint): string { + if ( + typeof checkpoint.tokensBefore === "number" && + typeof checkpoint.tokensAfter === "number" && + Number.isFinite(checkpoint.tokensBefore) && + Number.isFinite(checkpoint.tokensAfter) + ) { + return `${checkpoint.tokensBefore.toLocaleString()} → ${checkpoint.tokensAfter.toLocaleString()} tokens`; + } + if (typeof checkpoint.tokensBefore === "number" && Number.isFinite(checkpoint.tokensBefore)) { + return `${checkpoint.tokensBefore.toLocaleString()} tokens before`; + } + return "token delta unavailable"; +} + export function renderSessions(props: SessionsProps) { const rawRows = props.result?.sessions ?? []; const filtered = filterRows(rawRows, props.searchQuery); @@ -349,6 +391,7 @@ export function renderSessions(props: SessionsProps) { Label ${sortHeader("kind", "Kind")} ${sortHeader("updated", "Updated")} ${sortHeader("tokens", "Tokens")} + Compaction Thinking Fast Verbose @@ -360,24 +403,14 @@ export function renderSessions(props: SessionsProps) { ? html` No sessions found. ` - : paginated.map((row) => - renderRow( - row, - props.basePath, - props.onPatch, - props.selectedKeys.has(row.key), - props.onToggleSelect, - props.loading, - props.onNavigateToChat, - ), - )} + : paginated.flatMap((row) => renderRows(row, props))} @@ -416,15 +449,7 @@ export function renderSessions(props: SessionsProps) { `; } -function renderRow( - row: GatewaySessionRow, - basePath: string, - onPatch: SessionsProps["onPatch"], - selected: boolean, - onToggleSelect: SessionsProps["onToggleSelect"], - disabled: boolean, - onNavigateToChat?: (sessionKey: string) => void, -) { +function renderRows(row: GatewaySessionRow, props: SessionsProps) { const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : t("common.na"); const rawThinking = row.thinkingLevel ?? ""; const isBinaryThinking = isBinaryThinkingProvider(row.modelProvider); @@ -436,6 +461,11 @@ function renderRow( const verboseLevels = withCurrentLabeledOption(VERBOSE_LEVELS, verbose); const reasoning = row.reasoningLevel ?? ""; const reasoningLevels = withCurrentOption(REASONING_LEVELS, reasoning); + const latestCheckpoint = row.latestCompactionCheckpoint; + const checkpointCount = row.compactionCheckpointCount ?? 0; + const isExpanded = props.expandedCheckpointKey === row.key; + const checkpointItems = props.checkpointItemsByKey[row.key] ?? []; + const checkpointError = props.checkpointErrorByKey[row.key]; const displayName = typeof row.displayName === "string" && row.displayName.trim().length > 0 ? row.displayName.trim() @@ -447,7 +477,7 @@ function renderRow( ); const canLink = row.kind !== "global"; const chatUrl = canLink - ? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(row.key)}` + ? `${pathForTab("chat", props.basePath)}?session=${encodeURIComponent(row.key)}` : null; const badgeClass = row.kind === "direct" @@ -458,13 +488,13 @@ function renderRow( ? "data-table-badge--global" : "data-table-badge--unknown"; - return html` - + return [ + html` onToggleSelect(row.key)} + .checked=${props.selectedKeys.has(row.key)} + @change=${() => props.onToggleSelect(row.key)} aria-label="Select session" /> @@ -485,9 +515,9 @@ function renderRow( ) { return; } - if (onNavigateToChat) { + if (props.onNavigateToChat) { e.preventDefault(); - onNavigateToChat(row.key); + props.onNavigateToChat(row.key); } }} >${row.key} { const value = (e.target as HTMLInputElement).value.trim(); - onPatch(row.key, { label: value || null }); + props.onPatch(row.key, { label: value || null }); }} /> @@ -515,13 +545,37 @@ function renderRow( ${updated} ${formatSessionTokens(row)} + +
+ + ${checkpointCount > 0 + ? `${checkpointCount} checkpoint${checkpointCount === 1 ? "" : "s"}` + : "none"} + + ${latestCheckpoint + ? html` + + ${formatCheckpointReason(latestCheckpoint.reason)} · + ${formatRelativeTimestamp(latestCheckpoint.createdAt)} + + ` + : nothing} + +
+ { const value = (e.target as HTMLSelectElement).value; - onPatch(row.key, { fastMode: value === "" ? null : value === "on" }); + props.onPatch(row.key, { fastMode: value === "" ? null : value === "on" }); }} > ${fastLevels.map( @@ -553,11 +607,11 @@ function renderRow( { const value = (e.target as HTMLSelectElement).value; - onPatch(row.key, { reasoningLevel: value || null }); + props.onPatch(row.key, { reasoningLevel: value || null }); }} > ${reasoningLevels.map( @@ -585,6 +639,77 @@ function renderRow( )} - - `; + `, + ...(isExpanded + ? [ + html` + +
+ ${props.checkpointLoadingKey === row.key + ? html`
Loading checkpoints…
` + : checkpointError + ? html`
${checkpointError}
` + : checkpointItems.length === 0 + ? html`
+ No compaction checkpoints recorded for this session. +
` + : html` +
+ ${checkpointItems.map( + (checkpoint) => html` +
+
+ + ${formatCheckpointReason(checkpoint.reason)} · + ${formatRelativeTimestamp(checkpoint.createdAt)} + + + ${formatCheckpointDelta(checkpoint)} + +
+ ${checkpoint.summary + ? html`
+ ${checkpoint.summary} +
` + : html`
No summary captured.
`} +
+ + +
+
+ `, + )} +
+ `} +
+ + `, + ] + : []), + ]; }