权限管理: - 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>pull/1/head
parent
c989d3b451
commit
730551cb3f
@ -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 使用量自动选择并执行压缩策略。 |
||||||
|
* <p> |
||||||
|
* 对应 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<CompactionResult> 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<CompactionResult> onCompactionEvent) { |
||||||
|
this.onCompactionEvent = onCompactionEvent; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 在每次 API 响应后调用,根据 token 使用状态自动执行压缩。 |
||||||
|
* |
||||||
|
* @param historySupplier 获取当前消息历史的函数 |
||||||
|
* @param historyReplacer 替换消息历史的函数 |
||||||
|
* @return 如果执行了压缩返回结果,否则返回 null |
||||||
|
*/ |
||||||
|
public CompactionResult autoCompactIfNeeded( |
||||||
|
Supplier<List<Message>> historySupplier, |
||||||
|
Consumer<List<Message>> historyReplacer) { |
||||||
|
|
||||||
|
// 熔断器检查
|
||||||
|
if (circuitBroken) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// 检查是否需要压缩
|
||||||
|
if (!tokenTracker.shouldAutoCompact()) { |
||||||
|
// 即使不需要自动压缩,也执行微压缩(成本极低)
|
||||||
|
List<Message> history = historySupplier.get(); |
||||||
|
if (history instanceof java.util.ArrayList<Message> mutableHistory) { |
||||||
|
microCompact.compact(mutableHistory); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
log.info("Auto-compact triggered at {}% token usage", |
||||||
|
String.format("%.1f", tokenTracker.getUsagePercentage() * 100)); |
||||||
|
|
||||||
|
List<Message> history = historySupplier.get(); |
||||||
|
|
||||||
|
// 阶段 1:微压缩
|
||||||
|
if (history instanceof java.util.ArrayList<Message> mutableHistory) { |
||||||
|
CompactionResult microResult = microCompact.compact(mutableHistory); |
||||||
|
if (microResult.success()) { |
||||||
|
notifyEvent(microResult); |
||||||
|
// 微压缩后重新检查是否仍需深度压缩
|
||||||
|
if (!tokenTracker.shouldAutoCompact()) { |
||||||
|
consecutiveFailures = 0; |
||||||
|
return microResult; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 阶段 2:Session Memory 压缩
|
||||||
|
try { |
||||||
|
List<Message> 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<Message> 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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
@ -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)重试。 |
||||||
|
* <p> |
||||||
|
* 对应 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<Message> compact(List<Message> history) { |
||||||
|
if (history.size() <= KEEP_RECENT_MESSAGES + 2) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
int before = history.size(); |
||||||
|
Message systemMsg = history.getFirst(); |
||||||
|
|
||||||
|
// 按 API Round 分组
|
||||||
|
List<ApiRound> rounds = groupByRounds(history); |
||||||
|
|
||||||
|
// PTL 重试循环:逐步丢弃最旧的 round
|
||||||
|
int dropCount = 0; |
||||||
|
while (dropCount < rounds.size() - 1 && dropCount < MAX_PTL_RETRIES) { |
||||||
|
List<ApiRound> remaining = rounds.subList(dropCount, rounds.size()); |
||||||
|
|
||||||
|
try { |
||||||
|
String summary = generateFullSummary(remaining); |
||||||
|
if (summary != null && !summary.isBlank()) { |
||||||
|
// 构建新历史
|
||||||
|
List<Message> 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<Message> history) { |
||||||
|
int before = history.size(); |
||||||
|
List<Message> 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<ApiRound> groupByRounds(List<Message> history) { |
||||||
|
List<ApiRound> rounds = new ArrayList<>(); |
||||||
|
List<Message> 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<ApiRound> 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<Message> messages) {} |
||||||
|
} |
||||||
@ -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 内容。 |
||||||
|
* <p> |
||||||
|
* 对应 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<Message> 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(); |
||||||
|
} |
||||||
|
} |
||||||
@ -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 摘要旧消息。 |
||||||
|
* <p> |
||||||
|
* 对应 claude-code 的 sessionMemoryCompact。这是主要的自动压缩方式。 |
||||||
|
* 算法: |
||||||
|
* <ol> |
||||||
|
* <li>找到上次压缩的边界(通过检测 [Conversation Summary] 标记)</li> |
||||||
|
* <li>计算需要保留的近期消息段(至少保留 MIN_KEEP_TOKENS token 估算量 + MIN_KEEP_TEXT_MSGS 条文本消息)</li> |
||||||
|
* <li>将边界之后、保留段之前的消息通过 AI 生成摘要</li> |
||||||
|
* <li>用 [系统提示] + [历史摘要] + [新摘要] + [保留段] 替换历史</li> |
||||||
|
* </ol> |
||||||
|
*/ |
||||||
|
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<Message> 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<Message> 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<Message> 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<Message> getCompactedHistory(List<Message> 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<Message> toCompress = history.subList(compressibleStart, keepStart); |
||||||
|
String summary; |
||||||
|
try { |
||||||
|
summary = generateSummary(toCompress); |
||||||
|
} catch (Exception e) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
if (summary == null || summary.isBlank()) return null; |
||||||
|
|
||||||
|
List<Message> 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<Message> 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<Message> 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<Message> 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<Message> 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(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,105 @@ |
|||||||
|
package com.claudecode.permission; |
||||||
|
|
||||||
|
import java.util.List; |
||||||
|
import java.util.Set; |
||||||
|
|
||||||
|
/** |
||||||
|
* 危险命令模式检测 —— 识别可能有害的 shell 命令。 |
||||||
|
* <p> |
||||||
|
* 即使在 BYPASS 模式下也会对这些命令发出警告。 |
||||||
|
*/ |
||||||
|
public final class DangerousPatterns { |
||||||
|
|
||||||
|
private DangerousPatterns() {} |
||||||
|
|
||||||
|
/** 危险 shell 命令前缀(不区分大小写匹配) */ |
||||||
|
private static final List<String> 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<String> 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<String> 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; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 检测是否为危险的工具级通配符规则 |
||||||
|
* <p> |
||||||
|
* 用于防止用户添加过于宽泛的 "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"; |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
|
||||||
|
/** |
||||||
|
* 权限规则引擎 —— 根据规则、模式和工具属性做出权限决策。 |
||||||
|
* <p> |
||||||
|
* 决策流程: |
||||||
|
* <ol> |
||||||
|
* <li>检查全局模式(BYPASS → 全部允许,DONT_ASK → 拒绝需确认的)</li> |
||||||
|
* <li>检查 alwaysDeny 规则 → 匹配则 DENY</li> |
||||||
|
* <li>检查 alwaysAllow 规则 → 匹配则 ALLOW</li> |
||||||
|
* <li>只读工具 → ALLOW</li> |
||||||
|
* <li>ACCEPT_EDITS 模式下文件操作 → ALLOW</li> |
||||||
|
* <li>检查危险命令 → 强制 ASK</li> |
||||||
|
* <li>默认 → ASK</li> |
||||||
|
* </ol> |
||||||
|
*/ |
||||||
|
public class PermissionRuleEngine { |
||||||
|
|
||||||
|
private static final Set<String> FILE_EDIT_TOOLS = Set.of("Write", "Edit", "NotebookEdit"); |
||||||
|
private static final Set<String> 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<String, Object> 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<PermissionRule> 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<String, Object> 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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
|
||||||
|
/** |
||||||
|
* 权限设置持久化 —— 管理用户级和项目级权限规则文件。 |
||||||
|
* <p> |
||||||
|
* 存储结构: |
||||||
|
* <ul> |
||||||
|
* <li>用户级: ~/.claude-code-java/settings.json</li> |
||||||
|
* <li>项目级: .claude-code-java/settings.json</li> |
||||||
|
* </ul> |
||||||
|
* 加载优先级: 项目级 > 用户级 > 会话级 |
||||||
|
*/ |
||||||
|
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<PermissionRule> 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<PermissionRule> getAllRules() { |
||||||
|
var rules = new ArrayList<PermissionRule>(); |
||||||
|
// 项目级优先
|
||||||
|
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<String> listRules() { |
||||||
|
var result = new ArrayList<String>(); |
||||||
|
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<PermissionRule> toRules(List<String> 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<String> alwaysAllow = new ArrayList<>(); |
||||||
|
public List<String> alwaysDeny = new ArrayList<>(); |
||||||
|
public List<String> additionalDirectories = new ArrayList<>(); |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 权限规则 —— 定义工具和命令模式的权限行为。 |
||||||
|
* <p> |
||||||
|
* 示例: |
||||||
|
* <ul> |
||||||
|
* <li>{@code PermissionRule("Bash", "npm:*", ALLOW)} — 允许所有 npm 命令</li> |
||||||
|
* <li>{@code PermissionRule("Bash", "rm -rf:*", DENY)} — 拒绝 rm -rf</li> |
||||||
|
* <li>{@code PermissionRule("Write", "*", ALLOW)} — 允许所有文件写入</li> |
||||||
|
* </ul> |
||||||
|
* |
||||||
|
* @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<PermissionRule> 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 |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue