Skip to content
Merged
94 changes: 94 additions & 0 deletions .ralphex/plans/081-reset-restart-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Plan: /clean for full context wipe, rename /reset to /reconnect

GitHub issue: #81

## Problem

Current `/reset` kills subprocess but keeps session file. Resume brings back compacted history. No way to get a clean slate.

## Changes

### 1. session-manager.ts — add `destroySession()` method

Add a new public method that closes the session AND deletes stored state:

```ts
async destroySession(chatId: string): Promise<void> {
await this.closeSession(chatId);
this.store.deleteSession(chatId);
}
```

`closeSession()` already handles: kill subprocess, clear idle timer, clean outbox/inject dirs, remove from active map. `destroySession()` adds: delete from store so no `--resume` happens.

- [x] Add `destroySession()` method to SessionManager

### 2. telegram-bot.ts — rename `/reset` to `/reconnect`, add `/clean`

Command list (line ~29):
```ts
{ command: "reconnect", description: "Reconnect session (keeps context)" },
{ command: "clean", description: "Clean session (fresh start)" },
```

Remove the old "reset" entry from command list.

Rename existing handler at line 588 from `bot.command("reset", ...)` to `bot.command("reconnect", ...)`.
Keep reply: `"Session restarted. Prior context may be partially retained."`

Add new `/clean` handler:
```ts
bot.command("clean", async (ctx) => {
const topicId = ctx.message?.message_thread_id;
if (ctx.message) setThread(ctx.chat.id, ctx.message.message_id, topicId);
const binding = resolveBinding(ctx.chat.id, config.bindings, topicId);
if (!binding) return;
if (ctx.message && isStaleMessage(ctx.message.date * 1000, maxMessageAgeMs)) {
log.debug("telegram-bot", `Discarding stale /clean for chat ${ctx.chat.id}`);
return;
}
const key = sessionKey(ctx.chat.id, topicId);
messageQueue.clear(key);
await sessionManager.destroySession(key);
await ctx.reply("Session cleaned. Fresh start.");
});
```

- [x] Update command list: replace "reset" with "reconnect" and "clean"
- [x] Rename existing reset handler to "reconnect"
- [x] Add new "/clean" handler calling `destroySession()`

### 3. discord-bot.ts — same changes

Rename slash command from "reset" to "reconnect", add new "clean" slash command.
Update handlers accordingly.

- [x] Update Discord slash commands
- [x] Update Discord handlers

### 4. Error messages — update references

In `message-queue.ts` and `session-manager.ts`, error messages say "use /reset". Change to "/reconnect" since users hitting errors want to retry with context, not nuke everything.

Files:
- `bot/src/message-queue.ts` lines 206, 282 — change `/reset` to `/reconnect`
- `bot/src/session-manager.ts` line 180 — change `/reset` to `/reconnect`

- [x] Update error messages to reference /reconnect

### 5. Tests

- [x] Update telegram-bot.test.ts command list assertion to include "reconnect" and "clean" instead of "reset"
- [x] Add test: `/reconnect` calls `closeSession()` (not `destroySession()`)
- [x] Add test: `/clean` calls `destroySession()`
- [x] Add test: `destroySession()` calls `closeSession()` then `deleteSession()` on store
- [x] Update any existing reset tests to use "reconnect"
- [x] Update Discord tests if they exist

## Checklist

- [x] `destroySession()` in session-manager.ts
- [x] Telegram: rename to /reconnect, add /clean
- [x] Discord: rename to /reconnect, add /clean
- [x] Error messages → /reconnect
- [x] Tests pass
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,13 @@ Initial public release of Minime — a multi-platform bot that routes messages t
- Reaction event logging to JSONL
- In-memory message-thread cache with disk persistence for reaction routing
- Message content index for reaction context lookups
- Bot commands: `/start`, `/reset`, `/status`
- Bot commands: `/start`, `/reconnect`, `/clean`, `/status`

### Discord Features

- Guild-wide bindings with per-channel overrides
- Thread support with automatic parent channel binding inheritance
- Slash commands (`/start`, `/reset`, `/status`) registered per-guild on startup
- Slash commands (`/start`, `/reconnect`, `/clean`, `/status`) registered per-guild on startup
- File attachment handling and voice transcription
- Mention detection with configurable `requireMention`

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ To remove: `launchctl bootout gui/$(id -u)/ai.minime.cron.<name>`, delete from `

See [config.yaml](config.yaml) for per-channel overrides and guild-wide defaults.

3. Required bot permissions/intents: Guilds, GuildMessages, MessageContent (privileged), DirectMessages. Slash commands (`/start`, `/reset`, `/status`) are registered per-guild on startup.
3. Required bot permissions/intents: Guilds, GuildMessages, MessageContent (privileged), DirectMessages. Slash commands (`/start`, `/reconnect`, `/clean`, `/status`) are registered per-guild on startup.

`telegramTokenService` is optional — the bot can run Discord-only.

Expand Down
100 changes: 100 additions & 0 deletions bot/src/__tests__/session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,106 @@ describe("SessionManager", () => {
await manager.closeSession("nonexistent");
});

it("destroySession is safe for unknown chatId", async () => {
const { SessionManager } = await import("../session-manager.js");
const manager = new SessionManager(testConfig, TEST_STORE_PATH);
// Should not throw
await manager.destroySession("nonexistent");
});

it("closeSession preserves stored state (reconnect can resume)", async () => {
const { SessionManager } = await import("../session-manager.js");
const { SessionStore } = await import("../session-store.js");

// Pre-populate store with a session
const store = new SessionStore(TEST_STORE_PATH);
store.setSession("chat-reconnect", {
sessionId: "reconnect-session-id",
chatId: "chat-reconnect",
agentId: "main",
lastActivity: Date.now(),
});

const manager = new SessionManager(testConfig, TEST_STORE_PATH);
await manager.closeSession("chat-reconnect");

// Stored state should still exist — /reconnect preserves it for resume
const storeAfter = new SessionStore(TEST_STORE_PATH);
assert.ok(storeAfter.getSession("chat-reconnect"), "closeSession should preserve stored state");

// resolveStoredSession should find the stored session and allow resume
const result = manager.resolveStoredSession("chat-reconnect", "main");
assert.strictEqual(result.resume, true, "closed session should resume on next message");
});

it("destroySession closes session and deletes stored state", async () => {
const { SessionManager } = await import("../session-manager.js");
const { SessionStore } = await import("../session-store.js");

// Pre-populate store with a session
const store = new SessionStore(TEST_STORE_PATH);
store.setSession("chat-destroy", {
sessionId: "destroy-session-id",
chatId: "chat-destroy",
agentId: "main",
lastActivity: Date.now(),
});
// Also store another session that should NOT be affected
store.setSession("chat-keep", {
sessionId: "keep-session-id",
chatId: "chat-keep",
agentId: "main",
lastActivity: Date.now(),
});

const manager = new SessionManager(testConfig, TEST_STORE_PATH);

await manager.destroySession("chat-destroy");

// Verify stored state was deleted
const storeAfter = new SessionStore(TEST_STORE_PATH);
assert.strictEqual(storeAfter.getSession("chat-destroy"), undefined, "destroyed session should be removed from store");
assert.ok(storeAfter.getSession("chat-keep"), "other sessions should be unaffected");

// Verify resolveStoredSession returns fresh (no resume)
const result = manager.resolveStoredSession("chat-destroy", "main");
assert.strictEqual(result.resume, false, "destroyed session should not resume");
});

it("destroySession deletes state that closeSession would preserve", async () => {
const { SessionManager } = await import("../session-manager.js");
const { SessionStore } = await import("../session-store.js");

// Pre-populate store with two sessions
const store = new SessionStore(TEST_STORE_PATH);
const now = Date.now();
store.setSession("chat-close", {
sessionId: "close-sid",
chatId: "chat-close",
agentId: "main",
lastActivity: now,
});
store.setSession("chat-destroy", {
sessionId: "destroy-sid",
chatId: "chat-destroy",
agentId: "main",
lastActivity: now,
});

const manager = new SessionManager(testConfig, TEST_STORE_PATH);

// closeSession (what /reconnect calls) — preserves store
await manager.closeSession("chat-close");
// destroySession (what /clean calls) — deletes from store
await manager.destroySession("chat-destroy");

const closeResult = manager.resolveStoredSession("chat-close", "main");
const destroyResult = manager.resolveStoredSession("chat-destroy", "main");

assert.strictEqual(closeResult.resume, true, "/reconnect: session resumes");
assert.strictEqual(destroyResult.resume, false, "/clean: session starts fresh");
});

it("throws for unknown agent", async () => {
const { SessionManager } = await import("../session-manager.js");
const manager = new SessionManager(testConfig, TEST_STORE_PATH);
Expand Down
99 changes: 95 additions & 4 deletions bot/src/__tests__/telegram-bot.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
process.env.TZ = "UTC";
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { resolveBinding, isAuthorized, sessionKey, isImageMimeType, imageExtensionForMime, buildSourcePrefix, shouldRespondInGroup, BOT_COMMANDS, isStaleMessage, buildReplyContext, buildForwardContext, extensionForDocument, formatFileSize, formatDocumentMeta, buildReactionContext, AUTO_RETRY_OPTIONS, extractMediaInfo, extensionForMedia, formatMediaMeta } from "../telegram-bot.js";
import type { TelegramBinding } from "../types.js";
import { resolveBinding, isAuthorized, sessionKey, isImageMimeType, imageExtensionForMime, buildSourcePrefix, shouldRespondInGroup, BOT_COMMANDS, isStaleMessage, buildReplyContext, buildForwardContext, extensionForDocument, formatFileSize, formatDocumentMeta, buildReactionContext, AUTO_RETRY_OPTIONS, extractMediaInfo, extensionForMedia, formatMediaMeta, createTelegramBot } from "../telegram-bot.js";
import type { TelegramBinding, BotConfig } from "../types.js";
import type { SessionManager } from "../session-manager.js";

const testBindings: TelegramBinding[] = [
{ chatId: 111111111, agentId: "main", kind: "dm", label: "User1 DM" },
Expand Down Expand Up @@ -191,9 +192,9 @@ describe("isImageMimeType", () => {
});

describe("BOT_COMMANDS", () => {
it("contains start, reset, and status commands", () => {
it("contains start, reconnect, clean, and status commands", () => {
const names = BOT_COMMANDS.map((c) => c.command);
assert.deepStrictEqual(names, ["start", "reset", "status"]);
assert.deepStrictEqual(names, ["start", "reconnect", "clean", "status"]);
});

it("each command has a non-empty description", () => {
Expand Down Expand Up @@ -1257,3 +1258,93 @@ describe("formatMediaMeta", () => {
);
});
});

describe("command handler wiring", () => {
const testChatId = 111111111;

const handlerConfig: BotConfig = {
telegramToken: "test:fake-token-for-handler-tests",
agents: {
main: { id: "main", workspaceCwd: "/tmp/test", model: "claude-opus-4-6" },
},
bindings: [
{ chatId: testChatId, agentId: "main", kind: "dm" as const },
],
sessionDefaults: {
idleTimeoutMs: 60000,
maxConcurrentSessions: 2,
maxMessageAgeMs: 300000,
requireMention: false,
},
};

function createMockSessionManager(): SessionManager & { calls: string[] } {
const calls: string[] = [];
return {
calls,
closeSession: async (_chatId: string) => { calls.push("closeSession"); },
destroySession: async (_chatId: string) => { calls.push("destroySession"); },
sendSessionMessage: () => { throw new Error("unexpected"); },
getOrCreateSession: async () => { throw new Error("unexpected"); },
closeAll: async () => {},
resolveStoredSession: () => ({ resume: false }),
activeCount: () => 0,
getActiveSession: () => undefined,
isActive: () => false,
} as unknown as SessionManager & { calls: string[] };
}

function makeCommandUpdate(command: string, updateId: number) {
const text = `/${command}`;
return {
update_id: updateId,
message: {
message_id: updateId,
from: { id: testChatId, is_bot: false, first_name: "Test" },
chat: { id: testChatId, type: "private" as const, first_name: "Test" },
date: Math.floor(Date.now() / 1000),
text,
entities: [{ offset: 0, length: text.length, type: "bot_command" as const }],
},
};
}

function initBot(mockSM: SessionManager) {
const { bot } = createTelegramBot(handlerConfig, mockSM);
// Intercept all API calls so nothing reaches Telegram
bot.api.config.use(async (_prev, _method, _payload) => ({ ok: true, result: true } as any));
// Provide bot info so handleUpdate works without calling getMe
bot.botInfo = {
id: 999,
is_bot: true,
first_name: "TestBot",
username: "test_bot",
can_join_groups: false,
can_read_all_group_messages: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: false,
allows_users_to_create_topics: false,
};
return bot;
}

it("/reconnect calls closeSession (not destroySession)", async () => {
const mockSM = createMockSessionManager();
const bot = initBot(mockSM);

await bot.handleUpdate(makeCommandUpdate("reconnect", 1));
assert.ok(mockSM.calls.includes("closeSession"), "/reconnect should call closeSession");
assert.ok(!mockSM.calls.includes("destroySession"), "/reconnect should NOT call destroySession");
});

it("/clean calls destroySession (not closeSession directly)", async () => {
const mockSM = createMockSessionManager();
const bot = initBot(mockSM);

await bot.handleUpdate(makeCommandUpdate("clean", 2));
assert.ok(mockSM.calls.includes("destroySession"), "/clean should call destroySession");
assert.ok(!mockSM.calls.includes("closeSession"), "/clean handler calls destroySession, not closeSession directly");
});
});
15 changes: 12 additions & 3 deletions bot/src/discord-bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,17 +339,25 @@ export async function createDiscordBot(
);
break;
}
// Session lifecycle: create → compact → reset → resume. The reset
// Session lifecycle: create → compact → reconnect → resume. Reconnect
// kills the Claude subprocess but the session file (with compacted
// conversation history) remains on disk. When the next message arrives,
// getOrCreateSession() finds the file and resumes with --resume, so
// prior context may be partially retained through the compaction summary.
case "reset": {
case "reconnect": {
messageQueue.clear(key);
await sessionManager.closeSession(key);
await interaction.reply("Session restarted. Prior context may be partially retained.");
break;
}
// Clean destroys the session entirely — subprocess killed AND stored
// state deleted. Next message starts a brand new session with no history.
case "clean": {
messageQueue.clear(key);
await sessionManager.destroySession(key);
await interaction.reply("Session cleaned. Fresh start.");
break;
}
case "status": {
const activeCount = sessionManager.getActiveCount();
const memUsage = process.memoryUsage();
Expand Down Expand Up @@ -410,7 +418,8 @@ export async function createDiscordBot(
// Register guild-scoped slash commands (instant, no 1-hour propagation delay)
const commands = [
new SlashCommandBuilder().setName("start").setDescription("Start the bot"),
new SlashCommandBuilder().setName("reset").setDescription("Reset current session"),
new SlashCommandBuilder().setName("reconnect").setDescription("Reconnect session (keeps context)"),
new SlashCommandBuilder().setName("clean").setDescription("Clean session (fresh start)"),
new SlashCommandBuilder().setName("status").setDescription("Show bot status"),
];
const rest = new REST().setToken(discordConfig.token);
Expand Down
Loading
Loading