From c5ddd126128f614308ed0fa4c46a00c220b158be Mon Sep 17 00:00:00 2001 From: zhixian Date: Mon, 26 Jan 2026 19:37:04 +0900 Subject: [PATCH] feat: add /reset prune to delete session entry and transcript When user sends '/reset prune', the session entry is removed from the session store and the transcript .jsonl file is deleted. This is useful when deleting Telegram DM topics - users can first prune the session to avoid orphaned session files. Changes: - Add pruneRequested flag to SessionInitResult - Detect 'prune' argument after reset trigger - Add deleteSessionEntry() function to store.ts - Handle prune in get-reply.ts before normal reset flow --- src/auto-reply/reply/get-reply.ts | 15 +++++++++++ src/auto-reply/reply/session.ts | 10 +++++++ src/config/sessions/store.ts | 43 +++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index f6259d738..36b1beb1f 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -7,6 +7,7 @@ import { resolveModelRefFromString } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../../agents/workspace.js"; import { type ClawdbotConfig, loadConfig } from "../../config/config.js"; +import { deleteSessionEntry, resolveSessionTranscriptPath } from "../../config/sessions.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveCommandAuthorization } from "../command-auth.js"; import type { MsgContext } from "../templating.js"; @@ -116,6 +117,7 @@ export async function getReplyFromConfig( sessionId, isNewSession, resetTriggered, + pruneRequested, systemSent, abortedLastRun, storePath, @@ -126,6 +128,19 @@ export async function getReplyFromConfig( bodyStripped, } = sessionState; + // Handle /reset prune - delete session entry and transcript, then return confirmation + if (resetTriggered && pruneRequested && previousSessionEntry) { + const transcriptPath = previousSessionEntry.sessionId + ? resolveSessionTranscriptPath(previousSessionEntry.sessionId, agentId) + : undefined; + await deleteSessionEntry({ + storePath, + sessionKey, + transcriptPath, + }); + return { text: "✅ Session pruned." }; + } + await applyResetModelOverride({ cfg, resetTriggered, diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 45f37afdb..9045b5c61 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -43,6 +43,8 @@ export type SessionInitResult = { sessionId: string; isNewSession: boolean; resetTriggered: boolean; + /** When true, the session entry and transcript should be deleted after reset. */ + pruneRequested: boolean; systemSent: boolean; abortedLastRun: boolean; storePath: string; @@ -125,6 +127,7 @@ export async function initSessionState(params: { let systemSent = false; let abortedLastRun = false; let resetTriggered = false; + let pruneRequested = false; let persistedThinking: string | undefined; let persistedVerbose: string | undefined; @@ -186,6 +189,12 @@ export async function initSessionState(params: { } } + // Check if prune was requested (e.g., "/reset prune") + if (resetTriggered && bodyStripped?.toLowerCase() === "prune") { + pruneRequested = true; + bodyStripped = ""; // Clear the body since "prune" is a directive, not a message + } + sessionKey = resolveSessionKey(sessionScope, sessionCtxForState, mainKey); const entry = sessionStore[sessionKey]; const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined; @@ -364,6 +373,7 @@ export async function initSessionState(params: { sessionId: sessionId ?? crypto.randomUUID(), isNewSession, resetTriggered, + pruneRequested, systemSent, abortedLastRun, storePath, diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index f0e516476..9b986e078 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -438,3 +438,46 @@ export async function updateLastRoute(params: { return next; }); } + +/** + * Delete a session entry from the store and optionally remove its transcript file. + * Used by `/reset prune` to fully remove a session. + */ +export async function deleteSessionEntry(params: { + storePath: string; + sessionKey: string; + /** Path to the transcript file to delete (optional). */ + transcriptPath?: string; +}): Promise<{ deleted: boolean; transcriptDeleted: boolean }> { + const { storePath, sessionKey, transcriptPath } = params; + let deleted = false; + let transcriptDeleted = false; + + await withSessionStoreLock(storePath, async () => { + const store = loadSessionStore(storePath, { skipCache: true }); + if (sessionKey in store) { + delete store[sessionKey]; + await saveSessionStoreUnlocked(storePath, store); + deleted = true; + } + }); + + // Delete transcript file outside the lock (it's a separate file) + if (transcriptPath) { + try { + await fs.promises.unlink(transcriptPath); + transcriptDeleted = true; + } catch (err) { + const code = + err && typeof err === "object" && "code" in err + ? String((err as { code?: unknown }).code) + : null; + // Ignore if file doesn't exist + if (code !== "ENOENT") { + throw err; + } + } + } + + return { deleted, transcriptDeleted }; +}