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
This commit is contained in:
zhixian 2026-01-26 19:37:04 +09:00
parent f29f51569a
commit c5ddd12612
3 changed files with 68 additions and 0 deletions

View File

@ -7,6 +7,7 @@ import { resolveModelRefFromString } from "../../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../../agents/workspace.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../../agents/workspace.js";
import { type ClawdbotConfig, loadConfig } from "../../config/config.js"; import { type ClawdbotConfig, loadConfig } from "../../config/config.js";
import { deleteSessionEntry, resolveSessionTranscriptPath } from "../../config/sessions.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { resolveCommandAuthorization } from "../command-auth.js"; import { resolveCommandAuthorization } from "../command-auth.js";
import type { MsgContext } from "../templating.js"; import type { MsgContext } from "../templating.js";
@ -116,6 +117,7 @@ export async function getReplyFromConfig(
sessionId, sessionId,
isNewSession, isNewSession,
resetTriggered, resetTriggered,
pruneRequested,
systemSent, systemSent,
abortedLastRun, abortedLastRun,
storePath, storePath,
@ -126,6 +128,19 @@ export async function getReplyFromConfig(
bodyStripped, bodyStripped,
} = sessionState; } = 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({ await applyResetModelOverride({
cfg, cfg,
resetTriggered, resetTriggered,

View File

@ -43,6 +43,8 @@ export type SessionInitResult = {
sessionId: string; sessionId: string;
isNewSession: boolean; isNewSession: boolean;
resetTriggered: boolean; resetTriggered: boolean;
/** When true, the session entry and transcript should be deleted after reset. */
pruneRequested: boolean;
systemSent: boolean; systemSent: boolean;
abortedLastRun: boolean; abortedLastRun: boolean;
storePath: string; storePath: string;
@ -125,6 +127,7 @@ export async function initSessionState(params: {
let systemSent = false; let systemSent = false;
let abortedLastRun = false; let abortedLastRun = false;
let resetTriggered = false; let resetTriggered = false;
let pruneRequested = false;
let persistedThinking: string | undefined; let persistedThinking: string | undefined;
let persistedVerbose: 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); sessionKey = resolveSessionKey(sessionScope, sessionCtxForState, mainKey);
const entry = sessionStore[sessionKey]; const entry = sessionStore[sessionKey];
const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined; const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined;
@ -364,6 +373,7 @@ export async function initSessionState(params: {
sessionId: sessionId ?? crypto.randomUUID(), sessionId: sessionId ?? crypto.randomUUID(),
isNewSession, isNewSession,
resetTriggered, resetTriggered,
pruneRequested,
systemSent, systemSent,
abortedLastRun, abortedLastRun,
storePath, storePath,

View File

@ -438,3 +438,46 @@ export async function updateLastRoute(params: {
return next; 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 };
}