diff --git a/src/main/java/com/claudecode/command/impl/ContextVizCommand.java b/src/main/java/com/claudecode/command/impl/ContextVizCommand.java new file mode 100644 index 0000000..fae6e4b --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/ContextVizCommand.java @@ -0,0 +1,137 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; +import com.claudecode.core.TokenEstimationService; + +import java.util.List; + +/** + * /ctx-viz 命令 —— 上下文可视化(token 分布、消息结构)。 + */ +public class ContextVizCommand implements SlashCommand { + + @Override + public String name() { return "ctx-viz"; } + + @Override + public String description() { return "Visualize context window token distribution"; } + + @Override + public List aliases() { return List.of("context", "ctx"); } + + @Override + public String execute(String args, CommandContext context) { + StringBuilder sb = new StringBuilder(); + sb.append("\n").append(AnsiStyle.bold(" 📊 Context Window Visualization\n")); + sb.append(" ").append("─".repeat(45)).append("\n\n"); + + if (context.agentLoop() == null) { + return sb.append(" No active agent loop\n").toString(); + } + + var toolCtx = context.agentLoop().getToolContext(); + + // Get or create token estimation service + TokenEstimationService estimator = new TokenEstimationService(); + + // Model context limit + String modelName = "claude-sonnet-4-20250514"; + Object modelObj = toolCtx.get("MODEL_NAME"); + if (modelObj instanceof String m) modelName = m; + + int contextLimit = getContextLimit(modelName); + + // Estimate system prompt tokens + int systemPromptTokens = 0; + Object sysMsgObj = toolCtx.get("SYSTEM_PROMPT_CACHE"); + if (sysMsgObj instanceof String sysMsg) { + systemPromptTokens = estimator.estimateTokens(sysMsg); + } else { + systemPromptTokens = 4000; // typical estimate + } + + // Tool definitions estimate + int toolDefTokens = 0; + Object toolCountObj = toolCtx.get("TOOL_REGISTRY"); + if (toolCountObj != null) { + toolDefTokens = 2000; // ~65 tokens per tool × 30 tools + } + + // Token tracker data + long inputTokens = 0; + long outputTokens = 0; + var tokenTracker = context.agentLoop().getTokenTracker(); + if (tokenTracker != null) { + inputTokens = tokenTracker.getInputTokens(); + outputTokens = tokenTracker.getOutputTokens(); + } + + // Calculate remaining + long usedTokens = systemPromptTokens + toolDefTokens + inputTokens; + long remainingTokens = Math.max(0, contextLimit - usedTokens); + double usagePercent = (double) usedTokens / contextLimit * 100; + + // Display context bar + sb.append(AnsiStyle.bold(" Context Usage\n")); + int barWidth = 40; + int filled = (int) (usagePercent / 100 * barWidth); + filled = Math.min(filled, barWidth); + + String barColor; + if (usagePercent > 90) barColor = AnsiStyle.red("█".repeat(filled)); + else if (usagePercent > 70) barColor = AnsiStyle.yellow("█".repeat(filled)); + else barColor = AnsiStyle.green("█".repeat(filled)); + + sb.append(" [").append(barColor).append("░".repeat(barWidth - filled)).append("] "); + sb.append(String.format("%.1f%%\n\n", usagePercent)); + + // Token breakdown + sb.append(AnsiStyle.bold(" Token Breakdown\n")); + sb.append(" ┌────────────────────────┬────────────┬───────┐\n"); + sb.append(" │ Component │ Tokens │ % │\n"); + sb.append(" ├────────────────────────┼────────────┼───────┤\n"); + + appendRow(sb, "System Prompt", systemPromptTokens, contextLimit); + appendRow(sb, "Tool Definitions", toolDefTokens, contextLimit); + appendRow(sb, "Conversation (input)", (int) inputTokens, contextLimit); + appendRow(sb, "Generated (output)", (int) outputTokens, contextLimit); + sb.append(" ├────────────────────────┼────────────┼───────┤\n"); + appendRow(sb, "Total Used", (int) usedTokens, contextLimit); + appendRow(sb, "Remaining", (int) remainingTokens, contextLimit); + sb.append(" └────────────────────────┴────────────┴───────┘\n\n"); + + // Model info + sb.append(AnsiStyle.bold(" Model Info\n")); + sb.append(" Model: ").append(modelName).append("\n"); + sb.append(" Context: ").append(estimator.formatTokenCount(contextLimit)).append(" tokens\n"); + sb.append(" Cost est: $").append(String.format("%.4f", + estimator.estimateCost(inputTokens, outputTokens, modelName))).append("\n"); + + // Recommendations + if (usagePercent > 80) { + sb.append("\n ⚠️ ").append(AnsiStyle.yellow("Context is getting full. Consider /compact to free space.")).append("\n"); + } + + return sb.toString(); + } + + private void appendRow(StringBuilder sb, String label, int tokens, int total) { + double pct = (double) tokens / total * 100; + sb.append(String.format(" │ %-22s │ %10s │ %5.1f │\n", + label, formatTokens(tokens), pct)); + } + + private String formatTokens(int tokens) { + if (tokens >= 1_000_000) return String.format("%.1fM", tokens / 1_000_000.0); + if (tokens >= 1_000) return String.format("%.1fK", tokens / 1_000.0); + return String.valueOf(tokens); + } + + private int getContextLimit(String model) { + if (model.contains("opus")) return 200_000; + if (model.contains("haiku")) return 200_000; + return 200_000; // All Claude 3+ models: 200K + } +} diff --git a/src/main/java/com/claudecode/command/impl/DebugCommand.java b/src/main/java/com/claudecode/command/impl/DebugCommand.java new file mode 100644 index 0000000..78e35a4 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/DebugCommand.java @@ -0,0 +1,107 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; +import com.claudecode.core.InternalLogger; + +import java.util.List; + +/** + * /debug 命令 —— 调试模式开关 + 工具调用追踪。 + */ +public class DebugCommand implements SlashCommand { + + @Override + public String name() { return "debug"; } + + @Override + public String description() { return "Toggle debug mode and view internal logs"; } + + @Override + public List aliases() { return List.of("dbg"); } + + @Override + public String execute(String args, CommandContext context) { + String trimmed = (args == null) ? "" : args.trim(); + + StringBuilder sb = new StringBuilder(); + sb.append("\n").append(AnsiStyle.bold(" 🐛 Debug Mode\n")); + sb.append(" ").append("─".repeat(40)).append("\n\n"); + + if (context.agentLoop() == null) { + return sb.append(" No active agent loop\n").toString(); + } + + var toolCtx = context.agentLoop().getToolContext(); + + if (trimmed.equals("on") || trimmed.equals("enable")) { + toolCtx.set("DEBUG_MODE", true); + sb.append(" Debug mode: ").append(AnsiStyle.green("ENABLED")).append("\n"); + sb.append(" Tool call tracing is now active.\n"); + + // Set InternalLogger to DEBUG level if available + Object loggerObj = toolCtx.get("INTERNAL_LOGGER"); + if (loggerObj instanceof InternalLogger logger) { + logger.setLevel(InternalLogger.Level.DEBUG); + sb.append(" Internal log level: DEBUG\n"); + } + + } else if (trimmed.equals("off") || trimmed.equals("disable")) { + toolCtx.set("DEBUG_MODE", false); + sb.append(" Debug mode: ").append(AnsiStyle.red("DISABLED")).append("\n"); + + Object loggerObj = toolCtx.get("INTERNAL_LOGGER"); + if (loggerObj instanceof InternalLogger logger) { + logger.setLevel(InternalLogger.Level.NORMAL); + sb.append(" Internal log level: NORMAL\n"); + } + + } else if (trimmed.startsWith("log")) { + // /debug logs [N] — show recent internal logs + int count = 20; + String[] parts = trimmed.split("\\s+"); + if (parts.length > 1) { + try { count = Integer.parseInt(parts[1]); } catch (NumberFormatException ignored) {} + } + + Object loggerObj = toolCtx.get("INTERNAL_LOGGER"); + if (loggerObj instanceof InternalLogger logger) { + sb.append(AnsiStyle.bold(" Recent Logs (last " + count + ")\n\n")); + String logs = logger.getRecent(count); + if (logs.isEmpty()) { + sb.append(" No logs recorded yet.\n"); + } else { + sb.append(AnsiStyle.dim(logs)); + } + } else { + sb.append(" InternalLogger not available.\n"); + } + + } else if (trimmed.equals("tools")) { + // /debug tools — show tool call stats from ToolContext + sb.append(AnsiStyle.bold(" Tool Call Tracing\n\n")); + Object metrics = toolCtx.get("METRICS_COLLECTOR"); + if (metrics != null) { + sb.append(" Use /performance for detailed tool stats.\n"); + } else { + sb.append(" No metrics collector available.\n"); + } + + } else { + // Status + boolean debugOn = Boolean.TRUE.equals(toolCtx.get("DEBUG_MODE")); + sb.append(" Status: ").append(debugOn + ? AnsiStyle.green("ENABLED") : AnsiStyle.dim("disabled")).append("\n\n"); + + sb.append(AnsiStyle.bold(" Subcommands\n")); + sb.append(" /debug on Enable debug mode\n"); + sb.append(" /debug off Disable debug mode\n"); + sb.append(" /debug logs Show recent internal logs\n"); + sb.append(" /debug logs 50 Show last 50 log entries\n"); + sb.append(" /debug tools Show tool call tracing info\n"); + } + + return sb.toString(); + } +} diff --git a/src/main/java/com/claudecode/command/impl/HeapdumpCommand.java b/src/main/java/com/claudecode/command/impl/HeapdumpCommand.java new file mode 100644 index 0000000..eb223cc --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/HeapdumpCommand.java @@ -0,0 +1,114 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; + +import java.io.File; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.management.MemoryPoolMXBean; +import java.lang.management.MemoryUsage; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * /heapdump 命令 —— JVM 堆转储(Java 独有优势)。 + * 使用 HotSpotDiagnosticMXBean 进行堆转储。 + */ +public class HeapdumpCommand implements SlashCommand { + + @Override + public String name() { return "heapdump"; } + + @Override + public String description() { return "Generate JVM heap dump (Java advantage)"; } + + @Override + public String execute(String args, CommandContext context) { + String trimmed = (args == null) ? "" : args.trim(); + + StringBuilder sb = new StringBuilder(); + sb.append("\n").append(AnsiStyle.bold(" 📦 JVM Heap Dump\n")); + sb.append(" ").append("─".repeat(40)).append("\n\n"); + + if (trimmed.equals("info") || trimmed.isEmpty()) { + // Show memory pool details + MemoryMXBean memBean = ManagementFactory.getMemoryMXBean(); + MemoryUsage heap = memBean.getHeapMemoryUsage(); + MemoryUsage nonHeap = memBean.getNonHeapMemoryUsage(); + + sb.append(AnsiStyle.bold(" Heap Memory\n")); + sb.append(" Used: ").append(formatBytes(heap.getUsed())).append("\n"); + sb.append(" Committed: ").append(formatBytes(heap.getCommitted())).append("\n"); + sb.append(" Max: ").append(formatBytes(heap.getMax())).append("\n\n"); + + sb.append(AnsiStyle.bold(" Non-Heap Memory\n")); + sb.append(" Used: ").append(formatBytes(nonHeap.getUsed())).append("\n"); + sb.append(" Committed: ").append(formatBytes(nonHeap.getCommitted())).append("\n\n"); + + sb.append(AnsiStyle.bold(" Memory Pools\n")); + for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) { + MemoryUsage usage = pool.getUsage(); + if (usage != null && usage.getUsed() > 0) { + sb.append(" ").append(String.format("%-25s", pool.getName())) + .append(formatBytes(usage.getUsed())).append("\n"); + } + } + + sb.append("\n").append(AnsiStyle.dim(" Run /heapdump dump to generate a heap dump file")); + + } else if (trimmed.startsWith("dump")) { + // Determine output path + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")); + String filename = trimmed.length() > 5 ? trimmed.substring(5).trim() : ""; + if (filename.isEmpty()) { + filename = "heapdump-" + timestamp + ".hprof"; + } + Path dumpPath = Path.of(System.getProperty("user.dir"), filename); + + try { + var hotspot = ManagementFactory.getPlatformMXBean( + com.sun.management.HotSpotDiagnosticMXBean.class); + hotspot.dumpHeap(dumpPath.toString(), true); + long fileSize = dumpPath.toFile().length(); + sb.append(" ✅ Heap dump saved to:\n"); + sb.append(" ").append(AnsiStyle.cyan(dumpPath.toString())).append("\n"); + sb.append(" Size: ").append(formatBytes(fileSize)).append("\n\n"); + sb.append(AnsiStyle.dim(" Analyze with: jhat, MAT, or VisualVM")); + } catch (Exception e) { + sb.append(" ❌ Failed to create heap dump: ").append(e.getMessage()).append("\n"); + sb.append(AnsiStyle.dim(" Requires HotSpot JVM (OpenJDK or Oracle JDK)")); + } + + } else if (trimmed.equals("gc")) { + // Trigger GC and report + long beforeUsed = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); + System.gc(); + long afterUsed = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); + long freed = beforeUsed - afterUsed; + + sb.append(" 🗑 Garbage collection triggered\n"); + sb.append(" Before: ").append(formatBytes(beforeUsed)).append("\n"); + sb.append(" After: ").append(formatBytes(afterUsed)).append("\n"); + sb.append(" Freed: ").append(AnsiStyle.green(formatBytes(Math.max(0, freed)))).append("\n"); + + } else { + sb.append(AnsiStyle.bold(" Subcommands\n")); + sb.append(" /heapdump Show memory pool info\n"); + sb.append(" /heapdump dump Generate .hprof file\n"); + sb.append(" /heapdump gc Trigger garbage collection\n"); + } + + return sb.toString(); + } + + private String formatBytes(long bytes) { + if (bytes < 0) return "N/A"; + if (bytes >= 1_073_741_824) return String.format("%.1fGB", bytes / 1_073_741_824.0); + if (bytes >= 1_048_576) return String.format("%.1fMB", bytes / 1_048_576.0); + return String.format("%.0fKB", bytes / 1_024.0); + } +} diff --git a/src/main/java/com/claudecode/command/impl/ResetLimitsCommand.java b/src/main/java/com/claudecode/command/impl/ResetLimitsCommand.java new file mode 100644 index 0000000..5b7cf89 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/ResetLimitsCommand.java @@ -0,0 +1,60 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; +import com.claudecode.core.RateLimiter; + +import java.util.List; + +/** + * /reset-limits 命令 —— 重置速率限制。 + */ +public class ResetLimitsCommand implements SlashCommand { + + @Override + public String name() { return "reset-limits"; } + + @Override + public String description() { return "Reset rate limits and cooldowns"; } + + @Override + public List aliases() { return List.of("rl"); } + + @Override + public String execute(String args, CommandContext context) { + StringBuilder sb = new StringBuilder(); + sb.append("\n").append(AnsiStyle.bold(" 🔄 Rate Limit Reset\n")); + sb.append(" ").append("─".repeat(40)).append("\n\n"); + + if (context.agentLoop() == null) { + return sb.append(" No active agent loop\n").toString(); + } + + var toolCtx = context.agentLoop().getToolContext(); + Object limiterObj = toolCtx.get("RATE_LIMITER"); + + if (limiterObj instanceof RateLimiter limiter) { + // Show current state first + sb.append(AnsiStyle.bold(" Before Reset\n")); + sb.append(" Remaining (api): ").append(limiter.getRemaining("api")).append("\n"); + sb.append(" Remaining (tool): ").append(limiter.getRemaining("tool")).append("\n\n"); + + // Reset + limiter.resetAll(); + + sb.append(AnsiStyle.bold(" After Reset\n")); + sb.append(" Remaining (api): ").append(AnsiStyle.green( + String.valueOf(limiter.getRemaining("api")))).append("\n"); + sb.append(" Remaining (tool): ").append(AnsiStyle.green( + String.valueOf(limiter.getRemaining("tool")))).append("\n"); + sb.append(" Concurrent slots: ").append(AnsiStyle.green("all available")).append("\n\n"); + sb.append(" ✅ Rate limits have been reset.\n"); + } else { + sb.append(" No rate limiter configured.\n"); + sb.append(AnsiStyle.dim(" Rate limiting is not active in the current session.\n")); + } + + return sb.toString(); + } +} diff --git a/src/main/java/com/claudecode/command/impl/SandboxCommand.java b/src/main/java/com/claudecode/command/impl/SandboxCommand.java new file mode 100644 index 0000000..5f32c00 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/SandboxCommand.java @@ -0,0 +1,87 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; + +import java.util.List; + +/** + * /sandbox 命令 —— 沙箱模式切换。 + * 控制工具执行的安全隔离级别。 + */ +public class SandboxCommand implements SlashCommand { + + @Override + public String name() { return "sandbox"; } + + @Override + public String description() { return "Toggle sandbox mode for tool execution"; } + + @Override + public String execute(String args, CommandContext context) { + String trimmed = (args == null) ? "" : args.trim(); + + StringBuilder sb = new StringBuilder(); + sb.append("\n").append(AnsiStyle.bold(" 🏖 Sandbox Mode\n")); + sb.append(" ").append("─".repeat(40)).append("\n\n"); + + if (context.agentLoop() == null) { + return sb.append(" No active agent loop\n").toString(); + } + + var toolCtx = context.agentLoop().getToolContext(); + + if (trimmed.equals("on") || trimmed.equals("enable") || trimmed.equals("strict")) { + toolCtx.set("SANDBOX_MODE", "strict"); + sb.append(" Sandbox: ").append(AnsiStyle.green("STRICT")).append("\n\n"); + sb.append(" Restrictions:\n"); + sb.append(" • File writes limited to work directory\n"); + sb.append(" • Network access: disabled\n"); + sb.append(" • Shell commands: require approval\n"); + sb.append(" • System calls: blocked\n"); + + } else if (trimmed.equals("off") || trimmed.equals("disable") || trimmed.equals("none")) { + toolCtx.set("SANDBOX_MODE", "none"); + sb.append(" Sandbox: ").append(AnsiStyle.red("DISABLED")).append("\n"); + sb.append(" ⚠️ All tool operations are unrestricted.\n"); + + } else if (trimmed.equals("permissive")) { + toolCtx.set("SANDBOX_MODE", "permissive"); + sb.append(" Sandbox: ").append(AnsiStyle.yellow("PERMISSIVE")).append("\n\n"); + sb.append(" Restrictions:\n"); + sb.append(" • File writes: allowed with logging\n"); + sb.append(" • Network access: allowed\n"); + sb.append(" • Shell commands: allowed with logging\n"); + sb.append(" • System calls: require approval\n"); + + } else { + // Show current status + Object mode = toolCtx.get("SANDBOX_MODE"); + String current = (mode instanceof String m) ? m : "permissive"; + + sb.append(" Current mode: "); + sb.append(switch (current) { + case "strict" -> AnsiStyle.green("STRICT"); + case "none" -> AnsiStyle.red("NONE"); + default -> AnsiStyle.yellow("PERMISSIVE"); + }).append("\n\n"); + + sb.append(AnsiStyle.bold(" Available Modes\n")); + sb.append(" ┌─────────────┬────────────┬─────────┬──────────┐\n"); + sb.append(" │ Mode │ File Write │ Network │ Shell │\n"); + sb.append(" ├─────────────┼────────────┼─────────┼──────────┤\n"); + sb.append(" │ strict │ work dir │ blocked │ approval │\n"); + sb.append(" │ permissive │ logged │ allowed │ logged │\n"); + sb.append(" │ none │ unlimited │ allowed │ allowed │\n"); + sb.append(" └─────────────┴────────────┴─────────┴──────────┘\n\n"); + + sb.append(AnsiStyle.bold(" Usage\n")); + sb.append(" /sandbox strict Enable strict sandbox\n"); + sb.append(" /sandbox permissive Enable permissive sandbox\n"); + sb.append(" /sandbox off Disable sandbox\n"); + } + + return sb.toString(); + } +} diff --git a/src/main/java/com/claudecode/command/impl/TraceCommand.java b/src/main/java/com/claudecode/command/impl/TraceCommand.java new file mode 100644 index 0000000..d4cd2a6 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/TraceCommand.java @@ -0,0 +1,83 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; + +import java.time.Instant; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedDeque; + +/** + * /trace 命令 —— 请求/响应追踪。 + * 显示 API 调用追踪信息(模型调用、tool 调用链等)。 + */ +public class TraceCommand implements SlashCommand { + + @Override + public String name() { return "trace"; } + + @Override + public String description() { return "Show request/response tracing"; } + + @Override + public String execute(String args, CommandContext context) { + String trimmed = (args == null) ? "" : args.trim(); + + StringBuilder sb = new StringBuilder(); + sb.append("\n").append(AnsiStyle.bold(" 🔍 Request Tracing\n")); + sb.append(" ").append("─".repeat(40)).append("\n\n"); + + if (context.agentLoop() == null) { + return sb.append(" No active agent loop\n").toString(); + } + + var toolCtx = context.agentLoop().getToolContext(); + + if (trimmed.equals("on") || trimmed.equals("enable")) { + toolCtx.set("TRACE_ENABLED", true); + sb.append(" Tracing: ").append(AnsiStyle.green("ENABLED")).append("\n"); + sb.append(" API calls and tool executions will be traced.\n"); + + } else if (trimmed.equals("off") || trimmed.equals("disable")) { + toolCtx.set("TRACE_ENABLED", false); + sb.append(" Tracing: ").append(AnsiStyle.red("DISABLED")).append("\n"); + + } else if (trimmed.equals("clear")) { + toolCtx.set("TRACE_LOG", null); + sb.append(" Trace log cleared.\n"); + + } else { + // Show current trace info + boolean traceOn = Boolean.TRUE.equals(toolCtx.get("TRACE_ENABLED")); + sb.append(" Status: ").append(traceOn + ? AnsiStyle.green("ENABLED") : AnsiStyle.dim("disabled")).append("\n\n"); + + // Show thread info + sb.append(AnsiStyle.bold(" Active Threads\n")); + Thread.getAllStackTraces().entrySet().stream() + .filter(e -> e.getKey().getName().startsWith("agent") + || e.getKey().getName().contains("tool") + || e.getKey().getName().contains("http")) + .limit(10) + .forEach(e -> { + Thread t = e.getKey(); + sb.append(" ").append(String.format("%-30s", t.getName())) + .append(AnsiStyle.dim(t.getState().toString())).append("\n"); + }); + + // Show recent conversation turns + sb.append("\n").append(AnsiStyle.bold(" Conversation State\n")); + sb.append(" Session ID: ").append(context.agentLoop().getToolContext() + .get("SESSION_ID") != null ? toolCtx.get("SESSION_ID") : "default").append("\n"); + + sb.append("\n").append(AnsiStyle.bold(" Subcommands\n")); + sb.append(" /trace on Enable tracing\n"); + sb.append(" /trace off Disable tracing\n"); + sb.append(" /trace clear Clear trace log\n"); + } + + return sb.toString(); + } +} diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java index 992b0de..a92ae7e 100644 --- a/src/main/java/com/claudecode/config/AppConfig.java +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -200,6 +200,13 @@ public class AppConfig { new FeedbackCommand(), new ReleaseNotesCommand(), new KeybindingsCommand(), + // Phase 4D 调试命令 + new DebugCommand(), + new HeapdumpCommand(), + new TraceCommand(), + new ContextVizCommand(), + new ResetLimitsCommand(), + new SandboxCommand(), // Exit 放最后 new ExitCommand() ); diff --git a/src/main/java/com/claudecode/core/InternalLogger.java b/src/main/java/com/claudecode/core/InternalLogger.java new file mode 100644 index 0000000..1193f88 --- /dev/null +++ b/src/main/java/com/claudecode/core/InternalLogger.java @@ -0,0 +1,182 @@ +package com.claudecode.core; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.*; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 内部日志服务 —— 对应 claude-code 中 internalLogging。 + *

+ * 功能: + *

    + *
  • 结构化会话日志(独立于 SLF4J)
  • + *
  • 可调试级别(normal/verbose/debug)
  • + *
  • 导出为文本文件
  • + *
  • 最近 N 条日志内存缓存(用于 /debug 命令)
  • + *
  • 日志文件按天分割
  • + *
+ *

+ * 存储位置: ~/.claude-code-java/logs/{date}.log + */ +public class InternalLogger { + + private static final Logger log = LoggerFactory.getLogger(InternalLogger.class); + + public enum Level { NORMAL, VERBOSE, DEBUG } + + private final Path logDir; + private Level currentLevel = Level.NORMAL; + private final String sessionId; + + /** 最近日志缓存(用于 /debug 查看) */ + private final ConcurrentLinkedDeque recentLogs = new ConcurrentLinkedDeque<>(); + private static final int MAX_RECENT = 200; + + private final AtomicInteger entryCount = new AtomicInteger(0); + + public InternalLogger(String sessionId) { + this(sessionId, Path.of(System.getProperty("user.home"), ".claude-code-java", "logs")); + } + + public InternalLogger(String sessionId, Path logDir) { + this.sessionId = sessionId; + this.logDir = logDir; + } + + // ==================== 日志记录 ==================== + + public void info(String category, String message) { + record(Level.NORMAL, category, message); + } + + public void verbose(String category, String message) { + record(Level.VERBOSE, category, message); + } + + public void debug(String category, String message) { + record(Level.DEBUG, category, message); + } + + public void toolCall(String toolName, String input, String output, long durationMs) { + String msg = String.format("tool=%s duration=%dms input_len=%d output_len=%d", + toolName, durationMs, + input != null ? input.length() : 0, + output != null ? output.length() : 0); + record(Level.VERBOSE, "TOOL", msg); + } + + public void apiCall(String model, long inputTokens, long outputTokens, long durationMs) { + String msg = String.format("model=%s input=%d output=%d duration=%dms", + model, inputTokens, outputTokens, durationMs); + record(Level.NORMAL, "API", msg); + } + + public void error(String category, String message, Throwable throwable) { + String msg = throwable != null + ? message + " — " + throwable.getClass().getSimpleName() + ": " + throwable.getMessage() + : message; + record(Level.NORMAL, "ERROR:" + category, msg); + } + + public void command(String commandName, String args) { + record(Level.VERBOSE, "CMD", "/" + commandName + (args != null ? " " + args : "")); + } + + public void permission(String toolName, String decision) { + record(Level.VERBOSE, "PERM", toolName + " → " + decision); + } + + // ==================== 核心记录方法 ==================== + + private void record(Level level, String category, String message) { + if (level.ordinal() > currentLevel.ordinal()) return; + + LogEntry entry = new LogEntry( + Instant.now(), level, category, message, sessionId); + + // 内存缓存 + recentLogs.addLast(entry); + while (recentLogs.size() > MAX_RECENT) { + recentLogs.pollFirst(); + } + entryCount.incrementAndGet(); + + // 文件写入(异步友好 — 简单同步追加) + appendToFile(entry); + } + + private void appendToFile(LogEntry entry) { + try { + Files.createDirectories(logDir); + String date = LocalDate.now(ZoneId.systemDefault()).toString(); + Path file = logDir.resolve(date + ".log"); + String line = entry.format() + "\n"; + Files.writeString(file, line, StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (IOException e) { + // 静默失败 — 不能因为日志写入失败影响主流程 + } + } + + // ==================== 查询 ==================== + + /** + * 获取最近 N 条日志。 + */ + public String getRecent(int count) { + StringBuilder sb = new StringBuilder(); + var entries = recentLogs.stream() + .skip(Math.max(0, recentLogs.size() - count)) + .toList(); + for (LogEntry entry : entries) { + sb.append(entry.format()).append("\n"); + } + return sb.toString(); + } + + /** + * 导出完整日志到文件。 + */ + public Path export(Path targetFile) throws IOException { + StringBuilder sb = new StringBuilder(); + sb.append("# Session Log: ").append(sessionId).append("\n"); + sb.append("# Exported: ").append(Instant.now()).append("\n\n"); + for (LogEntry entry : recentLogs) { + sb.append(entry.format()).append("\n"); + } + Files.writeString(targetFile, sb.toString()); + return targetFile; + } + + // ==================== 配置 ==================== + + public void setLevel(Level level) { + this.currentLevel = level; + log.info("Internal log level set to {}", level); + } + + public Level getLevel() { return currentLevel; } + public int getEntryCount() { return entryCount.get(); } + public String getSessionId() { return sessionId; } + + // ==================== 日志条目 ==================== + + public record LogEntry( + Instant timestamp, + Level level, + String category, + String message, + String sessionId + ) { + public String format() { + String time = timestamp.toString().substring(11, 23); // HH:mm:ss.SSS + return String.format("[%s] %s [%s] %s", time, level.name().charAt(0), category, message); + } + } +} diff --git a/src/main/java/com/claudecode/core/NotificationService.java b/src/main/java/com/claudecode/core/NotificationService.java new file mode 100644 index 0000000..7fa801f --- /dev/null +++ b/src/main/java/com/claudecode/core/NotificationService.java @@ -0,0 +1,155 @@ +package com.claudecode.core; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +/** + * 通知服务 —— 对应 claude-code 中 notifier 服务。 + *

+ * 提供跨平台桌面通知功能: + *

    + *
  • Windows: PowerShell toast notification
  • + *
  • macOS: osascript display notification
  • + *
  • Linux: notify-send
  • + *
  • Fallback: terminal bell (BEL character)
  • + *
+ *

+ * 可配置: + *

    + *
  • 启用/禁用通知
  • + *
  • 声音提示开关
  • + *
  • 仅在窗口不活跃时通知
  • + *
+ */ +public class NotificationService { + + private static final Logger log = LoggerFactory.getLogger(NotificationService.class); + + private boolean enabled = true; + private boolean soundEnabled = true; + private boolean onlyWhenInactive = true; + + private final String os; + + public NotificationService() { + this.os = System.getProperty("os.name", "").toLowerCase(); + } + + /** + * 发送信息通知。 + */ + public void info(String title, String message) { + send(title, message, "info"); + } + + /** + * 发送警告通知。 + */ + public void warning(String title, String message) { + send(title, message, "warning"); + } + + /** + * 发送错误通知。 + */ + public void error(String title, String message) { + send(title, message, "error"); + } + + /** + * 发送通知。 + */ + public void send(String title, String message, String level) { + if (!enabled) return; + + // 播放声音 + if (soundEnabled) { + System.out.print('\u0007'); // BEL + System.out.flush(); + } + + // 发送桌面通知 + try { + if (os.contains("win")) { + sendWindows(title, message); + } else if (os.contains("mac")) { + sendMac(title, message); + } else if (os.contains("linux")) { + sendLinux(title, message, level); + } + } catch (Exception e) { + log.debug("Desktop notification failed: {}", e.getMessage()); + } + } + + /** + * 任务完成通知。 + */ + public void taskComplete(String taskName) { + info("Task Complete", taskName + " has finished"); + } + + /** + * 需要输入通知。 + */ + public void inputRequired(String reason) { + warning("Input Required", reason); + } + + /** + * 错误通知。 + */ + public void errorOccurred(String toolName, String errorMessage) { + error("Error in " + toolName, errorMessage); + } + + // ==================== 平台通知 ==================== + + private void sendWindows(String title, String message) throws IOException, InterruptedException { + String ps = String.format( + "[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')|Out-Null;" + + "$n=New-Object System.Windows.Forms.NotifyIcon;" + + "$n.Icon=[System.Drawing.SystemIcons]::Information;" + + "$n.Visible=$true;" + + "$n.ShowBalloonTip(3000,'%s','%s','Info');" + + "Start-Sleep 1;$n.Dispose()", + escape(title), escape(message)); + new ProcessBuilder("powershell", "-NoProfile", "-Command", ps) + .redirectErrorStream(true).start(); + } + + private void sendMac(String title, String message) throws IOException { + String script = String.format( + "display notification \"%s\" with title \"%s\"", + escape(message), escape(title)); + new ProcessBuilder("osascript", "-e", script) + .redirectErrorStream(true).start(); + } + + private void sendLinux(String title, String message, String level) throws IOException { + String urgency = switch (level) { + case "error" -> "critical"; + case "warning" -> "normal"; + default -> "low"; + }; + new ProcessBuilder("notify-send", "-u", urgency, title, message) + .redirectErrorStream(true).start(); + } + + private String escape(String s) { + return s.replace("'", "''").replace("\"", "\\\""); + } + + // ==================== 配置 ==================== + + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public boolean isEnabled() { return enabled; } + + public void setSoundEnabled(boolean enabled) { this.soundEnabled = enabled; } + public boolean isSoundEnabled() { return soundEnabled; } + + public void setOnlyWhenInactive(boolean only) { this.onlyWhenInactive = only; } + public boolean isOnlyWhenInactive() { return onlyWhenInactive; } +} diff --git a/src/main/java/com/claudecode/core/RateLimiter.java b/src/main/java/com/claudecode/core/RateLimiter.java new file mode 100644 index 0000000..56720cf --- /dev/null +++ b/src/main/java/com/claudecode/core/RateLimiter.java @@ -0,0 +1,218 @@ +package com.claudecode.core; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 速率限制器 —— 对应 claude-code 中 claudeAiLimits / policyLimits。 + *

+ * 本地实现(无远程策略服务),支持: + *

    + *
  • 滑动窗口请求频率限制
  • + *
  • 并发工具执行限制
  • + *
  • 按键(API/tool/command)独立限流
  • + *
  • 冷却时间和指数退避
  • + *
+ */ +public class RateLimiter { + + private static final Logger log = LoggerFactory.getLogger(RateLimiter.class); + + /** 滑动窗口时间帧 */ + private final Duration windowDuration; + + /** 窗口内最大请求数 */ + private final int maxRequestsPerWindow; + + /** 最大并发执行数 */ + private final int maxConcurrent; + + /** 并发信号量 */ + private final Semaphore concurrentSemaphore; + + /** 每个 key 的请求时间戳记录 */ + private final ConcurrentHashMap windows = new ConcurrentHashMap<>(); + + /** 冷却中的 key */ + private final ConcurrentHashMap cooldowns = new ConcurrentHashMap<>(); + + /** 全局请求计数(统计用) */ + private final AtomicInteger totalRequests = new AtomicInteger(0); + private final AtomicInteger totalRejections = new AtomicInteger(0); + + /** + * 创建速率限制器(默认配置)。 + */ + public RateLimiter() { + this(60, Duration.ofMinutes(1), 5); + } + + /** + * 创建速率限制器。 + * + * @param maxRequestsPerWindow 窗口内最大请求数 + * @param windowDuration 滑动窗口时长 + * @param maxConcurrent 最大并发执行数 + */ + public RateLimiter(int maxRequestsPerWindow, Duration windowDuration, int maxConcurrent) { + this.maxRequestsPerWindow = maxRequestsPerWindow; + this.windowDuration = windowDuration; + this.maxConcurrent = maxConcurrent; + this.concurrentSemaphore = new Semaphore(maxConcurrent); + } + + /** + * 尝试获取执行许可(非阻塞)。 + * + * @param key 限流键(如 "api", "tool:bash", "command:commit") + * @return 是否获得许可 + */ + public boolean tryAcquire(String key) { + totalRequests.incrementAndGet(); + + // 检查冷却 + Instant cooldownEnd = cooldowns.get(key); + if (cooldownEnd != null && Instant.now().isBefore(cooldownEnd)) { + totalRejections.incrementAndGet(); + log.debug("Rate limited (cooldown): {}", key); + return false; + } + cooldowns.remove(key); + + // 检查滑动窗口 + SlidingWindow window = windows.computeIfAbsent(key, + k -> new SlidingWindow(maxRequestsPerWindow, windowDuration)); + if (!window.tryAcquire()) { + totalRejections.incrementAndGet(); + log.debug("Rate limited (window): {} ({}/{})", key, window.getCount(), maxRequestsPerWindow); + return false; + } + + return true; + } + + /** + * 获取并发执行许可(阻塞,带超时)。 + * + * @param timeoutMs 超时毫秒数 + * @return 是否获得许可 + */ + public boolean acquireConcurrent(long timeoutMs) { + try { + return concurrentSemaphore.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + /** + * 释放并发执行许可。 + */ + public void releaseConcurrent() { + concurrentSemaphore.release(); + } + + /** + * 为指定 key 设置冷却时间。 + */ + public void setCooldown(String key, Duration cooldownDuration) { + cooldowns.put(key, Instant.now().plus(cooldownDuration)); + log.debug("Cooldown set for {}: {}", key, cooldownDuration); + } + + /** + * 重置指定 key 的限制。 + */ + public void reset(String key) { + windows.remove(key); + cooldowns.remove(key); + } + + /** + * 重置所有限制。 + */ + public void resetAll() { + windows.clear(); + cooldowns.clear(); + totalRequests.set(0); + totalRejections.set(0); + } + + /** + * 获取剩余可用请求数。 + */ + public int getRemaining(String key) { + SlidingWindow window = windows.get(key); + if (window == null) return maxRequestsPerWindow; + return Math.max(0, maxRequestsPerWindow - window.getCount()); + } + + /** + * 获取冷却剩余时间。 + */ + public Duration getCooldownRemaining(String key) { + Instant end = cooldowns.get(key); + if (end == null) return Duration.ZERO; + Duration remaining = Duration.between(Instant.now(), end); + return remaining.isNegative() ? Duration.ZERO : remaining; + } + + public int getTotalRequests() { return totalRequests.get(); } + public int getTotalRejections() { return totalRejections.get(); } + public int getAvailableConcurrent() { return concurrentSemaphore.availablePermits(); } + + /** + * 状态报告。 + */ + public String statusReport() { + return String.format("RateLimiter: %d/%d requests, %d rejections, %d/%d concurrent slots", + totalRequests.get(), maxRequestsPerWindow, + totalRejections.get(), + maxConcurrent - concurrentSemaphore.availablePermits(), maxConcurrent); + } + + // ==================== 滑动窗口实现 ==================== + + private static class SlidingWindow { + private final int maxRequests; + private final Duration windowDuration; + private final long[] timestamps; + private int head = 0; + private int count = 0; + + SlidingWindow(int maxRequests, Duration windowDuration) { + this.maxRequests = maxRequests; + this.windowDuration = windowDuration; + this.timestamps = new long[maxRequests + 1]; + } + + synchronized boolean tryAcquire() { + evict(); + if (count >= maxRequests) return false; + timestamps[(head + count) % timestamps.length] = System.currentTimeMillis(); + count++; + return true; + } + + synchronized int getCount() { + evict(); + return count; + } + + private void evict() { + long cutoff = System.currentTimeMillis() - windowDuration.toMillis(); + while (count > 0 && timestamps[head] < cutoff) { + head = (head + 1) % timestamps.length; + count--; + } + } + } +} diff --git a/src/main/java/com/claudecode/core/TokenEstimationService.java b/src/main/java/com/claudecode/core/TokenEstimationService.java new file mode 100644 index 0000000..cf13caa --- /dev/null +++ b/src/main/java/com/claudecode/core/TokenEstimationService.java @@ -0,0 +1,165 @@ +package com.claudecode.core; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; + +/** + * Token 估算服务 —— 对应 claude-code 中 tokenEstimation。 + *

+ * 使用简化的 cl100k_base / o200k_base 编码近似估算 token 数量。 + * 不使用真正的 BPE 编码器(避免大型词表依赖),而是基于统计规律近似。 + *

+ * 近似规则: + *

    + *
  • 英文文本:~4 chars/token
  • + *
  • 代码:~3.5 chars/token
  • + *
  • 中文/日文/韩文:~1.5 chars/token
  • + *
  • JSON/结构化数据:~3 chars/token
  • + *
+ */ +public class TokenEstimationService { + + private static final Logger log = LoggerFactory.getLogger(TokenEstimationService.class); + + private static final Pattern CJK_PATTERN = Pattern.compile("[\\u4e00-\\u9fff\\u3040-\\u30ff\\uac00-\\ud7a3]"); + private static final Pattern CODE_PATTERN = Pattern.compile("[{}()\\[\\];=<>|&!+\\-*/^~]"); + private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); + + /** 每 token 的平均字符数(各类型) */ + private static final double CHARS_PER_TOKEN_ENGLISH = 4.0; + private static final double CHARS_PER_TOKEN_CODE = 3.5; + private static final double CHARS_PER_TOKEN_CJK = 1.5; + private static final double CHARS_PER_TOKEN_JSON = 3.0; + + /** + * 估算文本的 token 数量。 + */ + public int estimateTokens(String text) { + if (text == null || text.isEmpty()) return 0; + + int totalChars = text.length(); + if (totalChars == 0) return 0; + + // 分析文本组成 + int cjkChars = countCJK(text); + int codeChars = countCodeChars(text); + int remainingChars = totalChars - cjkChars - codeChars; + + // 按类型估算 + double cjkTokens = cjkChars / CHARS_PER_TOKEN_CJK; + double codeTokens = codeChars / CHARS_PER_TOKEN_CODE; + double textTokens = remainingChars / CHARS_PER_TOKEN_ENGLISH; + + // 考虑 JSON 结构 + if (looksLikeJson(text)) { + return (int) Math.ceil(totalChars / CHARS_PER_TOKEN_JSON); + } + + int estimated = (int) Math.ceil(cjkTokens + codeTokens + textTokens); + + // 每段文本至少 1 个 token + return Math.max(1, estimated); + } + + /** + * 估算消息列表的总 token 数(包括消息结构开销)。 + */ + public int estimateMessageTokens(String role, String content) { + // 每条消息有 ~4 token 的结构开销(role 标记、分隔符等) + int contentTokens = estimateTokens(content); + return contentTokens + 4; + } + + /** + * 估算系统提示词 token 数。 + */ + public int estimateSystemPromptTokens(String systemPrompt) { + // 系统提示词通常有额外的缓存标记开销 + return estimateTokens(systemPrompt) + 10; + } + + /** + * 估算工具定义的 token 数。 + */ + public int estimateToolDefinitionTokens(String toolName, String description, String inputSchema) { + int tokens = estimateTokens(toolName) + estimateTokens(description); + if (inputSchema != null) { + tokens += estimateTokens(inputSchema); + } + // 工具定义的结构开销 + tokens += 20; + return tokens; + } + + /** + * 将 token 数转换为近似费用。 + * + * @param inputTokens 输入 token 数 + * @param outputTokens 输出 token 数 + * @param model 模型名称 + * @return 估计费用(美元) + */ + public double estimateCost(long inputTokens, long outputTokens, String model) { + double inputRate; + double outputRate; + + if (model != null && model.toLowerCase().contains("opus")) { + inputRate = 15.0; // $15/M + outputRate = 75.0; // $75/M + } else if (model != null && model.toLowerCase().contains("haiku")) { + inputRate = 0.25; // $0.25/M + outputRate = 1.25; // $1.25/M + } else { + // Default: Sonnet pricing + inputRate = 3.0; // $3/M + outputRate = 15.0; // $15/M + } + + return (inputTokens / 1_000_000.0 * inputRate) + + (outputTokens / 1_000_000.0 * outputRate); + } + + /** + * 格式化 token 数量。 + */ + public String formatTokenCount(int tokens) { + if (tokens >= 1_000_000) return String.format("%.1fM", tokens / 1_000_000.0); + if (tokens >= 1_000) return String.format("%.1fK", tokens / 1_000.0); + return String.valueOf(tokens); + } + + // ==================== 内部方法 ==================== + + private int countCJK(String text) { + int count = 0; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if ((c >= '\u4e00' && c <= '\u9fff') // CJK Unified + || (c >= '\u3040' && c <= '\u30ff') // Hiragana + Katakana + || (c >= '\uac00' && c <= '\ud7a3')) { // Hangul + count++; + } + } + return count; + } + + private int countCodeChars(String text) { + int count = 0; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if ("{}()[];=<>|&!+-*/^~@#$%".indexOf(c) >= 0) { + count++; + } + } + return count; + } + + private boolean looksLikeJson(String text) { + String trimmed = text.trim(); + return (trimmed.startsWith("{") && trimmed.endsWith("}")) + || (trimmed.startsWith("[") && trimmed.endsWith("]")); + } +}