From 730551cb3f0160e70a321adb58c5f2048c866347 Mon Sep 17 00:00:00 2001 From: liuzh Date: Thu, 2 Apr 2026 23:05:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=9A=E5=B1=82=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E5=8E=8B=E7=BC=A9=20+=20=E5=A4=9A=E7=BA=A7=E6=9D=83?= =?UTF-8?q?=E9=99=90=E7=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 权限管理: - PermissionTypes: 权限行为/模式/规则/决策/选择类型系统 - PermissionSettings: 用户级/项目级/会话级权限持久化(settings.json) - PermissionRuleEngine: 多层规则引擎(deny→allow→readOnly→mode→dangerous→ASK) - DangerousPatterns: 危险shell命令检测(rm -rf, eval, exec等) - ReplSession: Y/A/N/D四选项权限确认UI - ConfigCommand: permission-mode/list/reset子命令 - AgentLoop: 集成规则引擎,PermissionChoice回调 上下文压缩: - TokenTracker: 上下文窗口监控(93%自动压缩/82%警告/98%阻塞) - MicroCompact: 微压缩,裁剪旧tool_result(无API调用) - SessionMemoryCompact: Session Memory压缩,AI摘要旧消息保留近期段 - FullCompact: 全量压缩+PTL重试(按API Round逐步丢弃) - AutoCompactManager: 压缩编排器(micro→session→full),含熔断机制 - CompactCommand: 改造为委托FullCompact - StatusLine: token使用百分比+颜色告警(绿→黄→红→闪烁) - AppConfig: 注册PermissionSettings/RuleEngine/AutoCompactManager Bean Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../command/impl/CompactCommand.java | 108 ++----- .../command/impl/ConfigCommand.java | 102 +++++- .../java/com/claudecode/config/AppConfig.java | 32 +- .../com/claudecode/console/StatusLine.java | 17 +- .../java/com/claudecode/core/AgentLoop.java | 83 ++++- .../com/claudecode/core/TokenTracker.java | 100 +++++- .../core/compact/AutoCompactManager.java | 178 +++++++++++ .../core/compact/CompactionResult.java | 46 +++ .../claudecode/core/compact/FullCompact.java | 175 +++++++++++ .../claudecode/core/compact/MicroCompact.java | 91 ++++++ .../core/compact/SessionMemoryCompact.java | 297 ++++++++++++++++++ .../permission/DangerousPatterns.java | 105 +++++++ .../permission/PermissionRuleEngine.java | 173 ++++++++++ .../permission/PermissionSettings.java | 200 ++++++++++++ .../permission/PermissionTypes.java | 110 +++++++ .../java/com/claudecode/repl/ReplSession.java | 69 ++-- 16 files changed, 1759 insertions(+), 127 deletions(-) create mode 100644 src/main/java/com/claudecode/core/compact/AutoCompactManager.java create mode 100644 src/main/java/com/claudecode/core/compact/CompactionResult.java create mode 100644 src/main/java/com/claudecode/core/compact/FullCompact.java create mode 100644 src/main/java/com/claudecode/core/compact/MicroCompact.java create mode 100644 src/main/java/com/claudecode/core/compact/SessionMemoryCompact.java create mode 100644 src/main/java/com/claudecode/permission/DangerousPatterns.java create mode 100644 src/main/java/com/claudecode/permission/PermissionRuleEngine.java create mode 100644 src/main/java/com/claudecode/permission/PermissionSettings.java create mode 100644 src/main/java/com/claudecode/permission/PermissionTypes.java diff --git a/src/main/java/com/claudecode/command/impl/CompactCommand.java b/src/main/java/com/claudecode/command/impl/CompactCommand.java index 9b74646..b4ea5dc 100644 --- a/src/main/java/com/claudecode/command/impl/CompactCommand.java +++ b/src/main/java/com/claudecode/command/impl/CompactCommand.java @@ -4,10 +4,10 @@ import com.claudecode.command.CommandContext; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; import com.claudecode.core.TokenTracker; +import com.claudecode.core.compact.AutoCompactManager; +import com.claudecode.core.compact.FullCompact; import org.springframework.ai.chat.messages.*; import org.springframework.ai.chat.model.ChatModel; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.prompt.Prompt; import java.util.ArrayList; import java.util.List; @@ -16,22 +16,10 @@ import java.util.List; * /compact 命令 —— 用 AI 生成摘要来压缩上下文。 *

* 对应 claude-code/src/commands/compact.ts。 - * 将详细的对话历史替换为 AI 生成的摘要,大幅减少 token 消耗。 - * 保留系统提示词和最近一轮对话。 + * 委托给 FullCompact 执行实际压缩逻辑。 */ public class CompactCommand implements SlashCommand { - private static final String COMPACT_PROMPT = """ - Please compress the following conversation history into a concise summary. Requirements: - 1. Preserve all key decisions, code changes, and technical details - 2. Keep file paths, function names, and specific information - 3. Preserve user preferences and requirements - 4. Omit repeated discussions and irrelevant details - 5. Output within 500 words - - Conversation history: - """; - @Override public String name() { return "compact"; @@ -58,78 +46,38 @@ public class CompactCommand implements SlashCommand { TokenTracker tracker = context.agentLoop().getTokenTracker(); long tokensBefore = tracker.getInputTokens() + tracker.getOutputTokens(); - // 尝试用 AI 生成摘要 - String summary = generateSummary(context, history); - - // 构建压缩后的历史:系统提示 + 摘要作为系统消息 + 保留最后一轮对话 - List compacted = new ArrayList<>(); - compacted.add(history.getFirst()); // 原始系统提示词 - - if (summary != null && !summary.isBlank()) { - compacted.add(new SystemMessage("[Conversation Summary] " + summary)); - } - - // 保留最后一轮用户消息和助手回复(如果有) - for (int i = Math.max(1, before - 2); i < before; i++) { - compacted.add(history.get(i)); - } - - context.agentLoop().replaceHistory(compacted); - int after = compacted.size(); - - StringBuilder sb = new StringBuilder(); - sb.append(AnsiStyle.green(" ✅ Context compacted")).append("\n"); - sb.append(" Messages: ").append(before).append(" → ").append(after).append("\n"); - if (tokensBefore > 0) { - sb.append(" Tokens before compaction: ").append(TokenTracker.formatTokens(tokensBefore)).append("\n"); - } - if (summary != null) { - sb.append(AnsiStyle.dim(" 📝 AI summary generated and injected into context")); + // 优先使用 AutoCompactManager 中的 FullCompact + FullCompact fullCompact; + AutoCompactManager acm = context.agentLoop().getAutoCompactManager(); + if (acm != null) { + fullCompact = acm.getFullCompact(); } else { - sb.append(AnsiStyle.dim(" ⚠ AI summary generation failed, keeping recent conversation only")); + fullCompact = new FullCompact(context.agentLoop().getChatModel()); } - return sb.toString(); - } + // 执行全量压缩 + List compacted = fullCompact.compact(new ArrayList<>(history)); - /** 调用 AI 生成对话摘要 */ - private String generateSummary(CommandContext context, List history) { - try { - ChatModel chatModel = context.agentLoop().getChatModel(); + if (compacted != null) { + context.agentLoop().replaceHistory(compacted); + int after = compacted.size(); - // 构建摘要请求的消息列史 - StringBuilder dialogText = new StringBuilder(); - for (Message msg : history) { - switch (msg) { - case UserMessage um -> dialogText.append("[User] ").append(um.getText()).append("\n"); - case AssistantMessage am -> { - if (am.getText() != null && !am.getText().isBlank()) { - // 截断过长的助手回复 - String text = am.getText(); - if (text.length() > 500) text = text.substring(0, 500) + "..."; - dialogText.append("[Assistant] ").append(text).append("\n"); - } - if (am.hasToolCalls()) { - for (var tc : am.getToolCalls()) { - dialogText.append("[Tool Call] ").append(tc.name()).append("\n"); - } - } - } - default -> {} // 跳过系统消息和工具响应 - } + // 重置熔断器(手动压缩成功说明 AI 摘要功能正常) + if (acm != null) { + acm.resetCircuitBreaker(); } - if (dialogText.isEmpty()) return null; - - Prompt summaryPrompt = new Prompt(List.of( - new UserMessage(COMPACT_PROMPT + dialogText) - )); - - ChatResponse response = chatModel.call(summaryPrompt); - return response.getResult().getOutput().getText(); - } catch (Exception e) { - // 摘要生成失败不影响压缩操作 - return null; + StringBuilder sb = new StringBuilder(); + sb.append(AnsiStyle.green(" ✅ Context compacted")).append("\n"); + sb.append(" Messages: ").append(before).append(" → ").append(after).append("\n"); + if (tokensBefore > 0) { + sb.append(" Tokens before compaction: ").append(TokenTracker.formatTokens(tokensBefore)).append("\n"); + } + sb.append(AnsiStyle.dim(" 📝 AI summary generated and injected into context")); + return sb.toString(); } + + return AnsiStyle.yellow(" ⚠ Compaction failed — AI summary generation failed") + "\n" + + AnsiStyle.dim(" The conversation history was not modified."); } } diff --git a/src/main/java/com/claudecode/command/impl/ConfigCommand.java b/src/main/java/com/claudecode/command/impl/ConfigCommand.java index 0186ab1..88e4003 100644 --- a/src/main/java/com/claudecode/command/impl/ConfigCommand.java +++ b/src/main/java/com/claudecode/command/impl/ConfigCommand.java @@ -3,6 +3,9 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; +import com.claudecode.permission.PermissionRuleEngine; +import com.claudecode.permission.PermissionSettings; +import com.claudecode.permission.PermissionTypes.PermissionMode; import java.util.List; import java.util.Map; @@ -10,8 +13,7 @@ import java.util.Map; /** * /config 命令 —— 查看和设置应用配置。 *

- * 支持查看当前配置、设置单个配置项。 - * 配置变更仅在当前会话内生效。 + * 支持查看当前配置、设置单个配置项,以及权限管理子命令。 */ public class ConfigCommand implements SlashCommand { @@ -21,9 +23,24 @@ public class ConfigCommand implements SlashCommand { "max-tokens", "Maximum output tokens per response", "temperature", "Response randomness (0.0-1.0)", "verbose", "Enable verbose logging (true/false)", - "auto-compact", "Auto compact when context is large (true/false)" + "auto-compact", "Auto compact when context is large (true/false)", + "permission-mode", "Permission mode: default/accept-edits/bypass/dont-ask", + "permission-list", "List all saved permission rules", + "permission-reset", "Clear all permission rules" ); + private PermissionSettings permissionSettings; + + public ConfigCommand() {} + + public ConfigCommand(PermissionSettings permissionSettings) { + this.permissionSettings = permissionSettings; + } + + public void setPermissionSettings(PermissionSettings settings) { + this.permissionSettings = settings; + } + @Override public String name() { return "config"; @@ -90,17 +107,6 @@ public class ConfigCommand implements SlashCommand { return sb.toString(); } - private String showConfig(String key, CommandContext context) { - if (!CONFIG_KEYS.containsKey(key)) { - return AnsiStyle.yellow(" ⚠ Unknown config key: " + key) + "\n" - + AnsiStyle.dim(" Available: " + String.join(", ", CONFIG_KEYS.keySet())); - } - - String desc = CONFIG_KEYS.get(key); - return " " + AnsiStyle.bold(key) + ": " + AnsiStyle.dim(desc) + "\n" - + AnsiStyle.dim(" Set with: /config " + key + " "); - } - private String setConfig(String key, String value, CommandContext context) { return switch (key) { case "model" -> { @@ -112,6 +118,9 @@ public class ConfigCommand implements SlashCommand { boolean verbose = Boolean.parseBoolean(value); yield AnsiStyle.green(" ✅ Verbose mode: " + (verbose ? "ON" : "OFF")); } + case "permission-mode" -> setPermissionMode(value); + case "permission-list" -> listPermissionRules(); + case "permission-reset" -> resetPermissionRules(); default -> { if (!CONFIG_KEYS.containsKey(key)) { yield AnsiStyle.yellow(" ⚠ Unknown config key: " + key); @@ -121,4 +130,69 @@ public class ConfigCommand implements SlashCommand { } }; } + + private String showConfig(String key, CommandContext context) { + // 无参数的权限子命令 + if (key.equals("permission-list")) return listPermissionRules(); + if (key.equals("permission-reset")) return resetPermissionRules(); + + if (!CONFIG_KEYS.containsKey(key)) { + return AnsiStyle.yellow(" ⚠ Unknown config key: " + key) + "\n" + + AnsiStyle.dim(" Available: " + String.join(", ", CONFIG_KEYS.keySet())); + } + + String desc = CONFIG_KEYS.get(key); + return " " + AnsiStyle.bold(key) + ": " + AnsiStyle.dim(desc) + "\n" + + AnsiStyle.dim(" Set with: /config " + key + " "); + } + + // ── 权限管理子命令 ── + + private String setPermissionMode(String value) { + if (permissionSettings == null) { + return AnsiStyle.yellow(" ⚠ Permission settings not initialized"); + } + try { + PermissionMode mode = switch (value.toLowerCase()) { + case "default" -> PermissionMode.DEFAULT; + case "accept-edits", "acceptedits" -> PermissionMode.ACCEPT_EDITS; + case "bypass" -> PermissionMode.BYPASS; + case "dont-ask", "dontask" -> PermissionMode.DONT_ASK; + default -> throw new IllegalArgumentException(value); + }; + permissionSettings.setCurrentMode(mode); + return AnsiStyle.green(" ✅ Permission mode set to: " + mode); + } catch (IllegalArgumentException e) { + return AnsiStyle.yellow(" ⚠ Unknown mode: " + value) + "\n" + + AnsiStyle.dim(" Available: default, accept-edits, bypass, dont-ask"); + } + } + + private String listPermissionRules() { + if (permissionSettings == null) { + return AnsiStyle.yellow(" ⚠ Permission settings not initialized"); + } + var rules = permissionSettings.listRules(); + if (rules.isEmpty()) { + return AnsiStyle.dim(" No saved permission rules") + "\n" + + AnsiStyle.dim(" Mode: " + permissionSettings.getCurrentMode()); + } + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(AnsiStyle.bold(" 🔒 Permission Rules")).append("\n"); + sb.append(" ").append("─".repeat(40)).append("\n"); + sb.append(" ").append(AnsiStyle.bold("Mode: ")).append(permissionSettings.getCurrentMode()).append("\n\n"); + for (String rule : rules) { + sb.append(" ").append(rule).append("\n"); + } + return sb.toString(); + } + + private String resetPermissionRules() { + if (permissionSettings == null) { + return AnsiStyle.yellow(" ⚠ Permission settings not initialized"); + } + permissionSettings.clearAll(); + return AnsiStyle.green(" ✅ All permission rules cleared"); + } } diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java index 2b7a76e..ab2f33a 100644 --- a/src/main/java/com/claudecode/config/AppConfig.java +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -9,7 +9,10 @@ import com.claudecode.context.SystemPromptBuilder; import com.claudecode.core.AgentLoop; import com.claudecode.core.TaskManager; import com.claudecode.core.TokenTracker; +import com.claudecode.core.compact.AutoCompactManager; import com.claudecode.mcp.McpManager; +import com.claudecode.permission.PermissionRuleEngine; +import com.claudecode.permission.PermissionSettings; import com.claudecode.plugin.OutputStylePlugin; import com.claudecode.plugin.PluginManager; import com.claudecode.repl.ReplSession; @@ -115,7 +118,8 @@ public class AppConfig { } @Bean - public CommandRegistry commandRegistry(PluginManager pluginManager) { + public CommandRegistry commandRegistry(PluginManager pluginManager, PermissionSettings permissionSettings) { + ConfigCommand configCommand = new ConfigCommand(permissionSettings); CommandRegistry registry = new CommandRegistry(); registry.registerAll( // 基础命令 @@ -127,7 +131,7 @@ public class AppConfig { new StatusCommand(), new ContextCommand(), new InitCommand(), - new ConfigCommand(), + configCommand, new HistoryCommand(), // P0 命令 new DiffCommand(), @@ -192,6 +196,23 @@ public class AppConfig { return new ProviderInfo(provider, baseUrl, model); } + @Bean + public PermissionSettings permissionSettings() { + PermissionSettings settings = new PermissionSettings(); + settings.load(); + return settings; + } + + @Bean + public PermissionRuleEngine permissionRuleEngine(PermissionSettings permissionSettings) { + return new PermissionRuleEngine(permissionSettings); + } + + @Bean + public AutoCompactManager autoCompactManager(ChatModel activeChatModel, TokenTracker tokenTracker) { + return new AutoCompactManager(activeChatModel, tokenTracker); + } + @Bean public TokenTracker tokenTracker(ProviderInfo info) { TokenTracker tracker = new TokenTracker(); @@ -223,9 +244,14 @@ public class AppConfig { @Bean public AgentLoop agentLoop(ChatModel activeChatModel, ToolRegistry toolRegistry, ToolContext toolContext, String systemPrompt, TokenTracker tokenTracker, - PluginManager pluginManager) { + PluginManager pluginManager, PermissionRuleEngine permissionRuleEngine, + AutoCompactManager autoCompactManager) { AgentLoop mainLoop = new AgentLoop(activeChatModel, toolRegistry, toolContext, systemPrompt, tokenTracker); + // 注入权限引擎和自动压缩管理器 + mainLoop.setPermissionEngine(permissionRuleEngine); + mainLoop.setAutoCompactManager(autoCompactManager); + // 注册子 Agent 工厂 toolContext.set(AgentTool.AGENT_FACTORY_KEY, (java.util.function.Function) prompt -> { diff --git a/src/main/java/com/claudecode/console/StatusLine.java b/src/main/java/com/claudecode/console/StatusLine.java index 0021e7d..3a7b793 100644 --- a/src/main/java/com/claudecode/console/StatusLine.java +++ b/src/main/java/com/claudecode/console/StatusLine.java @@ -84,10 +84,25 @@ public class StatusLine { // 模型名 sb.append(" ").append(modelName); - // Token 用量 + // Token 用量 + 上下文窗口占比 sb.append(" │ ↑").append(TokenTracker.formatTokens(inputTokens)); sb.append(" ↓").append(TokenTracker.formatTokens(outputTokens)); + // 上下文窗口使用百分比(带颜色) + double usagePct = tokenTracker.getUsagePercentage(); + if (usagePct > 0) { + String pctStr = String.format(" %.0f%%", usagePct * 100); + var warningState = tokenTracker.getTokenWarningState(); + sb.append(AnsiStyle.RESET); // 先重置再着色 + sb.append(switch (warningState) { + case NORMAL -> AnsiStyle.DIM + AnsiStyle.GREEN + pctStr; + case WARNING -> AnsiStyle.BOLD + AnsiStyle.YELLOW + pctStr; + case ERROR -> AnsiStyle.BOLD + AnsiStyle.RED + pctStr; + case BLOCKING -> AnsiStyle.BOLD + AnsiStyle.RED + "⚠" + pctStr; + }); + sb.append(AnsiStyle.RESET).append(AnsiStyle.DIM); + } + // 费用 if (cost > 0) { sb.append(String.format(" $%.4f", cost)); diff --git a/src/main/java/com/claudecode/core/AgentLoop.java b/src/main/java/com/claudecode/core/AgentLoop.java index 33c2760..f841222 100644 --- a/src/main/java/com/claudecode/core/AgentLoop.java +++ b/src/main/java/com/claudecode/core/AgentLoop.java @@ -1,5 +1,9 @@ package com.claudecode.core; +import com.claudecode.core.compact.AutoCompactManager; +import com.claudecode.permission.PermissionRuleEngine; +import com.claudecode.permission.PermissionTypes.PermissionChoice; +import com.claudecode.permission.PermissionTypes.PermissionDecision; import com.claudecode.tool.ToolCallbackAdapter; import com.claudecode.tool.ToolContext; import com.claudecode.tool.ToolRegistry; @@ -52,6 +56,12 @@ public class AgentLoop { private final TokenTracker tokenTracker; private final HookManager hookManager; + /** 权限规则引擎(可选,为 null 时使用传统回调方式) */ + private PermissionRuleEngine permissionEngine; + + /** 自动压缩管理器(可选) */ + private AutoCompactManager autoCompactManager; + /** 消息历史 —— 自行管理,不依赖 Spring AI ChatMemory */ private final List messageHistory = new ArrayList<>(); @@ -64,8 +74,8 @@ public class AgentLoop { /** 流式输出开始回调:通知 UI 停止 spinner */ private Runnable onStreamStart; - /** 权限确认回调:危险操作前请求用户确认(返回 true 表示允许) */ - private Function onPermissionRequest; + /** 权限确认回调:危险操作前请求用户确认(返回 PermissionChoice) */ + private Function onPermissionRequest; /** Thinking 内容回调:显示 AI 的思考过程 */ private Consumer onThinkingContent; @@ -98,10 +108,22 @@ public class AgentLoop { this.onStreamStart = onStreamStart; } - public void setOnPermissionRequest(Function onPermissionRequest) { + public void setOnPermissionRequest(Function onPermissionRequest) { this.onPermissionRequest = onPermissionRequest; } + public void setPermissionEngine(PermissionRuleEngine engine) { + this.permissionEngine = engine; + } + + public void setAutoCompactManager(AutoCompactManager manager) { + this.autoCompactManager = manager; + } + + public AutoCompactManager getAutoCompactManager() { + return autoCompactManager; + } + public void setOnThinkingContent(Consumer onThinkingContent) { this.onThinkingContent = onThinkingContent; } @@ -183,6 +205,14 @@ public class AgentLoop { // 执行工具调用 executeToolCalls(result.assistant.getToolCalls(), callbacks); + + // 自动压缩检查(在工具调用后,下次 API 调用前) + if (autoCompactManager != null) { + autoCompactManager.autoCompactIfNeeded( + () -> messageHistory, + this::replaceHistory + ); + } } if (iteration >= MAX_ITERATIONS) { @@ -302,12 +332,34 @@ public class AgentLoop { String result; ToolCallbackAdapter adapter = findCallbackByName(callbacks, toolName); if (adapter != null) { - // 权限确认:非只读工具需要用户确认 + // 权限检查:优先使用规则引擎,回退到传统回调 boolean permitted = true; - if (!adapter.getTool().isReadOnly() && onPermissionRequest != null) { + if (permissionEngine != null) { + PermissionDecision decision = permissionEngine.evaluate( + toolName, parsedArgs, adapter.getTool().isReadOnly()); + if (decision.isAllowed()) { + permitted = true; + } else if (decision.isDenied()) { + permitted = false; + log.info("[{}] Denied by rule: {}", toolName, decision.reason()); + } else if (decision.needsAsk() && onPermissionRequest != null) { + String activity = adapter.getTool().activityDescription(parsedArgs); + PermissionRequest req = new PermissionRequest(toolName, toolArgs, activity); + req.setDecision(decision); + PermissionChoice choice = onPermissionRequest.apply(req); + permitted = (choice == PermissionChoice.ALLOW_ONCE || choice == PermissionChoice.ALWAYS_ALLOW); + // 持久化用户选择 + String command = parsedArgs != null ? (String) parsedArgs.get("command") : null; + permissionEngine.applyChoice(choice, toolName, command); + } else { + permitted = false; + } + } else if (!adapter.getTool().isReadOnly() && onPermissionRequest != null) { + // 传统回调模式(向后兼容) String activity = adapter.getTool().activityDescription(parsedArgs); PermissionRequest req = new PermissionRequest(toolName, toolArgs, activity); - permitted = onPermissionRequest.apply(req); + PermissionChoice choice = onPermissionRequest.apply(req); + permitted = (choice == PermissionChoice.ALLOW_ONCE || choice == PermissionChoice.ALWAYS_ALLOW); } if (permitted) { @@ -438,7 +490,24 @@ public class AgentLoop { } /** 权限确认请求 */ - public record PermissionRequest(String toolName, String arguments, String activityDescription) {} + public static class PermissionRequest { + private final String toolName; + private final String arguments; + private final String activityDescription; + private PermissionDecision decision; + + public PermissionRequest(String toolName, String arguments, String activityDescription) { + this.toolName = toolName; + this.arguments = arguments; + this.activityDescription = activityDescription; + } + + public String toolName() { return toolName; } + public String arguments() { return arguments; } + public String activityDescription() { return activityDescription; } + public PermissionDecision decision() { return decision; } + public void setDecision(PermissionDecision decision) { this.decision = decision; } + } /** 工具事件,用于 UI 展示 */ public record ToolEvent(String toolName, Phase phase, String arguments, String result) { diff --git a/src/main/java/com/claudecode/core/TokenTracker.java b/src/main/java/com/claudecode/core/TokenTracker.java index 1dbf5ad..013e949 100644 --- a/src/main/java/com/claudecode/core/TokenTracker.java +++ b/src/main/java/com/claudecode/core/TokenTracker.java @@ -3,29 +3,72 @@ package com.claudecode.core; import java.util.concurrent.atomic.AtomicLong; /** - * Token 使用量追踪器 —— 记录 API 调用的 token 消耗。 + * Token 使用量追踪器 —— 记录 API 调用的 token 消耗并监控上下文窗口。 *

* 从 ChatResponse 的 usage 元数据中提取 token 统计信息, - * 支持按会话累计和费用估算。 + * 支持按会话累计、费用估算和上下文窗口阈值监控。 */ public class TokenTracker { + // ── 上下文窗口阈值常量 ── + /** 自动压缩触发百分比(有效窗口的 93%) */ + public static final double AUTO_COMPACT_THRESHOLD_PCT = 0.93; + /** 警告阈值百分比(82%) */ + public static final double WARNING_THRESHOLD_PCT = 0.82; + /** 阻塞阈值百分比(98%,必须压缩才能继续) */ + public static final double BLOCKING_THRESHOLD_PCT = 0.98; + /** 自动压缩缓冲 token 数 */ + public static final long AUTO_COMPACT_BUFFER_TOKENS = 13_000; + /** 手动压缩缓冲 token 数 */ + public static final long MANUAL_COMPACT_BUFFER_TOKENS = 3_000; + + /** 上下文窗口警告状态 */ + public enum TokenWarningState { + NORMAL, // 正常(绿色) + WARNING, // 接近阈值(黄色) + ERROR, // 达到压缩阈值(红色) + BLOCKING // 必须压缩才能继续(闪烁红) + } + private final AtomicLong totalInputTokens = new AtomicLong(0); private final AtomicLong totalOutputTokens = new AtomicLong(0); private final AtomicLong totalCacheReadTokens = new AtomicLong(0); private final AtomicLong totalCacheCreationTokens = new AtomicLong(0); private final AtomicLong apiCallCount = new AtomicLong(0); + /** 最近一次 API 调用报告的 prompt token 数(近似当前上下文大小) */ + private final AtomicLong lastPromptTokens = new AtomicLong(0); + /** 模型定价(每百万 token 的美元价格) */ private double inputPricePerMillion = 3.0; // Claude Sonnet 4 input private double outputPricePerMillion = 15.0; // Claude Sonnet 4 output private double cacheReadPricePerMillion = 0.3; // 缓存读取 private String modelName = "claude-sonnet-4-20250514"; + /** 上下文窗口总大小(token) */ + private long contextWindowSize; + /** 预留给输出的 token 数 */ + private long reservedTokens = 20_000; + + public TokenTracker() { + // 支持环境变量覆盖上下文窗口大小 + String envWindow = System.getenv("CLAUDE_CODE_CONTEXT_WINDOW"); + if (envWindow != null && !envWindow.isBlank()) { + try { + this.contextWindowSize = Long.parseLong(envWindow.trim()); + } catch (NumberFormatException e) { + this.contextWindowSize = 200_000; // 默认 200K + } + } else { + this.contextWindowSize = 200_000; + } + } + /** 记录一次 API 调用的 token 使用 */ public void recordUsage(long inputTokens, long outputTokens) { totalInputTokens.addAndGet(inputTokens); totalOutputTokens.addAndGet(outputTokens); + lastPromptTokens.set(inputTokens); apiCallCount.incrementAndGet(); } @@ -35,6 +78,7 @@ public class TokenTracker { totalOutputTokens.addAndGet(outputTokens); totalCacheReadTokens.addAndGet(cacheRead); totalCacheCreationTokens.addAndGet(cacheCreation); + lastPromptTokens.set(inputTokens); apiCallCount.incrementAndGet(); } @@ -73,12 +117,64 @@ public class TokenTracker { return inputCost + outputCost + cacheCost; } + // ── 上下文窗口监控 ── + + /** 有效上下文窗口大小(总窗口 - 预留输出) */ + public long getEffectiveWindow() { + return contextWindowSize - reservedTokens; + } + + /** 最近一次 prompt 的 token 数(近似当前上下文大小) */ + public long getLastPromptTokens() { + return lastPromptTokens.get(); + } + + /** 当前上下文使用百分比 */ + public double getUsagePercentage() { + long effective = getEffectiveWindow(); + if (effective <= 0) return 0; + return (double) lastPromptTokens.get() / effective; + } + + /** 是否应触发自动压缩 */ + public boolean shouldAutoCompact() { + return getUsagePercentage() >= AUTO_COMPACT_THRESHOLD_PCT; + } + + /** 是否已达到阻塞阈值(必须压缩才能继续) */ + public boolean isBlocking() { + return getUsagePercentage() >= BLOCKING_THRESHOLD_PCT; + } + + /** 获取自动压缩触发的 token 阈值 */ + public long getAutoCompactThreshold() { + return (long) (getEffectiveWindow() * AUTO_COMPACT_THRESHOLD_PCT); + } + + /** 获取当前 token 警告状态 */ + public TokenWarningState getTokenWarningState() { + double pct = getUsagePercentage(); + if (pct >= BLOCKING_THRESHOLD_PCT) return TokenWarningState.BLOCKING; + if (pct >= AUTO_COMPACT_THRESHOLD_PCT) return TokenWarningState.ERROR; + if (pct >= WARNING_THRESHOLD_PCT) return TokenWarningState.WARNING; + return TokenWarningState.NORMAL; + } + + public long getContextWindowSize() { return contextWindowSize; } + + public void setContextWindowSize(long size) { this.contextWindowSize = size; } + + public long getReservedTokens() { return reservedTokens; } + + public void setReservedTokens(long reserved) { this.reservedTokens = reserved; } + /** 重置统计 */ public void reset() { totalInputTokens.set(0); totalOutputTokens.set(0); totalCacheReadTokens.set(0); totalCacheCreationTokens.set(0); + lastPromptTokens.set(0); apiCallCount.set(0); } diff --git a/src/main/java/com/claudecode/core/compact/AutoCompactManager.java b/src/main/java/com/claudecode/core/compact/AutoCompactManager.java new file mode 100644 index 0000000..9cd0ab5 --- /dev/null +++ b/src/main/java/com/claudecode/core/compact/AutoCompactManager.java @@ -0,0 +1,178 @@ +package com.claudecode.core.compact; + +import com.claudecode.core.TokenTracker; +import com.claudecode.core.compact.CompactionResult.CompactLayer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.model.ChatModel; + +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * 自动压缩编排器 —— 根据 token 使用量自动选择并执行压缩策略。 + *

+ * 对应 claude-code 的自动压缩编排逻辑。在 AgentLoop 中每次 API 响应后调用。 + * 流程:检查阈值 → 微压缩 → Session Memory 压缩 → 全量压缩(兜底) + * 熔断器:连续失败 {@value MAX_CONSECUTIVE_FAILURES} 次后暂停自动压缩。 + */ +public class AutoCompactManager { + + private static final Logger log = LoggerFactory.getLogger(AutoCompactManager.class); + + /** 连续失败阈值,超过后暂停自动压缩 */ + private static final int MAX_CONSECUTIVE_FAILURES = 3; + + private final MicroCompact microCompact; + private final SessionMemoryCompact sessionMemoryCompact; + private final FullCompact fullCompact; + private final TokenTracker tokenTracker; + + /** 连续压缩失败次数 */ + private int consecutiveFailures = 0; + + /** 是否已触发过熔断 */ + private boolean circuitBroken = false; + + /** 压缩事件回调(用于通知 UI) */ + private Consumer onCompactionEvent; + + public AutoCompactManager(ChatModel chatModel, TokenTracker tokenTracker) { + this.tokenTracker = tokenTracker; + this.microCompact = new MicroCompact(); + this.sessionMemoryCompact = new SessionMemoryCompact(chatModel); + this.fullCompact = new FullCompact(chatModel); + } + + public void setOnCompactionEvent(Consumer onCompactionEvent) { + this.onCompactionEvent = onCompactionEvent; + } + + /** + * 在每次 API 响应后调用,根据 token 使用状态自动执行压缩。 + * + * @param historySupplier 获取当前消息历史的函数 + * @param historyReplacer 替换消息历史的函数 + * @return 如果执行了压缩返回结果,否则返回 null + */ + public CompactionResult autoCompactIfNeeded( + Supplier> historySupplier, + Consumer> historyReplacer) { + + // 熔断器检查 + if (circuitBroken) { + return null; + } + + // 检查是否需要压缩 + if (!tokenTracker.shouldAutoCompact()) { + // 即使不需要自动压缩,也执行微压缩(成本极低) + List history = historySupplier.get(); + if (history instanceof java.util.ArrayList mutableHistory) { + microCompact.compact(mutableHistory); + } + return null; + } + + log.info("Auto-compact triggered at {}% token usage", + String.format("%.1f", tokenTracker.getUsagePercentage() * 100)); + + List history = historySupplier.get(); + + // 阶段 1:微压缩 + if (history instanceof java.util.ArrayList mutableHistory) { + CompactionResult microResult = microCompact.compact(mutableHistory); + if (microResult.success()) { + notifyEvent(microResult); + // 微压缩后重新检查是否仍需深度压缩 + if (!tokenTracker.shouldAutoCompact()) { + consecutiveFailures = 0; + return microResult; + } + } + } + + // 阶段 2:Session Memory 压缩 + try { + List compacted = sessionMemoryCompact.getCompactedHistory(history); + if (compacted != null) { + historyReplacer.accept(compacted); + CompactionResult result = CompactionResult.success( + CompactLayer.SESSION_MEMORY, + history.size(), compacted.size(), + "Auto session memory compact"); + consecutiveFailures = 0; + notifyEvent(result); + log.info("Session memory compact: {} → {} messages", history.size(), compacted.size()); + return result; + } + } catch (Exception e) { + log.warn("Session memory compact failed: {}", e.getMessage()); + } + + // 阶段 3:全量压缩(兜底) + try { + List compacted = fullCompact.compact(history); + if (compacted != null) { + historyReplacer.accept(compacted); + CompactionResult result = CompactionResult.success( + CompactLayer.FULL, + history.size(), compacted.size(), + "Auto full compact (fallback)"); + consecutiveFailures = 0; + notifyEvent(result); + log.info("Full compact fallback: {} → {} messages", history.size(), compacted.size()); + return result; + } + } catch (Exception e) { + log.warn("Full compact failed: {}", e.getMessage()); + } + + // 所有压缩方式均失败 + consecutiveFailures++; + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + circuitBroken = true; + log.error("Auto-compact circuit breaker triggered after {} consecutive failures", + consecutiveFailures); + CompactionResult result = CompactionResult.failure(CompactLayer.FULL, + "Circuit breaker: auto-compact disabled after " + consecutiveFailures + " failures"); + notifyEvent(result); + return result; + } + + return CompactionResult.failure(CompactLayer.SESSION_MEMORY, + "All compression strategies failed"); + } + + /** 手动重置熔断器 */ + public void resetCircuitBreaker() { + circuitBroken = false; + consecutiveFailures = 0; + log.info("Auto-compact circuit breaker reset"); + } + + public boolean isCircuitBroken() { + return circuitBroken; + } + + public int getConsecutiveFailures() { + return consecutiveFailures; + } + + /** 获取 FullCompact 实例(供 CompactCommand 委托使用) */ + public FullCompact getFullCompact() { + return fullCompact; + } + + private void notifyEvent(CompactionResult result) { + if (onCompactionEvent != null) { + try { + onCompactionEvent.accept(result); + } catch (Exception e) { + log.debug("Compaction event notification failed", e); + } + } + } +} diff --git a/src/main/java/com/claudecode/core/compact/CompactionResult.java b/src/main/java/com/claudecode/core/compact/CompactionResult.java new file mode 100644 index 0000000..b755247 --- /dev/null +++ b/src/main/java/com/claudecode/core/compact/CompactionResult.java @@ -0,0 +1,46 @@ +package com.claudecode.core.compact; + +/** + * 压缩操作的结果数据。 + * + * @param success 是否成功 + * @param layer 执行的压缩层级 + * @param messagesBefore 压缩前消息数 + * @param messagesAfter 压缩后消息数 + * @param summary AI 生成的摘要(可能为 null) + * @param reason 结果原因/描述 + */ +public record CompactionResult( + boolean success, + CompactLayer layer, + int messagesBefore, + int messagesAfter, + String summary, + String reason +) { + + /** 压缩层级 */ + public enum CompactLayer { + /** 微压缩:裁剪旧 tool_result 内容 */ + MICRO, + /** Session Memory:AI 摘要旧消息,保留近期段 */ + SESSION_MEMORY, + /** 全量压缩:AI 摘要全部,PTL 重试 */ + FULL, + /** 用户手动触发的全量压缩 */ + MANUAL + } + + public static CompactionResult success(CompactLayer layer, int before, int after, String summary) { + return new CompactionResult(true, layer, before, after, summary, + "Compacted from " + before + " to " + after + " messages"); + } + + public static CompactionResult noAction(CompactLayer layer, String reason) { + return new CompactionResult(false, layer, 0, 0, null, reason); + } + + public static CompactionResult failure(CompactLayer layer, String reason) { + return new CompactionResult(false, layer, 0, 0, null, reason); + } +} diff --git a/src/main/java/com/claudecode/core/compact/FullCompact.java b/src/main/java/com/claudecode/core/compact/FullCompact.java new file mode 100644 index 0000000..4799aad --- /dev/null +++ b/src/main/java/com/claudecode/core/compact/FullCompact.java @@ -0,0 +1,175 @@ +package com.claudecode.core.compact; + +import com.claudecode.core.compact.CompactionResult.CompactLayer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.messages.*; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; + +import java.util.ArrayList; +import java.util.List; + +/** + * 全量压缩 —— AI 摘要全部对话历史,带 PTL(Prompt Too Long)重试。 + *

+ * 对应 claude-code 的 fullCompact。当 SessionMemoryCompact 无法有效压缩时作为兜底。 + * PTL 重试策略:按 API Round(user→assistant→tool_result 为一组)逐步丢弃最旧的组。 + */ +public class FullCompact { + + private static final Logger log = LoggerFactory.getLogger(FullCompact.class); + + /** PTL 重试最大次数 */ + private static final int MAX_PTL_RETRIES = 5; + + /** 保留最近 N 条消息(不压缩) */ + private static final int KEEP_RECENT_MESSAGES = 2; + + private static final String FULL_COMPACT_PROMPT = """ + Please compress the following conversation history into a thorough summary. Requirements: + 1. Preserve ALL key decisions, code changes, and technical details + 2. Keep file paths, function names, class names, and specific identifiers + 3. Preserve user preferences, requirements, and constraints + 4. Record the current state of work: what was completed, what remains, what's blocked + 5. Note any errors encountered and their resolutions + 6. Keep important context about the project structure and architecture + 7. Output within 1000 words, using structured bullet points + + Conversation history: + """; + + private final ChatModel chatModel; + + public FullCompact(ChatModel chatModel) { + this.chatModel = chatModel; + } + + /** + * 执行全量压缩。 + * + * @param history 当前消息历史 + * @return 压缩后的新历史;如果失败返回 null + */ + public List compact(List history) { + if (history.size() <= KEEP_RECENT_MESSAGES + 2) { + return null; + } + + int before = history.size(); + Message systemMsg = history.getFirst(); + + // 按 API Round 分组 + List rounds = groupByRounds(history); + + // PTL 重试循环:逐步丢弃最旧的 round + int dropCount = 0; + while (dropCount < rounds.size() - 1 && dropCount < MAX_PTL_RETRIES) { + List remaining = rounds.subList(dropCount, rounds.size()); + + try { + String summary = generateFullSummary(remaining); + if (summary != null && !summary.isBlank()) { + // 构建新历史 + List newHistory = new ArrayList<>(); + newHistory.add(systemMsg); + newHistory.add(new SystemMessage("[Conversation Summary]\n" + summary)); + + // 保留最后几条消息 + for (int i = Math.max(1, before - KEEP_RECENT_MESSAGES); i < before; i++) { + newHistory.add(history.get(i)); + } + + log.info("Full compact succeeded: {} → {} messages (dropped {} rounds)", + before, newHistory.size(), dropCount); + return newHistory; + } + } catch (Exception e) { + log.warn("Full compact attempt failed (drop={}): {}", dropCount, e.getMessage()); + // PTL error — drop oldest round and retry + } + + dropCount++; + } + + log.error("Full compact failed after {} PTL retries", dropCount); + return null; + } + + /** + * 执行全量压缩并返回 CompactionResult。 + */ + public CompactionResult compactWithResult(List history) { + int before = history.size(); + List result = compact(history); + if (result == null) { + return CompactionResult.failure(CompactLayer.FULL, "Full compact failed"); + } + return CompactionResult.success(CompactLayer.FULL, before, result.size(), null); + } + + // ── 内部方法 ── + + /** 按 API Round 分组:一个 round = [UserMessage] + [AssistantMessage + ToolResponseMessages...] */ + private List groupByRounds(List history) { + List rounds = new ArrayList<>(); + List currentRound = new ArrayList<>(); + + for (int i = 1; i < history.size(); i++) { // 跳过系统消息 + Message msg = history.get(i); + if (msg instanceof UserMessage && !currentRound.isEmpty()) { + rounds.add(new ApiRound(List.copyOf(currentRound))); + currentRound.clear(); + } + currentRound.add(msg); + } + + if (!currentRound.isEmpty()) { + rounds.add(new ApiRound(List.copyOf(currentRound))); + } + + return rounds; + } + + /** 生成全量摘要 */ + private String generateFullSummary(List rounds) { + StringBuilder dialogText = new StringBuilder(); + + for (ApiRound round : rounds) { + for (Message msg : round.messages()) { + switch (msg) { + case UserMessage um -> dialogText.append("[User] ").append(um.getText()).append("\n"); + case AssistantMessage am -> { + if (am.getText() != null && !am.getText().isBlank()) { + String text = am.getText(); + if (text.length() > 600) text = text.substring(0, 600) + "..."; + dialogText.append("[Assistant] ").append(text).append("\n"); + } + if (am.hasToolCalls()) { + for (var tc : am.getToolCalls()) { + dialogText.append("[Tool Call] ").append(tc.name()).append("\n"); + } + } + } + case ToolResponseMessage trm -> { + for (var resp : trm.getResponses()) { + dialogText.append("[Tool Result: ").append(resp.name()).append("]\n"); + } + } + default -> {} + } + } + dialogText.append("---\n"); + } + + if (dialogText.isEmpty()) return null; + + Prompt prompt = new Prompt(List.of(new UserMessage(FULL_COMPACT_PROMPT + dialogText))); + ChatResponse response = chatModel.call(prompt); + return response.getResult().getOutput().getText(); + } + + /** API Round:一个用户请求 + AI 响应 + 工具调用的完整回合 */ + private record ApiRound(List messages) {} +} diff --git a/src/main/java/com/claudecode/core/compact/MicroCompact.java b/src/main/java/com/claudecode/core/compact/MicroCompact.java new file mode 100644 index 0000000..56818c5 --- /dev/null +++ b/src/main/java/com/claudecode/core/compact/MicroCompact.java @@ -0,0 +1,91 @@ +package com.claudecode.core.compact; + +import com.claudecode.core.compact.CompactionResult.CompactLayer; +import org.springframework.ai.chat.messages.*; + +import java.util.List; + +/** + * 微压缩 —— 在每次 API 调用后执行,裁剪旧的 tool_result 内容。 + *

+ * 对应 claude-code 的 microCompact。不需要额外 API 调用,纯本地操作。 + * 策略:保留最近 N 轮的 tool 结果,更早的只保留摘要行 "[Tool result truncated]"。 + */ +public class MicroCompact { + + /** 保留最近 N 条 ToolResponseMessage 的完整内容 */ + private static final int KEEP_RECENT_TOOL_RESULTS = 6; + + /** 截断阈值:超过此长度的旧 tool result 才会被截断 */ + private static final int TRUNCATE_THRESHOLD = 200; + + /** 截断后的占位文本 */ + private static final String TRUNCATED_MARKER = "[Tool result truncated — %d chars omitted]"; + + /** + * 对消息历史执行微压缩。 + * 直接在原始列表上原地修改以提升性能。 + * + * @param history 消息列表(直接修改) + * @return 压缩结果 + */ + public CompactionResult compact(List history) { + int totalToolResponses = 0; + int truncated = 0; + + // 倒序扫描,找到所有 ToolResponseMessage 的位置 + int recentCount = 0; + for (int i = history.size() - 1; i >= 0; i--) { + if (history.get(i) instanceof ToolResponseMessage) { + totalToolResponses++; + recentCount++; + if (recentCount > KEEP_RECENT_TOOL_RESULTS) { + // 需要截断 + ToolResponseMessage trm = (ToolResponseMessage) history.get(i); + if (shouldTruncate(trm)) { + history.set(i, truncateToolResponse(trm)); + truncated++; + } + } + } + } + + if (truncated == 0) { + return CompactionResult.noAction(CompactLayer.MICRO, "No tool results to truncate"); + } + + return CompactionResult.success(CompactLayer.MICRO, totalToolResponses, + totalToolResponses - truncated, null); + } + + /** 判断 ToolResponseMessage 是否需要截断 */ + private boolean shouldTruncate(ToolResponseMessage trm) { + var responses = trm.getResponses(); + if (responses == null || responses.isEmpty()) return false; + for (var resp : responses) { + if (resp.responseData() != null && resp.responseData().toString().length() > TRUNCATE_THRESHOLD) { + return true; + } + } + return false; + } + + /** 创建截断后的 ToolResponseMessage */ + private ToolResponseMessage truncateToolResponse(ToolResponseMessage original) { + var responses = original.getResponses(); + if (responses == null || responses.isEmpty()) return original; + + var truncatedResponses = responses.stream().map(resp -> { + String data = resp.responseData() != null ? resp.responseData().toString() : ""; + if (data.length() > TRUNCATE_THRESHOLD) { + String marker = String.format(TRUNCATED_MARKER, data.length()); + return new ToolResponseMessage.ToolResponse(resp.id(), resp.name(), marker); + } + return resp; + }).toList(); + + return ToolResponseMessage.builder() + .responses(truncatedResponses) + .build(); + } +} diff --git a/src/main/java/com/claudecode/core/compact/SessionMemoryCompact.java b/src/main/java/com/claudecode/core/compact/SessionMemoryCompact.java new file mode 100644 index 0000000..f7673d2 --- /dev/null +++ b/src/main/java/com/claudecode/core/compact/SessionMemoryCompact.java @@ -0,0 +1,297 @@ +package com.claudecode.core.compact; + +import com.claudecode.core.compact.CompactionResult.CompactLayer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.messages.*; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; + +import java.util.ArrayList; +import java.util.List; + +/** + * Session Memory 压缩 —— 保留近期消息段,用 AI 摘要旧消息。 + *

+ * 对应 claude-code 的 sessionMemoryCompact。这是主要的自动压缩方式。 + * 算法: + *

    + *
  1. 找到上次压缩的边界(通过检测 [Conversation Summary] 标记)
  2. + *
  3. 计算需要保留的近期消息段(至少保留 MIN_KEEP_TOKENS token 估算量 + MIN_KEEP_TEXT_MSGS 条文本消息)
  4. + *
  5. 将边界之后、保留段之前的消息通过 AI 生成摘要
  6. + *
  7. 用 [系统提示] + [历史摘要] + [新摘要] + [保留段] 替换历史
  8. + *
+ */ +public class SessionMemoryCompact { + + private static final Logger log = LoggerFactory.getLogger(SessionMemoryCompact.class); + + /** 最少保留的文本消息数(用户 + 助手) */ + private static final int MIN_KEEP_TEXT_MSGS = 5; + + /** 估算最少保留的 token 数 */ + private static final int MIN_KEEP_TOKENS = 10_000; + + /** 估算最多保留的 token 数 */ + private static final int MAX_KEEP_TOKENS = 40_000; + + /** 每字符估算的 token 数(粗略近似) */ + private static final double CHARS_PER_TOKEN = 4.0; + + private static final String SUMMARY_PROMPT = """ + Summarize the following conversation segment concisely but thoroughly. + Preserve: + - All key technical decisions and their rationale + - File paths, function names, class names, and specific code identifiers + - User requirements and preferences + - Current state of work (what was done, what remains) + - Any errors encountered and their resolutions + + Keep the summary under 800 words. Use bullet points for clarity. + + Conversation segment to summarize: + """; + + private final ChatModel chatModel; + + public SessionMemoryCompact(ChatModel chatModel) { + this.chatModel = chatModel; + } + + /** + * 执行 Session Memory 压缩。 + * + * @param history 当前消息历史(不直接修改,返回新列表) + * @return 压缩结果;如果无法压缩返回 noAction + */ + public CompactionResult compact(List history) { + if (history.size() <= MIN_KEEP_TEXT_MSGS + 2) { + return CompactionResult.noAction(CompactLayer.SESSION_MEMORY, + "Too few messages to compact"); + } + + int before = history.size(); + + // 找到系统提示词(第一条) + Message systemMsg = history.getFirst(); + + // 找到上一次摘要的位置(如果有的话) + int lastSummaryIndex = findLastSummaryIndex(history); + + // 从摘要之后开始计算可压缩区域 + int compressibleStart = lastSummaryIndex + 1; + + // 从末尾向前找保留段的起始位置 + int keepStart = findKeepStart(history, compressibleStart); + + // 如果可压缩区域太小,不值得压缩 + if (keepStart - compressibleStart < 4) { + return CompactionResult.noAction(CompactLayer.SESSION_MEMORY, + "Not enough messages to compress (only " + (keepStart - compressibleStart) + " in range)"); + } + + // 提取需要压缩的消息段 + List toCompress = history.subList(compressibleStart, keepStart); + + // 生成摘要 + String summary; + try { + summary = generateSummary(toCompress); + } catch (Exception e) { + log.warn("Session memory compression failed: {}", e.getMessage()); + return CompactionResult.failure(CompactLayer.SESSION_MEMORY, + "Summary generation failed: " + e.getMessage()); + } + + if (summary == null || summary.isBlank()) { + return CompactionResult.failure(CompactLayer.SESSION_MEMORY, + "Empty summary generated"); + } + + // 构建新历史 + List newHistory = new ArrayList<>(); + newHistory.add(systemMsg); + + // 保留旧的摘要(如果有的话,合并到新摘要中) + String previousSummary = extractPreviousSummary(history, lastSummaryIndex); + if (previousSummary != null) { + summary = "=== Earlier Context ===\n" + previousSummary + "\n\n=== Recent Activity ===\n" + summary; + } + + // 添加新的摘要消息 + newHistory.add(new SystemMessage("[Conversation Summary]\n" + summary)); + + // 添加保留段 + for (int i = keepStart; i < history.size(); i++) { + newHistory.add(history.get(i)); + } + + int after = newHistory.size(); + return new CompactionResult(true, CompactLayer.SESSION_MEMORY, before, after, summary, + "Session memory compacted: " + before + " → " + after + " messages"); + } + + /** + * 获取压缩后的新历史。调用方需要先调用 compact() 确认成功,然后调用此方法获取结果。 + * 为避免重复逻辑,此方法重新执行压缩并返回新历史。 + */ + public List getCompactedHistory(List history) { + if (history.size() <= MIN_KEEP_TEXT_MSGS + 2) return null; + + Message systemMsg = history.getFirst(); + int lastSummaryIndex = findLastSummaryIndex(history); + int compressibleStart = lastSummaryIndex + 1; + int keepStart = findKeepStart(history, compressibleStart); + + if (keepStart - compressibleStart < 4) return null; + + List toCompress = history.subList(compressibleStart, keepStart); + String summary; + try { + summary = generateSummary(toCompress); + } catch (Exception e) { + return null; + } + if (summary == null || summary.isBlank()) return null; + + List newHistory = new ArrayList<>(); + newHistory.add(systemMsg); + + String previousSummary = extractPreviousSummary(history, lastSummaryIndex); + if (previousSummary != null) { + summary = "=== Earlier Context ===\n" + previousSummary + "\n\n=== Recent Activity ===\n" + summary; + } + + newHistory.add(new SystemMessage("[Conversation Summary]\n" + summary)); + for (int i = keepStart; i < history.size(); i++) { + newHistory.add(history.get(i)); + } + + return newHistory; + } + + // ── 内部方法 ── + + /** 找到历史中最后一个 [Conversation Summary] 系统消息的索引 */ + private int findLastSummaryIndex(List history) { + for (int i = history.size() - 1; i >= 1; i--) { + if (history.get(i) instanceof SystemMessage sm + && sm.getText() != null + && sm.getText().startsWith("[Conversation Summary]")) { + return i; + } + } + return 0; // 没有摘要,从系统提示之后开始 + } + + /** 从末尾向前找保留段的起始位置 */ + private int findKeepStart(List history, int minStart) { + int textMsgCount = 0; + long estimatedTokens = 0; + + for (int i = history.size() - 1; i >= minStart; i--) { + Message msg = history.get(i); + + // 估算 token 量 + long msgTokens = estimateTokens(msg); + estimatedTokens += msgTokens; + + if (msg instanceof UserMessage || msg instanceof AssistantMessage) { + textMsgCount++; + } + + // 确保不会拆分 tool_use / tool_result 对 + // 如果当前是 ToolResponseMessage,它的 AssistantMessage(含 tool_calls)应在前面 + if (msg instanceof ToolResponseMessage && i > minStart) { + continue; // 继续往前包含对应的 AssistantMessage + } + + // 满足最小保留条件,且已达到上限则停止 + if (textMsgCount >= MIN_KEEP_TEXT_MSGS && estimatedTokens >= MIN_KEEP_TOKENS) { + // 检查是否达到 token 上限 + if (estimatedTokens >= MAX_KEEP_TOKENS) { + return i; + } + } + } + + // 如果从 minStart 开始全部都在保留范围内,返回 minStart + // 说明消息不够多,不需要压缩 + return minStart; + } + + /** 估算消息的 token 数 */ + private long estimateTokens(Message msg) { + String text = switch (msg) { + case UserMessage um -> um.getText(); + case AssistantMessage am -> am.getText(); + case SystemMessage sm -> sm.getText(); + case ToolResponseMessage trm -> { + StringBuilder sb = new StringBuilder(); + for (var resp : trm.getResponses()) { + if (resp.responseData() != null) { + sb.append(resp.responseData().toString()); + } + } + yield sb.toString(); + } + default -> ""; + }; + if (text == null || text.isEmpty()) return 10; // 最小估算 + return (long) (text.length() / CHARS_PER_TOKEN); + } + + /** 提取上一次的摘要文本 */ + private String extractPreviousSummary(List history, int summaryIndex) { + if (summaryIndex <= 0) return null; + Message msg = history.get(summaryIndex); + if (msg instanceof SystemMessage sm && sm.getText() != null) { + String text = sm.getText(); + if (text.startsWith("[Conversation Summary]\n")) { + return text.substring("[Conversation Summary]\n".length()); + } + if (text.startsWith("[Conversation Summary] ")) { + return text.substring("[Conversation Summary] ".length()); + } + } + return null; + } + + /** 调用 AI 生成对话段摘要 */ + private String generateSummary(List segment) { + StringBuilder dialogText = new StringBuilder(); + for (Message msg : segment) { + switch (msg) { + case UserMessage um -> dialogText.append("[User] ").append(um.getText()).append("\n"); + case AssistantMessage am -> { + if (am.getText() != null && !am.getText().isBlank()) { + String text = am.getText(); + if (text.length() > 800) text = text.substring(0, 800) + "..."; + dialogText.append("[Assistant] ").append(text).append("\n"); + } + if (am.hasToolCalls()) { + for (var tc : am.getToolCalls()) { + dialogText.append("[Tool Call] ").append(tc.name()).append("\n"); + } + } + } + case ToolResponseMessage trm -> { + for (var resp : trm.getResponses()) { + String data = resp.responseData() != null ? resp.responseData().toString() : ""; + if (data.length() > 200) data = data.substring(0, 200) + "..."; + dialogText.append("[Tool Result: ").append(resp.name()).append("] ") + .append(data).append("\n"); + } + } + default -> {} + } + } + + if (dialogText.isEmpty()) return null; + + Prompt prompt = new Prompt(List.of(new UserMessage(SUMMARY_PROMPT + dialogText))); + ChatResponse response = chatModel.call(prompt); + return response.getResult().getOutput().getText(); + } +} diff --git a/src/main/java/com/claudecode/permission/DangerousPatterns.java b/src/main/java/com/claudecode/permission/DangerousPatterns.java new file mode 100644 index 0000000..0fc25d9 --- /dev/null +++ b/src/main/java/com/claudecode/permission/DangerousPatterns.java @@ -0,0 +1,105 @@ +package com.claudecode.permission; + +import java.util.List; +import java.util.Set; + +/** + * 危险命令模式检测 —— 识别可能有害的 shell 命令。 + *

+ * 即使在 BYPASS 模式下也会对这些命令发出警告。 + */ +public final class DangerousPatterns { + + private DangerousPatterns() {} + + /** 危险 shell 命令前缀(不区分大小写匹配) */ + private static final List DANGEROUS_BASH_PREFIXES = List.of( + "rm -rf /", + "rm -rf ~", + "rm -rf .", + "rm -r /", + "rmdir /s", + "del /f /s /q", + "format ", + "mkfs.", + "dd if=", + "> /dev/sda", + "chmod -R 777 /", + "chown -R", + ":(){:|:&};:" // fork bomb + ); + + /** 危险代码执行模式 */ + private static final List CODE_EXECUTION_PATTERNS = List.of( + "eval ", + "exec ", + "python -c", + "python3 -c", + "node -e", + "ruby -e", + "perl -e", + "| sh", + "| bash", + "| zsh", + "| powershell", + "| pwsh", + "curl | sh", + "wget | sh", + "Invoke-Expression", + "iex ", + "Start-Process", + "Add-Type" + ); + + /** 在规则匹配中应自动拒绝的工具级通配符 */ + private static final Set DANGEROUS_TOOL_WILDCARDS = Set.of( + "Bash", // 不应允许所有 bash 命令 + "Bash(*)", + "PowerShell", + "PowerShell(*)" + ); + + /** + * 检测命令是否包含危险模式 + * + * @param command shell 命令文本 + * @return 如果危险返回原因描述,否则返回 null + */ + public static String detectDangerous(String command) { + if (command == null || command.isBlank()) return null; + String lower = command.toLowerCase().trim(); + + for (String prefix : DANGEROUS_BASH_PREFIXES) { + if (lower.startsWith(prefix.toLowerCase()) || lower.contains(prefix.toLowerCase())) { + return "Dangerous command detected: " + prefix.trim(); + } + } + + for (String pattern : CODE_EXECUTION_PATTERNS) { + if (lower.contains(pattern.toLowerCase())) { + return "Code execution pattern detected: " + pattern.trim(); + } + } + + return null; + } + + /** + * 检测是否为危险的工具级通配符规则 + *

+ * 用于防止用户添加过于宽泛的 "always allow" 规则。 + */ + public static boolean isDangerousWildcard(String ruleStr) { + return DANGEROUS_TOOL_WILDCARDS.contains(ruleStr); + } + + /** + * 获取危险原因的简短描述 + */ + public static String getDangerLevel(String command) { + String reason = detectDangerous(command); + if (reason == null) return "LOW"; + if (reason.contains("Dangerous command")) return "HIGH"; + return "MEDIUM"; + } +} diff --git a/src/main/java/com/claudecode/permission/PermissionRuleEngine.java b/src/main/java/com/claudecode/permission/PermissionRuleEngine.java new file mode 100644 index 0000000..832771d --- /dev/null +++ b/src/main/java/com/claudecode/permission/PermissionRuleEngine.java @@ -0,0 +1,173 @@ +package com.claudecode.permission; + +import com.claudecode.permission.PermissionTypes.*; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 权限规则引擎 —— 根据规则、模式和工具属性做出权限决策。 + *

+ * 决策流程: + *

    + *
  1. 检查全局模式(BYPASS → 全部允许,DONT_ASK → 拒绝需确认的)
  2. + *
  3. 检查 alwaysDeny 规则 → 匹配则 DENY
  4. + *
  5. 检查 alwaysAllow 规则 → 匹配则 ALLOW
  6. + *
  7. 只读工具 → ALLOW
  8. + *
  9. ACCEPT_EDITS 模式下文件操作 → ALLOW
  10. + *
  11. 检查危险命令 → 强制 ASK
  12. + *
  13. 默认 → ASK
  14. + *
+ */ +public class PermissionRuleEngine { + + private static final Set FILE_EDIT_TOOLS = Set.of("Write", "Edit", "NotebookEdit"); + private static final Set READ_ONLY_TOOLS = Set.of( + "Read", "Glob", "Grep", "ListFiles", "WebFetch", "WebSearch", + "TodoRead", "TaskGet", "TaskList", "AskUserQuestion" + ); + + private final PermissionSettings settings; + + public PermissionRuleEngine(PermissionSettings settings) { + this.settings = settings; + } + + /** + * 评估工具调用的权限 + * + * @param toolName 工具名称 + * @param input 工具参数 + * @param isReadOnly 工具是否为只读 + * @return 权限决策 + */ + public PermissionDecision evaluate(String toolName, Map input, boolean isReadOnly) { + PermissionMode mode = settings.getCurrentMode(); + + // BYPASS 模式:全部允许 + if (mode == PermissionMode.BYPASS) { + return PermissionDecision.allow("Bypass mode enabled"); + } + + // 获取命令内容(用于 Bash/PowerShell 的命令匹配) + String command = extractCommand(toolName, input); + + // 检查所有持久化规则 + List rules = settings.getAllRules(); + + // 1. 检查 alwaysDeny 规则 + for (var rule : rules) { + if (rule.behavior() == PermissionBehavior.DENY && matchesRule(rule, toolName, command)) { + return PermissionDecision.deny("Denied by rule: " + PermissionSettings.formatRule(rule)); + } + } + + // 2. 检查 alwaysAllow 规则 + for (var rule : rules) { + if (rule.behavior() == PermissionBehavior.ALLOW && matchesRule(rule, toolName, command)) { + return PermissionDecision.allow("Allowed by rule: " + PermissionSettings.formatRule(rule)); + } + } + + // 3. 只读工具直接放行 + if (isReadOnly || READ_ONLY_TOOLS.contains(toolName)) { + return PermissionDecision.allow("Read-only tool"); + } + + // 4. ACCEPT_EDITS 模式:文件操作工具自动允许 + if (mode == PermissionMode.ACCEPT_EDITS && FILE_EDIT_TOOLS.contains(toolName)) { + return PermissionDecision.allow("File edits auto-allowed in accept-edits mode"); + } + + // 5. DONT_ASK 模式:自动拒绝 + if (mode == PermissionMode.DONT_ASK) { + return PermissionDecision.deny("Auto-denied in dont-ask mode"); + } + + // 6. 检查危险命令(强制 ASK,附带警告) + if (command != null) { + String danger = DangerousPatterns.detectDangerous(command); + if (danger != null) { + String prefix = extractCommandPrefix(command); + return new PermissionDecision( + PermissionBehavior.ASK, + "⚠ DANGEROUS: " + danger, + toolName, prefix, List.of() + ); + } + } + + // 7. 默认:需要用户确认 + String prefix = extractCommandPrefix(command); + return PermissionDecision.ask(toolName, prefix); + } + + /** + * 根据用户选择应用权限变更 + */ + public void applyChoice(PermissionChoice choice, String toolName, String command) { + String prefix = extractCommandPrefix(command); + switch (choice) { + case ALWAYS_ALLOW -> { + var rule = prefix != null + ? PermissionRule.forCommand(toolName, prefix, PermissionBehavior.ALLOW) + : PermissionRule.forTool(toolName, PermissionBehavior.ALLOW); + // 检查是否为危险通配符 + String ruleStr = PermissionSettings.formatRule(rule); + if (!DangerousPatterns.isDangerousWildcard(ruleStr)) { + settings.addUserRule(rule); + } + } + case ALWAYS_DENY -> { + var rule = prefix != null + ? PermissionRule.forCommand(toolName, prefix, PermissionBehavior.DENY) + : PermissionRule.forTool(toolName, PermissionBehavior.DENY); + settings.addUserRule(rule); + } + case ALLOW_ONCE, DENY_ONCE -> { + // 单次操作,不持久化 + } + } + } + + // ── 内部匹配方法 ── + + /** 检查规则是否匹配当前工具和命令 */ + boolean matchesRule(PermissionRule rule, String toolName, String command) { + // 工具名不匹配直接跳过 + if (!rule.toolName().equalsIgnoreCase(toolName)) return false; + + String content = rule.ruleContent(); + // 通配符 * 匹配所有命令 + if ("*".equals(content)) return true; + + // 前缀匹配模式:npm:* 匹配以 "npm" 开头的命令 + if (content.endsWith(":*") && command != null) { + String prefix = content.substring(0, content.length() - 2); + return command.toLowerCase().startsWith(prefix.toLowerCase()); + } + + // 精确匹配 + return content.equalsIgnoreCase(command); + } + + /** 从工具参数中提取命令文本 */ + private String extractCommand(String toolName, Map input) { + if (input == null) return null; + return switch (toolName) { + case "Bash" -> (String) input.get("command"); + case "Write" -> (String) input.get("file_path"); + case "Edit" -> (String) input.get("file_path"); + default -> null; + }; + } + + /** 提取命令前缀(第一个空格前的部分) */ + private String extractCommandPrefix(String command) { + if (command == null || command.isBlank()) return null; + String trimmed = command.trim(); + int space = trimmed.indexOf(' '); + return space > 0 ? trimmed.substring(0, space) : trimmed; + } +} diff --git a/src/main/java/com/claudecode/permission/PermissionSettings.java b/src/main/java/com/claudecode/permission/PermissionSettings.java new file mode 100644 index 0000000..aeb7b4f --- /dev/null +++ b/src/main/java/com/claudecode/permission/PermissionSettings.java @@ -0,0 +1,200 @@ +package com.claudecode.permission; + +import com.claudecode.permission.PermissionTypes.PermissionBehavior; +import com.claudecode.permission.PermissionTypes.PermissionRule; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * 权限设置持久化 —— 管理用户级和项目级权限规则文件。 + *

+ * 存储结构: + *

    + *
  • 用户级: ~/.claude-code-java/settings.json
  • + *
  • 项目级: .claude-code-java/settings.json
  • + *
+ * 加载优先级: 项目级 > 用户级 > 会话级 + */ +public class PermissionSettings { + + private static final String SETTINGS_DIR = ".claude-code-java"; + private static final String SETTINGS_FILE = "settings.json"; + private static final ObjectMapper MAPPER = new ObjectMapper() + .enable(SerializationFeature.INDENT_OUTPUT); + + /** 内存中的合并规则(从所有来源加载后合并) */ + private final List sessionRules = new ArrayList<>(); + private PermissionTypes.PermissionMode currentMode = PermissionTypes.PermissionMode.DEFAULT; + + private final Path userSettingsPath; + private final Path projectSettingsPath; + + private SettingsData userData = new SettingsData(); + private SettingsData projectData = new SettingsData(); + + public PermissionSettings() { + this(Path.of(System.getProperty("user.home")), + Path.of(System.getProperty("user.dir"))); + } + + public PermissionSettings(Path userHome, Path projectDir) { + this.userSettingsPath = userHome.resolve(SETTINGS_DIR).resolve(SETTINGS_FILE); + this.projectSettingsPath = projectDir.resolve(SETTINGS_DIR).resolve(SETTINGS_FILE); + } + + /** 从磁盘加载所有设置 */ + public void load() { + userData = loadFromFile(userSettingsPath); + projectData = loadFromFile(projectSettingsPath); + // 项目级模式优先 + if (projectData.permissions.mode != null) { + currentMode = projectData.permissions.mode; + } else if (userData.permissions.mode != null) { + currentMode = userData.permissions.mode; + } + } + + /** 获取所有合并后的规则(项目级 > 用户级 > 会话级) */ + public List getAllRules() { + var rules = new ArrayList(); + // 项目级优先 + rules.addAll(toRules(projectData.permissions.alwaysAllow, PermissionBehavior.ALLOW)); + rules.addAll(toRules(projectData.permissions.alwaysDeny, PermissionBehavior.DENY)); + // 用户级 + rules.addAll(toRules(userData.permissions.alwaysAllow, PermissionBehavior.ALLOW)); + rules.addAll(toRules(userData.permissions.alwaysDeny, PermissionBehavior.DENY)); + // 会话级 + rules.addAll(sessionRules); + return rules; + } + + /** 添加规则并保存到用户级设置 */ + public void addUserRule(PermissionRule rule) { + if (rule.behavior() == PermissionBehavior.ALLOW) { + userData.permissions.alwaysAllow.add(formatRule(rule)); + } else if (rule.behavior() == PermissionBehavior.DENY) { + userData.permissions.alwaysDeny.add(formatRule(rule)); + } + saveToFile(userSettingsPath, userData); + } + + /** 添加规则到会话级(不持久化) */ + public void addSessionRule(PermissionRule rule) { + sessionRules.add(rule); + } + + /** 移除用户级规则 */ + public void removeUserRule(String ruleStr) { + userData.permissions.alwaysAllow.remove(ruleStr); + userData.permissions.alwaysDeny.remove(ruleStr); + saveToFile(userSettingsPath, userData); + } + + /** 清除所有规则 */ + public void clearAll() { + userData.permissions.alwaysAllow.clear(); + userData.permissions.alwaysDeny.clear(); + projectData.permissions.alwaysAllow.clear(); + projectData.permissions.alwaysDeny.clear(); + sessionRules.clear(); + saveToFile(userSettingsPath, userData); + } + + public PermissionTypes.PermissionMode getCurrentMode() { + return currentMode; + } + + public void setCurrentMode(PermissionTypes.PermissionMode mode) { + this.currentMode = mode; + userData.permissions.mode = mode; + saveToFile(userSettingsPath, userData); + } + + /** 获取所有已保存规则的可读列表 */ + public List listRules() { + var result = new ArrayList(); + for (var r : userData.permissions.alwaysAllow) { + result.add("[user] ALLOW " + r); + } + for (var r : userData.permissions.alwaysDeny) { + result.add("[user] DENY " + r); + } + for (var r : projectData.permissions.alwaysAllow) { + result.add("[proj] ALLOW " + r); + } + for (var r : projectData.permissions.alwaysDeny) { + result.add("[proj] DENY " + r); + } + for (var r : sessionRules) { + result.add("[sess] " + r.behavior() + " " + formatRule(r)); + } + return result; + } + + // ── 内部方法 ── + + private SettingsData loadFromFile(Path path) { + if (!Files.exists(path)) return new SettingsData(); + try { + return MAPPER.readValue(path.toFile(), SettingsData.class); + } catch (IOException e) { + return new SettingsData(); + } + } + + private void saveToFile(Path path, SettingsData data) { + try { + Files.createDirectories(path.getParent()); + MAPPER.writeValue(path.toFile(), data); + } catch (IOException e) { + // 静默失败,不影响主流程 + } + } + + private List toRules(List ruleStrings, PermissionBehavior behavior) { + return ruleStrings.stream() + .map(s -> parseRule(s, behavior)) + .toList(); + } + + /** 解析规则字符串,格式: "ToolName(pattern)" 或 "ToolName" */ + static PermissionRule parseRule(String ruleStr, PermissionBehavior behavior) { + int parenStart = ruleStr.indexOf('('); + if (parenStart > 0 && ruleStr.endsWith(")")) { + String toolName = ruleStr.substring(0, parenStart); + String content = ruleStr.substring(parenStart + 1, ruleStr.length() - 1); + return new PermissionRule(toolName, content, behavior); + } + return PermissionRule.forTool(ruleStr, behavior); + } + + /** 格式化规则为字符串 */ + static String formatRule(PermissionRule rule) { + if ("*".equals(rule.ruleContent())) { + return rule.toolName(); + } + return rule.toolName() + "(" + rule.ruleContent() + ")"; + } + + // ── JSON 数据结构 ── + + @JsonIgnoreProperties(ignoreUnknown = true) + static class SettingsData { + public PermissionsBlock permissions = new PermissionsBlock(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + static class PermissionsBlock { + public PermissionTypes.PermissionMode mode; + public List alwaysAllow = new ArrayList<>(); + public List alwaysDeny = new ArrayList<>(); + public List additionalDirectories = new ArrayList<>(); + } +} diff --git a/src/main/java/com/claudecode/permission/PermissionTypes.java b/src/main/java/com/claudecode/permission/PermissionTypes.java new file mode 100644 index 0000000..6fe472c --- /dev/null +++ b/src/main/java/com/claudecode/permission/PermissionTypes.java @@ -0,0 +1,110 @@ +package com.claudecode.permission; + +import java.util.List; + +/** + * 权限管理类型定义 —— 对应 claude-code 中的 permissions.ts。 + */ +public final class PermissionTypes { + + private PermissionTypes() {} + + /** 权限行为 */ + public enum PermissionBehavior { + ALLOW, // 允许执行 + DENY, // 拒绝执行 + ASK // 需要用户确认 + } + + /** 权限模式 */ + public enum PermissionMode { + /** 默认模式:非只读工具需要用户确认 */ + DEFAULT, + /** 自动允许文件编辑,shell 命令仍需确认 */ + ACCEPT_EDITS, + /** 跳过所有权限检查(不安全) */ + BYPASS, + /** 自动拒绝而非询问用户(无头模式) */ + DONT_ASK + } + + /** + * 权限规则 —— 定义工具和命令模式的权限行为。 + *

+ * 示例: + *

    + *
  • {@code PermissionRule("Bash", "npm:*", ALLOW)} — 允许所有 npm 命令
  • + *
  • {@code PermissionRule("Bash", "rm -rf:*", DENY)} — 拒绝 rm -rf
  • + *
  • {@code PermissionRule("Write", "*", ALLOW)} — 允许所有文件写入
  • + *
+ * + * @param toolName 工具名称(如 Bash, Write, Edit) + * @param ruleContent 规则内容,支持通配符 *(如 "npm:*", "git:*", "*") + * @param behavior 权限行为 + */ + public record PermissionRule( + String toolName, + String ruleContent, + PermissionBehavior behavior + ) { + /** 匹配整个工具(无命令模式限制) */ + public static PermissionRule forTool(String toolName, PermissionBehavior behavior) { + return new PermissionRule(toolName, "*", behavior); + } + + /** 匹配工具的特定命令前缀 */ + public static PermissionRule forCommand(String toolName, String prefix, PermissionBehavior behavior) { + return new PermissionRule(toolName, prefix + ":*", behavior); + } + } + + /** 权限决策结果 */ + public record PermissionDecision( + PermissionBehavior behavior, + String reason, + String toolName, + String commandPrefix, + List suggestedRules + ) { + public static PermissionDecision allow(String reason) { + return new PermissionDecision(PermissionBehavior.ALLOW, reason, null, null, List.of()); + } + + public static PermissionDecision deny(String reason) { + return new PermissionDecision(PermissionBehavior.DENY, reason, null, null, List.of()); + } + + public static PermissionDecision ask(String toolName, String commandPrefix) { + // 生成建议规则供用户选择 "always allow" + var suggested = List.of( + PermissionRule.forCommand(toolName, commandPrefix, PermissionBehavior.ALLOW) + ); + return new PermissionDecision(PermissionBehavior.ASK, "Requires user confirmation", + toolName, commandPrefix, suggested); + } + + public boolean isAllowed() { + return behavior == PermissionBehavior.ALLOW; + } + + public boolean isDenied() { + return behavior == PermissionBehavior.DENY; + } + + public boolean needsAsk() { + return behavior == PermissionBehavior.ASK; + } + } + + /** 权限确认选项(用户在 UI 中的选择) */ + public enum PermissionChoice { + /** 允许本次执行 */ + ALLOW_ONCE, + /** 始终允许此模式 */ + ALWAYS_ALLOW, + /** 拒绝本次执行 */ + DENY_ONCE, + /** 始终拒绝此模式 */ + ALWAYS_DENY + } +} diff --git a/src/main/java/com/claudecode/repl/ReplSession.java b/src/main/java/com/claudecode/repl/ReplSession.java index c94e454..ccca17e 100644 --- a/src/main/java/com/claudecode/repl/ReplSession.java +++ b/src/main/java/com/claudecode/repl/ReplSession.java @@ -6,6 +6,9 @@ import com.claudecode.command.CommandRegistry; import com.claudecode.console.*; import com.claudecode.core.AgentLoop; import com.claudecode.core.ConversationPersistence; +import com.claudecode.permission.DangerousPatterns; +import com.claudecode.permission.PermissionTypes.PermissionChoice; +import com.claudecode.permission.PermissionTypes.PermissionDecision; import com.claudecode.tool.ToolRegistry; import com.claudecode.tool.impl.AskUserQuestionTool; import org.jline.reader.*; @@ -430,11 +433,21 @@ public class ReplSession { /** * 显示权限确认提示并等待用户输入。 - * 用于危险操作(文件写入、bash 执行等)前的安全确认。 + * 支持 4 种选项:Y(允许一次) / A(始终允许) / N(拒绝) / D(始终拒绝)。 */ - private boolean promptPermission(AgentLoop.PermissionRequest request) { + private PermissionChoice promptPermission(AgentLoop.PermissionRequest request) { out.println(); - out.println(AnsiStyle.yellow(" ⚠ Permission Required")); + + // 检查是否为危险命令 + PermissionDecision decision = request.decision(); + boolean isDangerous = (decision != null && decision.reason() != null + && decision.reason().startsWith("⚠ DANGEROUS")); + + if (isDangerous) { + out.println(AnsiStyle.red(" ⚠ DANGEROUS Operation")); + } else { + out.println(AnsiStyle.yellow(" ⚠ Permission Required")); + } out.println(" " + "─".repeat(50)); out.println(" " + AnsiStyle.bold("Tool: ") + AnsiStyle.cyan(request.toolName())); out.println(" " + AnsiStyle.bold("Action: ") + request.activityDescription()); @@ -448,30 +461,46 @@ public class ReplSession { out.println(" " + AnsiStyle.dim("Args: " + argsPreview)); } + // 显示建议规则 + String suggestedRule = null; + if (decision != null && decision.commandPrefix() != null) { + suggestedRule = request.toolName() + "(" + decision.commandPrefix() + ":*)"; + } + out.println(" " + "─".repeat(50)); - out.print(" " + AnsiStyle.bold("Allow execution?") + AnsiStyle.dim(" [Y/n/always] ") + AnsiStyle.BOLD + AnsiStyle.BRIGHT_CYAN + "→ " + AnsiStyle.RESET); + out.println(" " + AnsiStyle.green("[Y]") + " Allow once"); + if (suggestedRule != null && !isDangerous) { + out.println(" " + AnsiStyle.green("[A]") + " Always allow " + AnsiStyle.cyan(suggestedRule)); + } + out.println(" " + AnsiStyle.red("[N]") + " Deny"); + if (suggestedRule != null) { + out.println(" " + AnsiStyle.red("[D]") + " Always deny this pattern"); + } + out.print(" " + AnsiStyle.bold("Choice") + AnsiStyle.dim(" [Y/a/n/d] ") + AnsiStyle.BOLD + AnsiStyle.BRIGHT_CYAN + "→ " + AnsiStyle.RESET); out.flush(); String answer = readLineForPermission(); - if (answer == null) return false; + if (answer == null) return PermissionChoice.DENY_ONCE; answer = answer.strip().toLowerCase(); - // "always" → 禁用后续权限确认 - if (answer.equals("always") || answer.equals("a")) { - agentLoop.setOnPermissionRequest(null); // 移除权限回调 - out.println(AnsiStyle.green(" ✓ All subsequent operations authorized")); - return true; - } - - // 空字符串或 y/yes → 允许 - if (answer.isEmpty() || answer.equals("y") || answer.equals("yes")) { - return true; - } - - // 其他输入 → 拒绝 - out.println(AnsiStyle.red(" ✗ Operation denied")); - return false; + return switch (answer) { + case "a", "always" -> { + out.println(AnsiStyle.green(" ✓ Rule saved: always allow " + + (suggestedRule != null ? suggestedRule : request.toolName()))); + yield PermissionChoice.ALWAYS_ALLOW; + } + case "d" -> { + out.println(AnsiStyle.red(" ✗ Rule saved: always deny " + + (suggestedRule != null ? suggestedRule : request.toolName()))); + yield PermissionChoice.ALWAYS_DENY; + } + case "n", "no" -> { + out.println(AnsiStyle.red(" ✗ Operation denied")); + yield PermissionChoice.DENY_ONCE; + } + default -> PermissionChoice.ALLOW_ONCE; // 空字符串、y、yes → 允许 + }; } /** 读取权限确认的用户输入(兼容 JLine 和 Scanner 模式) */