Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/OpenClaw.Shared/IOperatorGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ public interface IOperatorGatewayClient
// ─── Request Methods ───
Task SendChatMessageAsync(string message, string? sessionKey = null);
Task<ChatSendResult> SendChatMessageForRunAsync(string message, string? sessionKey = null);
/// <summary>
/// Fetches the normalized conversation transcript for a session
/// (<c>chat.history</c>). Ships with a default so adding it does not
/// source-break external implementers (test doubles); the real client
/// overrides it. Non-overriding clients fail explicitly instead of looking
/// like an empty transcript.
/// </summary>
Task<ChatHistoryInfo> RequestChatHistoryAsync(string? sessionKey = null, int timeoutMs = 15000)
=> Task.FromException<ChatHistoryInfo>(new NotSupportedException("chat.history is not supported by this gateway client."));
Task CheckHealthAsync();
Task RequestSessionsAsync(string? agentId = null);
Task RequestUsageAsync();
Expand Down
3 changes: 1 addition & 2 deletions src/OpenClaw.Shared/OpenClawGatewayClient.Protocol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -434,8 +434,7 @@ internal static SessionCompactionCheckpointList ParseCompactionCheckpointList(Js
{
if (item.ValueKind != JsonValueKind.Object) continue;
var checkpoint = ParseCompactionCheckpoint(item);
if (!string.IsNullOrEmpty(checkpoint.Id))
checkpoints.Add(checkpoint);
checkpoints.Add(checkpoint);
}
}

Expand Down
190 changes: 190 additions & 0 deletions src/OpenClaw.Shared/Sessions/SessionActionPlanner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
namespace OpenClaw.Shared.Sessions;

/// <summary>
/// The set of per-session lifecycle actions the UI can offer: open, reset
/// (new chat), compact, delete, export, plus compaction-checkpoint
/// branch/restore.
/// </summary>
public enum SessionActionKind
{
OpenChat,
Reset,
Compact,
Delete,
Export,
Branch,
Restore,
}

/// <summary>
/// The copy a confirmation dialog should display for a destructive or
/// context-altering session action.
/// </summary>
public sealed record SessionActionPrompt(
SessionActionKind Kind,
string SessionName,
string Title,
string Body,
string ConfirmLabel,
bool IsDestructive);

public enum SessionMainState
{
Main,
NotMain,
Unknown,
}

/// <summary>
/// Pure decision logic for session lifecycle actions, shared by the tray's
/// session menus (SessionsPage flyout + App tray/toast handlers) so every
/// entry point applies the same confirmation copy and main-session
/// protection. Contains no UI or gateway dependencies so it is unit testable.
/// </summary>
public static class SessionActionPlanner
{
/// <summary>
/// Destructive actions clear or remove conversation state and must always
/// be confirmed and styled as dangerous.
/// </summary>
public static bool IsDestructive(SessionActionKind kind) =>
kind is SessionActionKind.Reset
or SessionActionKind.Delete
or SessionActionKind.Restore;

/// <summary>
/// Actions that prompt before running. Compact is reversible-ish (it
/// archives rather than deletes) but still alters the live context, so it
/// is confirmed too. Restore rolls the live session back to a checkpoint
/// and is therefore confirmed.
/// </summary>
public static bool RequiresConfirmation(SessionActionKind kind) =>
kind is SessionActionKind.Reset
or SessionActionKind.Compact
or SessionActionKind.Delete
or SessionActionKind.Restore;

/// <summary>
/// Returns false when an action must not be offered for the given session.
/// The main session is the gateway's primary conversation: it cannot be
/// deleted and is not eligible for a destructive checkpoint restore.
/// Resetting, compacting, and branching it are allowed.
/// </summary>
public static bool IsAllowed(SessionActionKind kind, bool isMainSession, out string? blockedReason)
{
if (isMainSession && kind == SessionActionKind.Delete)
{
blockedReason = "The main session can't be deleted. Reset it instead to start fresh.";
return false;
}

if (isMainSession && kind == SessionActionKind.Restore)
{
blockedReason = "The main session can't be rolled back to a checkpoint. Branch from it instead.";
return false;
}

blockedReason = null;
return true;
}

public static bool IsAllowed(SessionActionKind kind, SessionMainState mainState, out string? blockedReason)
{
if (!IsAllowed(kind, mainState == SessionMainState.Main, out blockedReason))
return false;

if (mainState == SessionMainState.Unknown &&
kind is SessionActionKind.Delete or SessionActionKind.Restore)
{
blockedReason = "Session identity is still loading. Try again after sessions refresh.";
return false;
}

return true;
}

public static SessionMainState ResolveMainState(
string key,
bool? rowIsMain = null,
string? mainSessionKey = null,
IEnumerable<SessionInfo>? sessions = null)
{
if (IsMainSessionKeyShape(key)) return SessionMainState.Main;
if (rowIsMain == true) return SessionMainState.Main;

if (string.Equals(mainSessionKey, key, StringComparison.Ordinal)) return SessionMainState.Main;
if (!string.IsNullOrWhiteSpace(mainSessionKey)) return SessionMainState.NotMain;

var session = sessions?.FirstOrDefault(s => string.Equals(s.Key, key, StringComparison.Ordinal));
if (session?.IsMain == true) return SessionMainState.Main;
if (session is not null || rowIsMain.HasValue) return SessionMainState.NotMain;

return SessionMainState.Unknown;
}

public static bool IsMainSessionKeyShape(string key)
{
if (string.Equals(key, "main", StringComparison.Ordinal)) return true;
return key.EndsWith(":main", StringComparison.Ordinal) ||
key.Contains(":main:main", StringComparison.Ordinal);
}

/// <summary>
/// Builds the confirmation copy for an action, or <c>null</c> when the
/// action needs no confirmation. The session's friendly name is used when
/// available, falling back to the raw key.
/// </summary>
public static SessionActionPrompt? BuildPrompt(
SessionActionKind kind,
string sessionKey,
string? displayName,
bool isMainSession)
{
if (!RequiresConfirmation(kind))
return null;

var name = Describe(sessionKey, displayName);

return kind switch
{
SessionActionKind.Reset => new SessionActionPrompt(
kind,
name,
"Reset session?",
$"Start a fresh session for \u201C{name}\u201D? The current conversation context will be cleared.",
"Reset",
IsDestructive: true),

SessionActionKind.Compact => new SessionActionPrompt(
kind,
name,
"Compact session log?",
$"Keep the most recent messages for \u201C{name}\u201D and archive the rest. " +
"This creates a compaction checkpoint; export the transcript first if you need the full history.",
"Compact",
IsDestructive: false),

SessionActionKind.Delete => new SessionActionPrompt(
kind,
name,
"Delete session?",
$"Delete \u201C{name}\u201D and archive its transcript? It will be removed from the session list.",
"Delete",
IsDestructive: true),

SessionActionKind.Restore => new SessionActionPrompt(
kind,
name,
"Restore checkpoint?",
$"Roll \u201C{name}\u201D back to this compaction checkpoint? Messages added after the checkpoint will be archived.",
"Restore",
IsDestructive: true),

_ => null,
};
}

/// <summary>Friendly label for a session, preferring its display name.</summary>
public static string Describe(string sessionKey, string? displayName) =>
!string.IsNullOrWhiteSpace(displayName) ? displayName! : sessionKey;
}
30 changes: 30 additions & 0 deletions src/OpenClaw.Shared/Sessions/SessionCheckpointSelection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace OpenClaw.Shared.Sessions;

/// <summary>
/// Selection policy for compaction checkpoints. Branching can use a displayed
/// checkpoint directly, but restore is destructive, so it should only target a
/// checkpoint when the newest entry is unambiguous.
/// </summary>
public static class SessionCheckpointSelection
{
/// <summary>
/// Returns the checkpoint that is provably the most recent, or
/// <c>null</c> when that can't be established. Restore archives every
/// message after the checkpoint, so callers should refuse to restore when
/// this returns <c>null</c> rather than guessing.
/// </summary>
public static SessionCompactionCheckpoint? ResolveUnambiguousLatest(
IReadOnlyList<SessionCompactionCheckpoint> checkpoints)
{
if (checkpoints.Count == 0) return null;

if (checkpoints.Any(c => c.CreatedAt is null)) return null;

var ordered = checkpoints.OrderByDescending(c => c.CreatedAt!.Value).ToList();
var latest = ordered[0];
if (string.IsNullOrEmpty(latest.Id)) return null;
if (ordered.Count == 1) return latest;

return latest.CreatedAt!.Value > ordered[1].CreatedAt!.Value ? latest : null;
}
}
70 changes: 70 additions & 0 deletions src/OpenClaw.Shared/Sessions/SessionTranscriptFormatter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System;
using System.Text;

namespace OpenClaw.Shared.Sessions;

/// <summary>
/// Renders a <see cref="ChatHistoryInfo"/> transcript to plain text for the
/// "Export transcript" action, and suggests a filename. Pure and testable.
/// </summary>
public static class SessionTranscriptFormatter
{
/// <summary>
/// Formats the transcript as a readable plain-text document with a header
/// and one block per message (role, local timestamp, text).
/// </summary>
public static string Format(ChatHistoryInfo history)
{
if (history is null) throw new ArgumentNullException(nameof(history));

var nl = Environment.NewLine;
var sb = new StringBuilder();
sb.Append("OpenClaw session transcript").Append(nl);
sb.Append("Session: ").Append(string.IsNullOrWhiteSpace(history.SessionKey) ? "(unknown)" : history.SessionKey).Append(nl);
if (!string.IsNullOrWhiteSpace(history.SessionId))
sb.Append("Session ID: ").Append(history.SessionId).Append(nl);
sb.Append("Exported: ").Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")).Append(nl);
sb.Append("Messages: ").Append(history.Messages.Count).Append(nl);
sb.Append(new string('-', 40)).Append(nl);

foreach (var m in history.Messages)
{
var role = string.IsNullOrWhiteSpace(m.Role) ? "?" : m.Role;
sb.Append(nl).Append('[').Append(role);
if (m.Ts > 0)
{
var ts = DateTimeOffset.FromUnixTimeMilliseconds(m.Ts).LocalDateTime;
sb.Append(" \u00B7 ").Append(ts.ToString("yyyy-MM-dd HH:mm:ss"));
}
sb.Append(']').Append(nl);
sb.Append(m.Text ?? string.Empty).Append(nl);
}

return sb.ToString();
}

/// <summary>
/// A filesystem-safe suggested filename for the export, derived from the
/// session key and current date.
/// </summary>
public static string SuggestFileName(string? sessionKey)
{
var slug = Slugify(sessionKey);
return $"openclaw-transcript-{slug}-{DateTime.Now:yyyyMMdd-HHmmss}.txt";
}

private static string Slugify(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return "session";
var sb = new StringBuilder(value!.Length);
foreach (var ch in value)
{
if (char.IsLetterOrDigit(ch)) sb.Append(char.ToLowerInvariant(ch));
else if (ch is '-' or '_') sb.Append(ch);
else sb.Append('-');
}
var slug = sb.ToString().Trim('-');
if (slug.Length == 0) return "session";
return slug.Length > 48 ? slug[..48].Trim('-') : slug;
}
}
Loading
Loading