From 7f6ec2ca20afe4aa72fe3d174d884a768a15b4a9 Mon Sep 17 00:00:00 2001 From: Unknown_Anonymous Date: Sat, 13 Jun 2026 15:09:59 +0800 Subject: [PATCH] feat: add /resume command and fix /compact to preserve session ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /resume (no args): lists recent conversations for the current working directory, showing custom /rename titles, first user message, timestamp, and message count — matching what Claude Code's own /resume picker shows. /resume : switches the active session to entry N from the list. /resume : switches by full session ID. Session labels are read directly from the .jsonl transcript files so custom-title (set by /rename) takes priority; falls back to the first real user message, then the summary field. /compact is rewritten to call `claude -p /compact --resume ` instead of clearing the session ID. This triggers Claude Code's native compaction: the conversation is summarised in-place, token usage drops dramatically, and the session ID is preserved — the next message continues the same session with full context compressed. The stream-json output is parsed for the compact_boundary event to extract pre/post token counts, which are reported back to WeChat. --- src/claude/provider.ts | 8 ++ src/commands/handlers.ts | 172 ++++++++++++++++++++++++++++++++++++--- src/commands/router.ts | 5 +- src/main.ts | 60 ++++++++++++++ 4 files changed, 231 insertions(+), 14 deletions(-) diff --git a/src/claude/provider.ts b/src/claude/provider.ts index fedb7a3..3f188c3 100644 --- a/src/claude/provider.ts +++ b/src/claude/provider.ts @@ -184,6 +184,14 @@ export async function claudeQuery(options: QueryOptions): Promise { if (obj.subtype === 'init' && obj.session_id) { sessionId = obj.session_id; } + // compact completed — treat empty result as success + if (obj.subtype === 'compact_boundary') { + const pre = obj.compact_metadata?.pre_tokens ?? 0; + const post = obj.compact_metadata?.post_tokens ?? 0; + if (pre > 0) { + textParts.push(`__compact__:${pre}:${post}`); + } + } break; } case 'assistant': { diff --git a/src/commands/handlers.ts b/src/commands/handlers.ts index 25586df..692ef24 100644 --- a/src/commands/handlers.ts +++ b/src/commands/handlers.ts @@ -15,9 +15,11 @@ const HELP_TEXT = `可用命令: /clear 清除当前会话 /reset 完全重置(包括工作目录等设置) /status 查看当前会话状态 - /compact 压缩上下文(开始新 SDK 会话,保留历史) + /compact 压缩上下文(保持当前对话,大幅减少 token 占用) /history [数量] 查看对话记录(默认最近20条) /undo [数量] 撤销最近对话(默认1条) + /resume 列出当前目录的历史对话 + /resume <编号> 恢复指定编号的历史对话 文件: /send <路径> 发送本地文件(图片直接显示,其他文件作为附件) @@ -130,20 +132,12 @@ export function handleReset(ctx: CommandContext): CommandResult { return { reply: '✅ 会话已完全重置,所有设置恢复默认。', handled: true }; } -/** 压缩上下文 — 清除 SDK 会话 ID,开始新上下文但保留聊天历史 */ +/** 压缩上下文 — 通过原生 /compact 命令压缩当前 session,保持 session ID 不变 */ export function handleCompact(ctx: CommandContext): CommandResult { - const currentSessionId = ctx.session.sdkSessionId; - if (!currentSessionId) { - return { reply: 'ℹ️ 当前没有活动的 SDK 会话,无需压缩。', handled: true }; + if (!ctx.session.sdkSessionId) { + return { reply: 'ℹ️ 当前没有活动的对话,无需压缩。', handled: true }; } - ctx.updateSession({ - previousSdkSessionId: currentSessionId, - sdkSessionId: undefined, - }); - return { - reply: '✅ 上下文已压缩\n\n下次消息将开始新的 SDK 会话(token 清零)\n聊天历史已保留,可用 /history 查看', - handled: true, - }; + return { handled: true, compactSession: true }; } /** 撤销最近 N 条对话 */ @@ -217,6 +211,158 @@ export function handleSend(ctx: CommandContext, args: string): CommandResult { return { handled: true, sendFile: resolved }; } +interface SessionIndexEntry { + sessionId: string; + fullPath: string; + summary: string; + firstPrompt: string; + created: string; + modified: string; + messageCount: number; + gitBranch: string; +} + +interface SessionIndex { + version: number; + entries: SessionIndexEntry[]; + originalPath: string; +} + +function cwdToProjectSlug(cwd: string): string { + // Claude Code converts the full path to a slug by replacing every non-alphanumeric + // character (slashes, underscores, dots, etc.) with a hyphen. + // e.g. /Users/unknown_liang/Desktop/Code/atlas_v01 + // → -Users-unknown-liang-Desktop-Code-atlas-v01 + return cwd.replace(/[^a-zA-Z0-9]/g, '-'); +} + +function extractSessionInfo(jsonlPath: string): { customTitle?: string; firstUserMessage?: string } { + if (!existsSync(jsonlPath)) return {}; + try { + const lines = readFileSync(jsonlPath, 'utf-8').split('\n'); + let customTitle: string | undefined; + let firstUserMessage: string | undefined; + for (const line of lines) { + if (!line.trim()) continue; + let obj: any; + try { obj = JSON.parse(line); } catch { continue; } + // custom title set by /rename — keep updating to get the latest + if (obj.type === 'custom-title' && typeof obj.customTitle === 'string' && obj.customTitle.trim()) { + customTitle = obj.customTitle.trim(); + } + // first real user message + if (!firstUserMessage && obj.type === 'user') { + const content = obj.message?.content; + if (typeof content === 'string' && content.trim().length > 5) { + firstUserMessage = content.trim(); + } else if (Array.isArray(content)) { + for (const block of content) { + if (block?.type === 'text' && typeof block.text === 'string' && block.text.trim().length > 5) { + firstUserMessage = block.text.trim(); + break; + } + } + } + } + // once we have firstUserMessage, we still need to scan for the latest customTitle + // so never break early here + } + return { customTitle, firstUserMessage }; + } catch { /* ignore */ } + return {}; +} + +function loadSessionIndex(cwd: string): SessionIndexEntry[] { + const slug = cwdToProjectSlug(cwd.replace(/^~/, homedir())); + const indexPath = join(homedir(), '.claude', 'projects', slug, 'sessions-index.json'); + if (!existsSync(indexPath)) return []; + try { + const data: SessionIndex = JSON.parse(readFileSync(indexPath, 'utf-8')); + return (data.entries || []).sort( + (a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime() + ); + } catch { + return []; + } +} + +function formatSessionLabel(entry: SessionIndexEntry, index: number): string { + const { customTitle, firstUserMessage } = extractSessionInfo(entry.fullPath); + // customTitle (from /rename) takes priority, then first user message, then summary + const raw = customTitle || firstUserMessage || entry.summary || '(无内容)'; + const label = raw + .replace(/<[^>]+>[\s\S]*?<\/[^>]+>/g, '') + .replace(/<[^>]+>/g, '') + .trim() + .slice(0, 50); + const titleMark = customTitle ? `[${customTitle}] ` : ''; + const displayLabel = customTitle + ? `[${customTitle}]` + : (firstUserMessage || entry.summary || '(无内容)') + .replace(/<[^>]+>[\s\S]*?<\/[^>]+>/g, '') + .replace(/<[^>]+>/g, '') + .trim() + .slice(0, 50); + const modified = new Date(entry.modified).toLocaleString('zh-CN', { + month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', + }); + const msgs = entry.messageCount; + return `${index + 1}. [${modified}] ${displayLabel} (${msgs}条)`; +} + +export function handleResume(ctx: CommandContext, args: string): CommandResult { + const cwd = ctx.session.workingDirectory || DEFAULT_WORKING_DIR; + + const entries = loadSessionIndex(cwd); + if (entries.length === 0) { + return { reply: `当前目录 ${cwd} 没有历史对话记录。`, handled: true }; + } + + // /resume 不带参数 — 列出会话列表 + if (!args) { + const MAX_LIST = 15; + const shown = entries.slice(0, MAX_LIST); + const lines = shown.map((e, i) => formatSessionLabel(e, i)); + const footer = entries.length > MAX_LIST ? `\n…共 ${entries.length} 条,仅显示最近 ${MAX_LIST} 条` : ''; + return { + reply: `📋 历史对话(目录: ${cwd})\n\n${lines.join('\n')}${footer}\n\n用 /resume <编号> 恢复,例: /resume 1`, + handled: true, + }; + } + + // /resume <编号> — 按编号恢复 + const num = parseInt(args.trim(), 10); + if (!isNaN(num) && num >= 1 && num <= entries.length) { + const target = entries[num - 1]; + const label = (target.summary || target.firstPrompt || target.sessionId).slice(0, 60); + ctx.updateSession({ sdkSessionId: target.sessionId }); + return { + reply: `✅ 已切换到历史对话 #${num}\n摘要: ${label}\n时间: ${new Date(target.modified).toLocaleString('zh-CN')}\n\n发送下一条消息即可继续该对话。`, + handled: true, + }; + } + + // /resume — 按完整 UUID 恢复 + const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (uuidRe.test(args.trim())) { + const target = entries.find(e => e.sessionId === args.trim()); + if (!target) { + return { reply: `未找到 sessionId: ${args.trim()}`, handled: true }; + } + ctx.updateSession({ sdkSessionId: target.sessionId }); + const label = (target.summary || target.firstPrompt || target.sessionId).slice(0, 60); + return { + reply: `✅ 已切换到历史对话\n摘要: ${label}\n时间: ${new Date(target.modified).toLocaleString('zh-CN')}\n\n发送下一条消息即可继续该对话。`, + handled: true, + }; + } + + return { + reply: `用法:\n /resume 列出历史对话\n /resume <编号> 恢复指定对话(编号来自列表)`, + handled: true, + }; +} + export function handleUnknown(cmd: string, args: string): CommandResult { const skills = getSkills(); const skill = findSkill(skills, cmd); diff --git a/src/commands/router.ts b/src/commands/router.ts index b1446b9..e2174ef 100644 --- a/src/commands/router.ts +++ b/src/commands/router.ts @@ -1,7 +1,7 @@ import type { Session } from '../session.js'; import { findSkill } from '../claude/skill-scanner.js'; import { logger } from '../logger.js'; -import { handleHelp, handleClear, handleCwd, handleModel, handleStatus, handleSkills, handleHistory, handleReset, handleCompact, handleUndo, handleVersion, handlePrompt, handleSend, handleUnknown } from './handlers.js'; +import { handleHelp, handleClear, handleCwd, handleModel, handleStatus, handleSkills, handleHistory, handleReset, handleCompact, handleUndo, handleVersion, handlePrompt, handleSend, handleResume, handleUnknown } from './handlers.js'; export interface CommandContext { accountId: string; @@ -17,6 +17,7 @@ export interface CommandResult { handled: boolean; claudePrompt?: string; sendFile?: string; // Absolute path to a file to send to the user + compactSession?: boolean; // Trigger a real /compact on the current session } /** @@ -66,6 +67,8 @@ export function routeCommand(ctx: CommandContext): CommandResult { return handleUndo(ctx, args); case 'compact': return handleCompact(ctx); + case 'resume': + return handleResume(ctx, args); case 'send': return handleSend(ctx, args); case 'version': diff --git a/src/main.ts b/src/main.ts index e0bb69b..74b3ada 100644 --- a/src/main.ts +++ b/src/main.ts @@ -399,6 +399,14 @@ async function handleMessage( return; } + if (result.handled && result.compactSession) { + await compactSession( + fromUserId, contextToken, + account, session, sessionStore, sender, config, + ); + return; + } + if (result.handled && result.sendFile) { await sender.sendFile(fromUserId, contextToken, result.sendFile); return; @@ -426,6 +434,58 @@ function extractTextFromItems(items: NonNullable): s return items.map((item) => extractText(item)).filter(Boolean).join('\n'); } +async function compactSession( + fromUserId: string, + contextToken: string, + account: AccountData, + session: Session, + sessionStore: ReturnType, + sender: ReturnType, + config: ReturnType, +): Promise { + const stopTyping = sender.startTyping(fromUserId, contextToken); + try { + await sender.sendText(fromUserId, contextToken, '⏳ 正在压缩上下文,请稍候(通常需要1-2分钟)...'); + + const cwd = (session.workingDirectory || config.workingDirectory).replace(/^~/, homedir()); + const result = await claudeQuery({ + prompt: '/compact', + cwd, + resume: session.sdkSessionId, + model: session.model, + }); + + // compact 完成后 session ID 不变,直接用 result.sessionId(和原来一样) + // 从 result.text 里不会有实际输出(compact 完成后 Claude 没有回复文字) + // 但 claudeQuery 内部能拿到 sessionId(来自 system/init) + if (result.error && !result.sessionId) { + await sender.sendText(fromUserId, contextToken, `❌ 压缩失败: ${result.error}`); + return; + } + + // session ID 保持不变(compact 不会改变 session ID) + session.sdkSessionId = result.sessionId || session.sdkSessionId; + sessionStore.save(account.accountId, session); + + // Parse compact stats from the sentinel we injected in provider.ts + let statsLine = ''; + const compactMarker = result.text.match(/^__compact__:(\d+):(\d+)$/m); + if (compactMarker) { + const pre = parseInt(compactMarker[1], 10); + const post = parseInt(compactMarker[2], 10); + const pct = pre > 0 ? Math.round((1 - post / pre) * 100) : 0; + statsLine = `\n压缩前: ${pre.toLocaleString()} tokens → 压缩后: ${post.toLocaleString()} tokens(减少 ${pct}%)`; + } + + await sender.sendText(fromUserId, contextToken, `✅ 上下文已压缩${statsLine}\n\nSession ID 不变,对话继续。可直接发送下一条消息。`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await sender.sendText(fromUserId, contextToken, `❌ 压缩出错: ${msg}`); + } finally { + stopTyping(); + } +} + async function sendToClaude( userText: string, imageItem: ReturnType,