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。这是主要的自动压缩方式。
+ * 算法:
+ *
+ * - 找到上次压缩的边界(通过检测 [Conversation Summary] 标记)
+ * - 计算需要保留的近期消息段(至少保留 MIN_KEEP_TOKENS token 估算量 + MIN_KEEP_TEXT_MSGS 条文本消息)
+ * - 将边界之后、保留段之前的消息通过 AI 生成摘要
+ * - 用 [系统提示] + [历史摘要] + [新摘要] + [保留段] 替换历史
+ *
+ */
+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;
+
+/**
+ * 权限规则引擎 —— 根据规则、模式和工具属性做出权限决策。
+ *
+ * 决策流程:
+ *
+ * - 检查全局模式(BYPASS → 全部允许,DONT_ASK → 拒绝需确认的)
+ * - 检查 alwaysDeny 规则 → 匹配则 DENY
+ * - 检查 alwaysAllow 规则 → 匹配则 ALLOW
+ * - 只读工具 → ALLOW
+ * - ACCEPT_EDITS 模式下文件操作 → ALLOW
+ * - 检查危险命令 → 强制 ASK
+ * - 默认 → ASK
+ *
+ */
+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 模式) */