feat: Phase 4C infrastructure services + 4D debug commands

4C Services:
- RateLimiter: sliding window rate limiting, concurrent semaphore, cooldowns
- TokenEstimationService: approximate token counting, cost estimation
- NotificationService: cross-platform desktop notifications
- InternalLogger: structured session logging with export

4D Debug Commands:
- /debug: toggle debug mode, view internal logs
- /heapdump: JVM heap dump and memory pool info (Java advantage)
- /trace: request/response tracing
- /ctx-viz: context window token distribution visualization
- /reset-limits: reset rate limits and cooldowns
- /sandbox: sandbox mode toggle (strict/permissive/none)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
pull/1/head
abel533 1 month ago
parent d09809e924
commit dd47566cb8
  1. 137
      src/main/java/com/claudecode/command/impl/ContextVizCommand.java
  2. 107
      src/main/java/com/claudecode/command/impl/DebugCommand.java
  3. 114
      src/main/java/com/claudecode/command/impl/HeapdumpCommand.java
  4. 60
      src/main/java/com/claudecode/command/impl/ResetLimitsCommand.java
  5. 87
      src/main/java/com/claudecode/command/impl/SandboxCommand.java
  6. 83
      src/main/java/com/claudecode/command/impl/TraceCommand.java
  7. 7
      src/main/java/com/claudecode/config/AppConfig.java
  8. 182
      src/main/java/com/claudecode/core/InternalLogger.java
  9. 155
      src/main/java/com/claudecode/core/NotificationService.java
  10. 218
      src/main/java/com/claudecode/core/RateLimiter.java
  11. 165
      src/main/java/com/claudecode/core/TokenEstimationService.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<String> 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
}
}

@ -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<String> 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();
}
}

@ -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);
}
}

@ -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<String> 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();
}
}

@ -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();
}
}

@ -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();
}
}

@ -200,6 +200,13 @@ public class AppConfig {
new FeedbackCommand(), new FeedbackCommand(),
new ReleaseNotesCommand(), new ReleaseNotesCommand(),
new KeybindingsCommand(), new KeybindingsCommand(),
// Phase 4D 调试命令
new DebugCommand(),
new HeapdumpCommand(),
new TraceCommand(),
new ContextVizCommand(),
new ResetLimitsCommand(),
new SandboxCommand(),
// Exit 放最后 // Exit 放最后
new ExitCommand() new ExitCommand()
); );

@ -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
* <p>
* 功能
* <ul>
* <li>结构化会话日志独立于 SLF4J</li>
* <li>可调试级别normal/verbose/debug</li>
* <li>导出为文本文件</li>
* <li>最近 N 条日志内存缓存用于 /debug 命令</li>
* <li>日志文件按天分割</li>
* </ul>
* <p>
* 存储位置: ~/.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<LogEntry> 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);
}
}
}

@ -0,0 +1,155 @@
package com.claudecode.core;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
/**
* 通知服务 对应 claude-code notifier 服务
* <p>
* 提供跨平台桌面通知功能
* <ul>
* <li>Windows: PowerShell toast notification</li>
* <li>macOS: osascript display notification</li>
* <li>Linux: notify-send</li>
* <li>Fallback: terminal bell (BEL character)</li>
* </ul>
* <p>
* 可配置
* <ul>
* <li>启用/禁用通知</li>
* <li>声音提示开关</li>
* <li>仅在窗口不活跃时通知</li>
* </ul>
*/
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; }
}

@ -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
* <p>
* 本地实现无远程策略服务支持
* <ul>
* <li>滑动窗口请求频率限制</li>
* <li>并发工具执行限制</li>
* <li>按键API/tool/command独立限流</li>
* <li>冷却时间和指数退避</li>
* </ul>
*/
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<String, SlidingWindow> windows = new ConcurrentHashMap<>();
/** 冷却中的 key */
private final ConcurrentHashMap<String, Instant> 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--;
}
}
}
}

@ -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
* <p>
* 使用简化的 cl100k_base / o200k_base 编码近似估算 token 数量
* 不使用真正的 BPE 编码器避免大型词表依赖而是基于统计规律近似
* <p>
* 近似规则
* <ul>
* <li>英文文本~4 chars/token</li>
* <li>代码~3.5 chars/token</li>
* <li>中文/日文/韩文~1.5 chars/token</li>
* <li>JSON/结构化数据~3 chars/token</li>
* </ul>
*/
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("]"));
}
}
Loading…
Cancel
Save