Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/claude/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,14 @@ export async function claudeQuery(options: QueryOptions): Promise<QueryResult> {
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': {
Expand Down
172 changes: 159 additions & 13 deletions src/commands/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ const HELP_TEXT = `可用命令:
/clear 清除当前会话
/reset 完全重置(包括工作目录等设置)
/status 查看当前会话状态
/compact 压缩上下文(开始新 SDK 会话,保留历史
/compact 压缩上下文(保持当前对话,大幅减少 token 占用
/history [数量] 查看对话记录(默认最近20条)
/undo [数量] 撤销最近对话(默认1条)
/resume 列出当前目录的历史对话
/resume <编号> 恢复指定编号的历史对话

文件:
/send <路径> 发送本地文件(图片直接显示,其他文件作为附件)
Expand Down Expand Up @@ -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 条对话 */
Expand Down Expand Up @@ -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 <sessionId> — 按完整 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);
Expand Down
5 changes: 4 additions & 1 deletion src/commands/router.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
}

/**
Expand Down Expand Up @@ -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':
Expand Down
60 changes: 60 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -426,6 +434,58 @@ function extractTextFromItems(items: NonNullable<WeixinMessage['item_list']>): s
return items.map((item) => extractText(item)).filter(Boolean).join('\n');
}

async function compactSession(
fromUserId: string,
contextToken: string,
account: AccountData,
session: Session,
sessionStore: ReturnType<typeof createSessionStore>,
sender: ReturnType<typeof createSender>,
config: ReturnType<typeof loadConfig>,
): Promise<void> {
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<typeof extractFirstImageUrl>,
Expand Down