From 38e4414b7bc616357454cf8edfc26b67e3f16415 Mon Sep 17 00:00:00 2001 From: clawdbot Date: Sun, 15 Mar 2026 06:36:43 +0000 Subject: [PATCH 01/11] feat: add /set_topic_name for telegram topic labels --- src/auto-reply/commands-registry.data.ts | 15 ++++ src/auto-reply/reply/commands-core.ts | 2 + src/auto-reply/reply/commands-topic-name.ts | 90 +++++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 src/auto-reply/reply/commands-topic-name.ts diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 80f8d4bd73f..2965a0847d3 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -382,6 +382,21 @@ function buildChatCommands(): ChatCommandDefinition[] { textAlias: "/unfocus", category: "management", }), + defineChatCommand({ + key: "set_topic_name", + nativeName: "set_topic_name", + description: "Set the display label for the current Telegram topic.", + textAlias: "/set_topic_name", + category: "management", + args: [ + { + name: "name", + description: "Topic label", + type: "string", + captureRemaining: true, + }, + ], + }), defineChatCommand({ key: "agents", nativeName: "agents", diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 7a6cc36c05e..ac210e88f06 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -35,6 +35,7 @@ import { handleUsageCommand, } from "./commands-session.js"; import { handleSubagentsCommand } from "./commands-subagents.js"; +import { handleSetTopicNameCommand } from "./commands-topic-name.js"; import { handleTtsCommands } from "./commands-tts.js"; import type { CommandHandler, @@ -184,6 +185,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise" }; + } + return { ok: true, name: rest }; +} + +function resolveConversationLabel(params: Parameters[0]): string { + const base = + (typeof params.ctx.GroupSubject === "string" ? params.ctx.GroupSubject.trim() : "") || + (typeof params.ctx.ConversationLabel === "string" ? params.ctx.ConversationLabel.trim() : "") || + "telegram"; + return base; +} + +export const handleSetTopicNameCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) { + return null; + } + const parsed = parseTopicNameCommand(params.command.commandBodyNormalized); + if (!parsed) { + return null; + } + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /set_topic_name from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + if (!parsed.ok) { + return { shouldContinue: false, reply: { text: parsed.error } }; + } + if (!isTelegramSurface(params)) { + return { + shouldContinue: false, + reply: { text: "⚙️ /set_topic_name only works for Telegram topics." }, + }; + } + const threadId = params.ctx.MessageThreadId; + if (threadId == null || `${threadId}`.trim() === "") { + return { + shouldContinue: false, + reply: { text: "⚙️ /set_topic_name only works inside a Telegram topic." }, + }; + } + if (!params.sessionKey) { + return { + shouldContinue: false, + reply: { text: "⚙️ Could not resolve the current session key." }, + }; + } + const baseLabel = resolveConversationLabel(params); + const label = `telegram · ${baseLabel} · ${parsed.name}`; + + try { + await callGateway({ + method: "sessions.patch", + params: { + key: params.sessionKey, + label, + }, + timeoutMs: 10_000, + }); + } catch (err) { + return { + shouldContinue: false, + reply: { text: `❌ Failed to set topic name: ${String(err)}` }, + }; + } + + return { + shouldContinue: false, + reply: { text: `✅ Topic label set to ${label}.` }, + }; +}; From f34ff1399d4b7331541713a2565d3d3969d24f8b Mon Sep 17 00:00:00 2001 From: clawdbot Date: Sun, 15 Mar 2026 08:22:18 +0000 Subject: [PATCH 02/11] feat: add session archiving --- src/auto-reply/reply/session.ts | 4 + src/config/sessions/types.ts | 1 + src/gateway/protocol/schema/sessions.ts | 2 + src/gateway/server-methods/sessions.ts | 14 +++ src/gateway/session-utils.ts | 5 + src/gateway/session-utils.types.ts | 1 + src/gateway/sessions-patch.ts | 19 +++- src/sessions/archive-summary.ts | 135 ++++++++++++++++++++++++ ui/src/ui/app-render.ts | 11 ++ ui/src/ui/app-view-state.ts | 1 + ui/src/ui/app.ts | 1 + ui/src/ui/controllers/sessions.ts | 10 ++ ui/src/ui/types.ts | 1 + ui/src/ui/views/sessions.ts | 43 +++++++- 14 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 src/sessions/archive-summary.ts diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index a2c0b1c7cf4..34af2d881c8 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -439,7 +439,11 @@ export async function initSessionState(params: { lastTo, lastAccountId, lastThreadId, + archivedAt: baseEntry?.archivedAt, }; + if (typeof sessionEntry.archivedAt === "number") { + delete sessionEntry.archivedAt; + } const metaPatch = deriveSessionMetaPatch({ ctx: sessionCtxForState, sessionKey, diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 4ba9b336127..af8f8b11d9b 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -157,6 +157,7 @@ export type SessionEntry = { claudeCliSessionId?: string; label?: string; displayName?: string; + archivedAt?: number; channel?: string; groupId?: string; subject?: string; diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 743700b9a48..5bde1e1a859 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -7,6 +7,7 @@ export const SessionsListParamsSchema = Type.Object( activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), includeGlobal: Type.Optional(Type.Boolean()), includeUnknown: Type.Optional(Type.Boolean()), + includeArchived: Type.Optional(Type.Boolean()), /** * Read first 8KB of each session transcript to derive title from first user message. * Performs a file read per session - use `limit` to bound result set on large stores. @@ -86,6 +87,7 @@ export const SessionsPatchParamsSchema = Type.Object( groupActivation: Type.Optional( Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]), ), + archivedAt: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.Null()])), }, { additionalProperties: false }, ); diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index d5244116d33..cc4665fde52 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -8,6 +8,7 @@ import { updateSessionStore, } from "../../config/sessions.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; +import { writeSessionArchiveSummary } from "../../sessions/archive-summary.js"; import { GATEWAY_CLIENT_IDS } from "../protocol/client-info.js"; import { ErrorCodes, @@ -212,6 +213,19 @@ export const sessionsHandlers: GatewayRequestHandlers = { respond(false, undefined, applied.error); return; } + + const wasArchived = typeof applied.previous?.archivedAt === "number"; + const isArchived = typeof applied.entry.archivedAt === "number"; + if (!wasArchived && isArchived) { + void writeSessionArchiveSummary({ + cfg, + key: target.canonicalKey ?? key, + entry: applied.entry, + storePath, + archivedAt: applied.entry.archivedAt ?? undefined, + }); + } + const parsed = parseAgentSessionKey(target.canonicalKey ?? key); const agentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg)); const resolved = resolveSessionModelRef(cfg, applied.entry, agentId); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 00a2cb7747e..c428948dde0 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -854,6 +854,7 @@ export function listSessionsFromStore(params: { const includeGlobal = opts.includeGlobal === true; const includeUnknown = opts.includeUnknown === true; + const includeArchived = opts.includeArchived === true; const includeDerivedTitles = opts.includeDerivedTitles === true; const includeLastMessage = opts.includeLastMessage === true; const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : ""; @@ -889,6 +890,9 @@ export function listSessionsFromStore(params: { return true; }) .filter(([key, entry]) => { + if (!includeArchived && typeof entry?.archivedAt === "number") { + return false; + } if (!spawnedBy) { return true; } @@ -971,6 +975,7 @@ export function listSessionsFromStore(params: { lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel, lastTo: deliveryFields.lastTo ?? entry?.lastTo, lastAccountId: deliveryFields.lastAccountId ?? entry?.lastAccountId, + archivedAt: entry?.archivedAt ?? null, }; }) .toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 200df4459e9..a45569d84b9 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -49,6 +49,7 @@ export type GatewaySessionRow = { lastChannel?: SessionEntry["lastChannel"]; lastTo?: string; lastAccountId?: string; + archivedAt?: number | null; }; export type GatewayAgentRow = SharedGatewayAgentRow; diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 18b542302f6..5b1d9d8a21b 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -90,7 +90,9 @@ export async function applySessionsPatchToStore(params: { storeKey: string; patch: SessionsPatchParams; loadGatewayModelCatalog?: () => Promise; -}): Promise<{ ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape }> { +}): Promise< + { ok: true; entry: SessionEntry; previous?: SessionEntry } | { ok: false; error: ErrorShape } +> { const { cfg, store, storeKey, patch } = params; const now = Date.now(); const parsedAgent = parseAgentSessionKey(storeKey); @@ -235,6 +237,19 @@ export async function applySessionsPatchToStore(params: { } } + if ("archivedAt" in patch) { + const raw = patch.archivedAt; + if (raw === null) { + delete next.archivedAt; + } else if (raw !== undefined) { + const numeric = Number(raw); + if (!Number.isFinite(numeric) || numeric < 0) { + return invalid("invalid archivedAt (use epoch ms >= 0)"); + } + next.archivedAt = Math.floor(numeric); + } + } + if ("thinkingLevel" in patch) { const raw = patch.thinkingLevel; if (raw === null) { @@ -458,5 +473,5 @@ export async function applySessionsPatchToStore(params: { } store[storeKey] = next; - return { ok: true, entry: next }; + return { ok: true, entry: next, previous: existing }; } diff --git a/src/sessions/archive-summary.ts b/src/sessions/archive-summary.ts new file mode 100644 index 00000000000..76fea9066aa --- /dev/null +++ b/src/sessions/archive-summary.ts @@ -0,0 +1,135 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { SessionEntry } from "../config/sessions.js"; +import { readSessionMessages } from "../gateway/session-utils.fs.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; + +const log = createSubsystemLogger("sessions-archive"); + +function extractMessageText(message: unknown): string { + if (!message || typeof message !== "object") { + return ""; + } + const msg = message as { content?: unknown; text?: unknown; message?: unknown }; + if (typeof msg.text === "string") { + return msg.text; + } + const content = msg.content; + if (typeof content === "string") { + return content; + } + if (Array.isArray(content)) { + const parts = content + .map((part) => { + if (!part || typeof part !== "object") { + return ""; + } + const entry = part as { type?: string; text?: string }; + if (entry.type === "text" && typeof entry.text === "string") { + return entry.text; + } + return ""; + }) + .filter(Boolean); + return parts.join(" ").trim(); + } + return ""; +} + +function truncate(text: string, max = 200): string { + if (text.length <= max) { + return text; + } + return `${text.slice(0, max - 1).trim()}…`; +} + +function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); +} + +async function resolveUniquePath(dir: string, baseName: string): Promise { + let candidate = path.join(dir, baseName); + if (!(await fileExists(candidate))) { + return candidate; + } + for (let i = 2; i < 1000; i += 1) { + candidate = path.join(dir, baseName.replace(/\.md$/i, `-${i}.md`)); + if (!(await fileExists(candidate))) { + return candidate; + } + } + return path.join(dir, `${Date.now()}-${baseName}`); +} + +async function fileExists(filePath: string): Promise { + try { + await fs.stat(filePath); + return true; + } catch { + return false; + } +} + +export async function writeSessionArchiveSummary(params: { + cfg: OpenClawConfig; + key: string; + entry: SessionEntry; + storePath?: string; + archivedAt?: number | null; +}): Promise<{ ok: true; path: string } | { ok: false; reason: string }> { + const { cfg, key, entry, storePath } = params; + const archivedAt = typeof params.archivedAt === "number" ? params.archivedAt : Date.now(); + const sessionId = entry.sessionId; + if (!sessionId) { + return { ok: false, reason: "missing sessionId" }; + } + + const messages = readSessionMessages(sessionId, storePath, entry.sessionFile); + if (!messages.length) { + return { ok: false, reason: "no transcript" }; + } + + const userMessages = messages + .filter((msg) => (msg as { role?: string })?.role === "user") + .map((msg) => extractMessageText(msg)) + .filter((text) => text); + + const assistantMessages = messages + .filter((msg) => (msg as { role?: string })?.role === "assistant") + .map((msg) => extractMessageText(msg)) + .filter((text) => text); + + const firstUser = userMessages.slice(0, 3).map((text) => truncate(text)); + const lastUser = userMessages.slice(-3).map((text) => truncate(text)); + + const label = entry.label?.trim() || entry.displayName?.trim() || entry.origin?.label?.trim(); + const displayLabel = label || key; + const parsed = parseAgentSessionKey(key); + const agentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg)); + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + const date = new Date(archivedAt).toISOString().slice(0, 10); + const dir = path.join(workspaceDir, "memory"); + + const baseSlug = slugify(displayLabel) || "session"; + const keySlug = slugify(key) || "session"; + const baseName = `${date}-${baseSlug}--${keySlug}.md`; + + await fs.mkdir(dir, { recursive: true }); + const filePath = await resolveUniquePath(dir, baseName); + + const summary = `# Session Summary\n\n- Session: ${displayLabel}\n- Key: \`${key}\`\n- Archived at: ${new Date(archivedAt).toISOString()}\n- Messages: ${messages.length} total (${userMessages.length} user, ${assistantMessages.length} assistant)\n\n## First user messages\n${ + firstUser.length ? firstUser.map((line) => `- ${line}`).join("\n") : "- (none)" + }\n\n## Recent user messages\n${lastUser.length ? lastUser.map((line) => `- ${line}`).join("\n") : "- (none)"}\n`; + + await fs.writeFile(filePath, summary, "utf-8"); + log.info(`wrote archive summary to ${filePath}`); + return { ok: true, path: filePath }; +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 328f2cb6e33..f166fbdfef2 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -726,6 +726,7 @@ export function renderApp(state: AppViewState) { limit: state.sessionsFilterLimit, includeGlobal: state.sessionsIncludeGlobal, includeUnknown: state.sessionsIncludeUnknown, + includeArchived: state.sessionsIncludeArchived, basePath: state.basePath, searchQuery: state.sessionsSearchQuery, sortColumn: state.sessionsSortColumn, @@ -738,6 +739,16 @@ export function renderApp(state: AppViewState) { state.sessionsFilterLimit = next.limit; state.sessionsIncludeGlobal = next.includeGlobal; state.sessionsIncludeUnknown = next.includeUnknown; + state.sessionsIncludeArchived = next.includeArchived; + const activeMinutes = Number(next.activeMinutes); + const limit = Number(next.limit); + void loadSessions(state, { + activeMinutes: Number.isFinite(activeMinutes) ? activeMinutes : 0, + limit: Number.isFinite(limit) ? limit : 0, + includeGlobal: next.includeGlobal, + includeUnknown: next.includeUnknown, + includeArchived: next.includeArchived, + }); }, onSearchChange: (q) => { state.sessionsSearchQuery = q; diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index ad2910625b6..e420023407c 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -184,6 +184,7 @@ export type AppViewState = { sessionsFilterLimit: string; sessionsIncludeGlobal: boolean; sessionsIncludeUnknown: boolean; + sessionsIncludeArchived: boolean; sessionsHideCron: boolean; sessionsSearchQuery: string; sessionsSortColumn: "key" | "kind" | "updated" | "tokens"; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 1b3971a41f6..0598d8200a6 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -282,6 +282,7 @@ export class OpenClawApp extends LitElement { @state() sessionsFilterLimit = "120"; @state() sessionsIncludeGlobal = true; @state() sessionsIncludeUnknown = false; + @state() sessionsIncludeArchived = false; @state() sessionsHideCron = true; @state() sessionsSearchQuery = ""; @state() sessionsSortColumn: "key" | "kind" | "updated" | "tokens" = "updated"; diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index c1d2f44d20c..163af12a5f8 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -12,6 +12,7 @@ export type SessionsState = { sessionsFilterLimit: string; sessionsIncludeGlobal: boolean; sessionsIncludeUnknown: boolean; + sessionsIncludeArchived: boolean; }; export async function loadSessions( @@ -21,6 +22,7 @@ export async function loadSessions( limit?: number; includeGlobal?: boolean; includeUnknown?: boolean; + includeArchived?: boolean; }, ) { if (!state.client || !state.connected) { @@ -34,12 +36,16 @@ export async function loadSessions( try { const includeGlobal = overrides?.includeGlobal ?? state.sessionsIncludeGlobal; const includeUnknown = overrides?.includeUnknown ?? state.sessionsIncludeUnknown; + const includeArchived = overrides?.includeArchived ?? state.sessionsIncludeArchived; const activeMinutes = overrides?.activeMinutes ?? toNumber(state.sessionsFilterActive, 0); const limit = overrides?.limit ?? toNumber(state.sessionsFilterLimit, 0); const params: Record = { includeGlobal, includeUnknown, }; + if (includeArchived) { + params.includeArchived = true; + } if (activeMinutes > 0) { params.activeMinutes = activeMinutes; } @@ -66,6 +72,7 @@ export async function patchSession( fastMode?: boolean | null; verboseLevel?: string | null; reasoningLevel?: string | null; + archivedAt?: number | null; }, ) { if (!state.client || !state.connected) { @@ -87,6 +94,9 @@ export async function patchSession( if ("reasoningLevel" in patch) { params.reasoningLevel = patch.reasoningLevel; } + if ("archivedAt" in patch) { + params.archivedAt = patch.archivedAt; + } try { await state.client.request("sessions.patch", params); await loadSessions(state); diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index d9764a024e6..7b1787d6d7d 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -389,6 +389,7 @@ export type GatewaySessionRow = { model?: string; modelProvider?: string; contextTokens?: number; + archivedAt?: number | null; }; export type SessionsListResult = SessionsListResultBase; diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 2620ec35acf..8a0e433528a 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -13,6 +13,7 @@ export type SessionsProps = { limit: string; includeGlobal: boolean; includeUnknown: boolean; + includeArchived: boolean; basePath: string; searchQuery: string; sortColumn: "key" | "kind" | "updated" | "tokens"; @@ -25,6 +26,7 @@ export type SessionsProps = { limit: string; includeGlobal: boolean; includeUnknown: boolean; + includeArchived: boolean; }) => void; onSearchChange: (query: string) => void; onSortChange: (column: "key" | "kind" | "updated" | "tokens", dir: "asc" | "desc") => void; @@ -179,7 +181,10 @@ function paginateRows(rows: T[], page: number, pageSize: number): T[] { export function renderSessions(props: SessionsProps) { const rawRows = props.result?.sessions ?? []; - const filtered = filterRows(rawRows, props.searchQuery); + const visibleRows = props.includeArchived + ? rawRows + : rawRows.filter((row) => typeof row.archivedAt !== "number"); + const filtered = filterRows(visibleRows, props.searchQuery); const sorted = sortRows(filtered, props.sortColumn, props.sortDir); const totalRows = sorted.length; const totalPages = Math.max(1, Math.ceil(totalRows / props.pageSize)); @@ -237,6 +242,7 @@ export function renderSessions(props: SessionsProps) { limit: props.limit, includeGlobal: props.includeGlobal, includeUnknown: props.includeUnknown, + includeArchived: props.includeArchived, })} /> @@ -251,6 +257,7 @@ export function renderSessions(props: SessionsProps) { limit: (e.target as HTMLInputElement).value, includeGlobal: props.includeGlobal, includeUnknown: props.includeUnknown, + includeArchived: props.includeArchived, })} /> @@ -264,6 +271,7 @@ export function renderSessions(props: SessionsProps) { limit: props.limit, includeGlobal: (e.target as HTMLInputElement).checked, includeUnknown: props.includeUnknown, + includeArchived: props.includeArchived, })} /> Global @@ -278,10 +286,26 @@ export function renderSessions(props: SessionsProps) { limit: props.limit, includeGlobal: props.includeGlobal, includeUnknown: (e.target as HTMLInputElement).checked, + includeArchived: props.includeArchived, })} /> Unknown + ${ @@ -425,6 +449,7 @@ function renderRow( : row.kind === "global" ? "data-table-badge--global" : "data-table-badge--unknown"; + const isArchived = typeof row.archivedAt === "number"; return html` @@ -436,6 +461,13 @@ function renderRow( ? html`${displayName}` : nothing } + ${ + isArchived + ? html` + Archived + ` + : nothing + } @@ -555,6 +587,15 @@ function renderRow( ` : nothing } +