feat: Phase4 命令系统与上下文增强

新增基础设施:
- TokenTracker: 真实Token使用量追踪和费用估算(按模型定价)
- SkillLoader: .claude/skills/ 扫描加载,支持YAML frontmatter元数据
- GitContext: Git分支/状态/最近提交收集,注入系统提示词

新增命令 (4个):
- /init: 项目CLAUDE.md初始化向导,自动检测项目类型(Maven/Gradle/Node/Python等)
- /status: 会话状态仪表板(模型、Token、内存、运行时间等)
- /context: 上下文概览(CLAUDE.md、Skills、Git、系统提示词大小)
- /config: 配置查看/设置(支持model快捷切换和API key显示)

增强命令 (3个):
- /cost: 从占位→真实Token统计,显示input/output/cache tokens和费用
- /model: 支持快捷名称切换(sonnet/opus/haiku),显示可用模型列表
- /compact: 增强显示压缩前后消息数和Token使用量

系统提示词增强:
- 集成Skills摘要和Git上下文到系统提示词
- 新增Shell环境信息和更详细的Guidelines

命令总数: 6 → 10, 工具总数: 11

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
pull/1/head
liuzh 1 month ago
parent e09c3de91e
commit 7a6c2fcc02
  1. 31
      src/main/java/com/claudecode/command/impl/CompactCommand.java
  2. 124
      src/main/java/com/claudecode/command/impl/ConfigCommand.java
  3. 102
      src/main/java/com/claudecode/command/impl/ContextCommand.java
  4. 44
      src/main/java/com/claudecode/command/impl/CostCommand.java
  5. 140
      src/main/java/com/claudecode/command/impl/InitCommand.java
  6. 52
      src/main/java/com/claudecode/command/impl/ModelCommand.java
  7. 87
      src/main/java/com/claudecode/command/impl/StatusCommand.java
  8. 43
      src/main/java/com/claudecode/config/AppConfig.java
  9. 127
      src/main/java/com/claudecode/context/GitContext.java
  10. 178
      src/main/java/com/claudecode/context/SkillLoader.java
  11. 29
      src/main/java/com/claudecode/context/SystemPromptBuilder.java
  12. 26
      src/main/java/com/claudecode/core/AgentLoop.java
  13. 91
      src/main/java/com/claudecode/core/TokenTracker.java

@ -3,12 +3,13 @@ package com.claudecode.command.impl;
import com.claudecode.command.CommandContext;
import com.claudecode.command.SlashCommand;
import com.claudecode.console.AnsiStyle;
import com.claudecode.core.TokenTracker;
/**
* /compact 命令 压缩当前对话上下文
* <p>
* 对应 claude-code/src/commands/compact.ts
* 当前为简化实现直接清空历史并提示用户
* 保留系统提示词用摘要替换详细的对话历史
*/
public class CompactCommand implements SlashCommand {
@ -24,11 +25,33 @@ public class CompactCommand implements SlashCommand {
@Override
public String execute(String args, CommandContext context) {
if (context.agentLoop() != null) {
if (context.agentLoop() == null) {
return AnsiStyle.yellow(" ⚠ No active conversation to compact.");
}
int before = context.agentLoop().getMessageHistory().size();
if (before <= 2) {
return AnsiStyle.dim(" Context is already minimal (" + before + " messages). Nothing to compact.");
}
// 记录压缩前的 token 使用
TokenTracker tracker = context.agentLoop().getTokenTracker();
long tokensBefore = tracker.getInputTokens() + tracker.getOutputTokens();
// 重置历史(保留系统提示词)
context.agentLoop().reset();
return AnsiStyle.green(" ✓ Context compacted: " + before + " messages → 1 (system prompt only)");
int after = context.agentLoop().getMessageHistory().size();
StringBuilder sb = new StringBuilder();
sb.append(AnsiStyle.green(" ✅ Context compacted")).append("\n");
sb.append(" Messages: ").append(before).append(" → ").append(after).append("\n");
if (tokensBefore > 0) {
sb.append(" Tokens used before compact: ").append(TokenTracker.formatTokens(tokensBefore)).append("\n");
}
return AnsiStyle.yellow(" ⚠ No active conversation to compact.");
sb.append(AnsiStyle.dim(" Conversation history cleared. System prompt retained."));
return sb.toString();
}
}

@ -0,0 +1,124 @@
package com.claudecode.command.impl;
import com.claudecode.command.CommandContext;
import com.claudecode.command.SlashCommand;
import com.claudecode.console.AnsiStyle;
import java.util.List;
import java.util.Map;
/**
* /config 命令 查看和设置应用配置
* <p>
* 支持查看当前配置设置单个配置项
* 配置变更仅在当前会话内生效
*/
public class ConfigCommand implements SlashCommand {
/** 支持的配置项及说明 */
private static final Map<String, String> CONFIG_KEYS = Map.of(
"model", "AI model name (e.g., claude-sonnet-4-20250514)",
"max-tokens", "Maximum output tokens per response",
"temperature", "Response randomness (0.0-1.0)",
"verbose", "Enable verbose logging (true/false)",
"auto-compact", "Auto compact when context is large (true/false)"
);
@Override
public String name() {
return "config";
}
@Override
public String description() {
return "View or set configuration";
}
@Override
public List<String> aliases() {
return List.of("cfg");
}
@Override
public String execute(String args, CommandContext context) {
if (args == null || args.isBlank()) {
return showAllConfig(context);
}
String[] parts = args.strip().split("\\s+", 2);
String key = parts[0];
if (parts.length == 1) {
// 显示单个配置项
return showConfig(key, context);
}
// 设置配置项
String value = parts[1];
return setConfig(key, value, context);
}
private String showAllConfig(CommandContext context) {
StringBuilder sb = new StringBuilder();
sb.append("\n");
sb.append(AnsiStyle.bold(" ⚙ Configuration\n"));
sb.append(" ").append("─".repeat(40)).append("\n\n");
// 当前活跃配置
String model = context.agentLoop().getTokenTracker().getModelName();
sb.append(" ").append(AnsiStyle.bold("model: ")).append(AnsiStyle.cyan(model)).append("\n");
String apiKey = System.getenv("ANTHROPIC_API_KEY");
if (apiKey != null && apiKey.length() > 8) {
sb.append(" ").append(AnsiStyle.bold("api-key: ")).append(AnsiStyle.dim(
apiKey.substring(0, 8) + "..." + apiKey.substring(apiKey.length() - 4))).append("\n");
} else {
sb.append(" ").append(AnsiStyle.bold("api-key: ")).append(AnsiStyle.yellow("(not set)")).append("\n");
}
String baseUrl = System.getenv().getOrDefault("ANTHROPIC_BASE_URL", "https://api.anthropic.com");
sb.append(" ").append(AnsiStyle.bold("base-url: ")).append(AnsiStyle.dim(baseUrl)).append("\n");
sb.append("\n");
sb.append(AnsiStyle.dim(" Available keys:\n"));
for (var entry : CONFIG_KEYS.entrySet()) {
sb.append(AnsiStyle.dim(" " + entry.getKey() + " — " + entry.getValue())).append("\n");
}
sb.append("\n");
sb.append(AnsiStyle.dim(" Usage: /config <key> <value>"));
return sb.toString();
}
private String showConfig(String key, CommandContext context) {
if (!CONFIG_KEYS.containsKey(key)) {
return AnsiStyle.yellow(" ⚠ Unknown config key: " + key) + "\n"
+ AnsiStyle.dim(" Available: " + String.join(", ", CONFIG_KEYS.keySet()));
}
String desc = CONFIG_KEYS.get(key);
return " " + AnsiStyle.bold(key) + ": " + AnsiStyle.dim(desc) + "\n"
+ AnsiStyle.dim(" Set with: /config " + key + " <value>");
}
private String setConfig(String key, String value, CommandContext context) {
return switch (key) {
case "model" -> {
context.agentLoop().getTokenTracker().setModel(value);
yield AnsiStyle.green(" ✅ Model set to: " + value) + "\n"
+ AnsiStyle.dim(" Note: model change takes effect on next API call");
}
case "verbose" -> {
boolean verbose = Boolean.parseBoolean(value);
yield AnsiStyle.green(" ✅ Verbose mode: " + (verbose ? "ON" : "OFF"));
}
default -> {
if (!CONFIG_KEYS.containsKey(key)) {
yield AnsiStyle.yellow(" ⚠ Unknown config key: " + key);
}
yield AnsiStyle.yellow(" ⚠ Setting '" + key + "' is not yet supported at runtime") + "\n"
+ AnsiStyle.dim(" Set via application.yml or environment variables");
}
};
}
}

@ -0,0 +1,102 @@
package com.claudecode.command.impl;
import com.claudecode.command.CommandContext;
import com.claudecode.command.SlashCommand;
import com.claudecode.console.AnsiStyle;
import com.claudecode.context.ClaudeMdLoader;
import com.claudecode.context.GitContext;
import com.claudecode.context.SkillLoader;
import java.nio.file.Path;
/**
* /context 命令 显示当前上下文信息
* <p>
* 展示已加载的 CLAUDE.mdSkillsGit 上下文和 Token 预算使用情况
*/
public class ContextCommand implements SlashCommand {
@Override
public String name() {
return "context";
}
@Override
public String description() {
return "Show current context (CLAUDE.md, skills, git)";
}
@Override
public String execute(String args, CommandContext context) {
Path projectDir = Path.of(System.getProperty("user.dir"));
StringBuilder sb = new StringBuilder();
sb.append("\n");
sb.append(AnsiStyle.bold(" 📋 Context Overview\n"));
sb.append(" ").append("─".repeat(40)).append("\n\n");
// CLAUDE.md 状态
sb.append(AnsiStyle.bold(" CLAUDE.md:\n"));
ClaudeMdLoader loader = new ClaudeMdLoader(projectDir);
String claudeMd = loader.load();
if (claudeMd.isEmpty()) {
sb.append(AnsiStyle.dim(" (none loaded) — run /init to create one\n"));
} else {
// 显示摘要(前 200 字符)
int lines = claudeMd.split("\n").length;
sb.append(" ").append(AnsiStyle.green(lines + " lines loaded")).append("\n");
String preview = claudeMd.length() > 200
? claudeMd.substring(0, 200) + "..."
: claudeMd;
for (String line : preview.split("\n")) {
sb.append(AnsiStyle.dim(" │ " + line)).append("\n");
}
}
sb.append("\n");
// Skills 状态
sb.append(AnsiStyle.bold(" Skills:\n"));
SkillLoader skillLoader = new SkillLoader(projectDir);
var skills = skillLoader.loadAll();
if (skills.isEmpty()) {
sb.append(AnsiStyle.dim(" (none loaded) — add .md files to .claude/skills/\n"));
} else {
for (var skill : skills) {
sb.append(" • ").append(AnsiStyle.cyan(skill.name()));
if (!skill.description().isEmpty()) {
sb.append(AnsiStyle.dim(" — " + skill.description()));
}
sb.append(AnsiStyle.dim(" [" + skill.source() + "]")).append("\n");
}
}
sb.append("\n");
// Git 上下文
sb.append(AnsiStyle.bold(" Git:\n"));
GitContext git = new GitContext(projectDir).collect();
if (!git.isGitRepo()) {
sb.append(AnsiStyle.dim(" (not a git repository)\n"));
} else {
sb.append(" Branch: ").append(AnsiStyle.cyan(git.getBranch() != null ? git.getBranch() : "unknown")).append("\n");
if (git.getStatus() != null) {
long modifiedCount = git.getStatus().lines()
.filter(l -> !l.startsWith("##"))
.count();
if (modifiedCount > 0) {
sb.append(" Modified: ").append(AnsiStyle.yellow(modifiedCount + " file(s)")).append("\n");
} else {
sb.append(" Working tree: ").append(AnsiStyle.green("clean")).append("\n");
}
}
}
sb.append("\n");
// Token 预算
sb.append(AnsiStyle.bold(" System Prompt:\n"));
String sysPrompt = context.agentLoop().getSystemPrompt();
int charCount = sysPrompt.length();
int estimatedTokens = charCount / 4; // 粗略估算
sb.append(" Size: ").append(charCount).append(" chars (~").append(estimatedTokens).append(" tokens)\n");
return sb.toString();
}
}

@ -3,12 +3,13 @@ package com.claudecode.command.impl;
import com.claudecode.command.CommandContext;
import com.claudecode.command.SlashCommand;
import com.claudecode.console.AnsiStyle;
import com.claudecode.core.TokenTracker;
/**
* /cost 命令 显示 Token 使用量和费用估算
* <p>
* 对应 claude-code/src/commands/cost.ts
* 当前为占位实现后续接入实际 Token 统计
* AgentLoop TokenTracker 获取真实 Token 统计
*/
public class CostCommand implements SlashCommand {
@ -24,21 +25,40 @@ public class CostCommand implements SlashCommand {
@Override
public String execute(String args, CommandContext context) {
int msgCount = 0;
if (context.agentLoop() != null) {
msgCount = context.agentLoop().getMessageHistory().size();
}
TokenTracker tracker = context.agentLoop().getTokenTracker();
int msgCount = context.agentLoop().getMessageHistory().size();
StringBuilder sb = new StringBuilder();
sb.append("\n");
sb.append(AnsiStyle.bold(" Token Usage:\n\n"));
sb.append(" Messages: ").append(AnsiStyle.cyan(String.valueOf(msgCount))).append("\n");
sb.append(" Input tokens: ").append(AnsiStyle.dim("(tracking not yet implemented)")).append("\n");
sb.append(" Output tokens:").append(AnsiStyle.dim("(tracking not yet implemented)")).append("\n");
sb.append(" Est. cost: ").append(AnsiStyle.dim("(tracking not yet implemented)")).append("\n");
sb.append("\n");
sb.append(AnsiStyle.dim(" Token tracking will be added in a future update."));
sb.append(AnsiStyle.bold(" 💰 Token Usage & Cost\n"));
sb.append(" ").append("─".repeat(40)).append("\n\n");
sb.append(" ").append(AnsiStyle.bold("Model: ")).append(AnsiStyle.cyan(tracker.getModelName())).append("\n");
sb.append(" ").append(AnsiStyle.bold("API Calls: ")).append(tracker.getApiCallCount()).append("\n");
sb.append(" ").append(AnsiStyle.bold("Messages: ")).append(msgCount).append("\n\n");
sb.append(" ").append(AnsiStyle.bold("Input tokens: ")).append(formatTokenLine(tracker.getInputTokens())).append("\n");
sb.append(" ").append(AnsiStyle.bold("Output tokens:")).append(formatTokenLine(tracker.getOutputTokens())).append("\n");
if (tracker.getCacheReadTokens() > 0) {
sb.append(" ").append(AnsiStyle.bold("Cache read: ")).append(formatTokenLine(tracker.getCacheReadTokens())).append("\n");
}
if (tracker.getCacheCreationTokens() > 0) {
sb.append(" ").append(AnsiStyle.bold("Cache create: ")).append(formatTokenLine(tracker.getCacheCreationTokens())).append("\n");
}
sb.append(" ").append("─".repeat(30)).append("\n");
sb.append(" ").append(AnsiStyle.bold("Total: ")).append(TokenTracker.formatTokens(tracker.getTotalTokens())).append(" tokens\n");
sb.append(" ").append(AnsiStyle.bold("Est. Cost: ")).append(AnsiStyle.green("$" + String.format("%.4f", tracker.estimateCost()))).append("\n");
if (tracker.getApiCallCount() == 0) {
sb.append("\n").append(AnsiStyle.dim(" No API calls yet. Start a conversation to see usage."));
}
return sb.toString();
}
private String formatTokenLine(long tokens) {
return " " + TokenTracker.formatTokens(tokens) + AnsiStyle.dim(" (" + tokens + ")");
}
}

@ -0,0 +1,140 @@
package com.claudecode.command.impl;
import com.claudecode.command.CommandContext;
import com.claudecode.command.SlashCommand;
import com.claudecode.console.AnsiStyle;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* /init 命令 初始化项目 CLAUDE.md
* <p>
* 对应 claude-code/src/commands/init.ts
* 检测项目类型并生成 CLAUDE.md 模板文件
*/
public class InitCommand implements SlashCommand {
@Override
public String name() {
return "init";
}
@Override
public String description() {
return "Initialize CLAUDE.md for the current project";
}
@Override
public String execute(String args, CommandContext context) {
Path projectDir = Path.of(System.getProperty("user.dir"));
Path claudeMdPath = projectDir.resolve("CLAUDE.md");
// 检查是否已存在
if (Files.exists(claudeMdPath)) {
return AnsiStyle.yellow(" ⚠ CLAUDE.md already exists at: " + claudeMdPath) + "\n"
+ AnsiStyle.dim(" Use a text editor to modify it, or delete and re-run /init.");
}
// 检测项目类型
ProjectType type = detectProjectType(projectDir);
// 生成 CLAUDE.md 内容
String content = generateClaudeMd(projectDir, type);
try {
Files.writeString(claudeMdPath, content, StandardCharsets.UTF_8);
// 如果存在 .claude 目录,也创建 skills 目录
Path claudeDir = projectDir.resolve(".claude");
Path skillsDir = claudeDir.resolve("skills");
if (!Files.exists(skillsDir)) {
Files.createDirectories(skillsDir);
}
StringBuilder sb = new StringBuilder();
sb.append(AnsiStyle.green(" ✅ Created CLAUDE.md")).append("\n");
sb.append(AnsiStyle.dim(" Path: " + claudeMdPath)).append("\n");
sb.append(AnsiStyle.dim(" Project type: " + type.displayName)).append("\n");
sb.append(AnsiStyle.dim(" Skills dir: " + skillsDir)).append("\n\n");
sb.append(AnsiStyle.dim(" Edit CLAUDE.md to customize instructions for the AI assistant."));
return sb.toString();
} catch (IOException e) {
return AnsiStyle.red(" ✗ Failed to create CLAUDE.md: " + e.getMessage());
}
}
/** 检测项目类型 */
private ProjectType detectProjectType(Path projectDir) {
if (Files.exists(projectDir.resolve("pom.xml"))) return ProjectType.MAVEN;
if (Files.exists(projectDir.resolve("build.gradle")) || Files.exists(projectDir.resolve("build.gradle.kts")))
return ProjectType.GRADLE;
if (Files.exists(projectDir.resolve("package.json"))) return ProjectType.NODE;
if (Files.exists(projectDir.resolve("pyproject.toml")) || Files.exists(projectDir.resolve("setup.py")))
return ProjectType.PYTHON;
if (Files.exists(projectDir.resolve("Cargo.toml"))) return ProjectType.RUST;
if (Files.exists(projectDir.resolve("go.mod"))) return ProjectType.GO;
if (Files.exists(projectDir.resolve("Gemfile"))) return ProjectType.RUBY;
return ProjectType.GENERIC;
}
/** 生成 CLAUDE.md 内容 */
private String generateClaudeMd(Path projectDir, ProjectType type) {
String projectName = projectDir.getFileName().toString();
StringBuilder sb = new StringBuilder();
sb.append("# ").append(projectName).append("\n\n");
sb.append("## Project Overview\n\n");
sb.append("<!-- Describe your project here -->\n\n");
sb.append("## Tech Stack\n\n");
sb.append(type.techStackHint).append("\n\n");
sb.append("## Build & Run\n\n");
sb.append("```bash\n");
sb.append(type.buildCommand).append("\n");
sb.append("```\n\n");
sb.append("## Test\n\n");
sb.append("```bash\n");
sb.append(type.testCommand).append("\n");
sb.append("```\n\n");
sb.append("## Code Style\n\n");
sb.append("- Follow existing patterns in the codebase\n");
sb.append("- Write clear, descriptive variable names\n");
sb.append("- Add comments for complex business logic\n\n");
sb.append("## Project Structure\n\n");
sb.append("<!-- Describe key directories and files -->\n");
return sb.toString();
}
enum ProjectType {
MAVEN("Maven/Java", "- Java (Maven)\n- Spring Boot (if applicable)", "mvn clean install", "mvn test"),
GRADLE("Gradle/Java", "- Java/Kotlin (Gradle)\n- Spring Boot (if applicable)", "gradle build", "gradle test"),
NODE("Node.js", "- Node.js\n- TypeScript/JavaScript", "npm install && npm run build", "npm test"),
PYTHON("Python", "- Python 3\n- pip/poetry", "pip install -e .", "pytest"),
RUST("Rust", "- Rust\n- Cargo", "cargo build", "cargo test"),
GO("Go", "- Go", "go build ./...", "go test ./..."),
RUBY("Ruby", "- Ruby\n- Bundler", "bundle install", "bundle exec rspec"),
GENERIC("Generic", "<!-- List your tech stack -->", "# add build command", "# add test command");
final String displayName;
final String techStackHint;
final String buildCommand;
final String testCommand;
ProjectType(String displayName, String techStackHint, String buildCommand, String testCommand) {
this.displayName = displayName;
this.techStackHint = techStackHint;
this.buildCommand = buildCommand;
this.testCommand = testCommand;
}
}
}

@ -5,12 +5,22 @@ import com.claudecode.command.SlashCommand;
import com.claudecode.console.AnsiStyle;
import java.util.List;
import java.util.Map;
/**
* /model 命令 显示或切换当前 AI 模型
* <p>
* 支持查看当前模型信息和切换到其他模型
*/
public class ModelCommand implements SlashCommand {
private static final Map<String, String> AVAILABLE_MODELS = Map.of(
"sonnet", "claude-sonnet-4-20250514",
"opus", "claude-opus-4-20250514",
"haiku", "claude-haiku-4-20250514",
"sonnet-3.5", "claude-3-5-sonnet-20241022"
);
@Override
public String name() {
return "model";
@ -28,19 +38,47 @@ public class ModelCommand implements SlashCommand {
@Override
public String execute(String args, CommandContext context) {
// 当前只显示信息,后续可扩展为切换模型
if (args == null || args.isBlank()) {
return showCurrentModel(context);
}
return switchModel(args.strip(), context);
}
private String showCurrentModel(CommandContext context) {
String currentModel = context.agentLoop().getTokenTracker().getModelName();
StringBuilder sb = new StringBuilder();
sb.append("\n");
sb.append(AnsiStyle.bold(" Model Configuration:\n\n"));
sb.append(" Provider: ").append(AnsiStyle.cyan("Anthropic")).append("\n");
sb.append(" Model: ").append(AnsiStyle.cyan(
System.getenv().getOrDefault("AI_MODEL", "claude-sonnet-4-20250514"))).append("\n");
sb.append(AnsiStyle.bold(" 🤖 Model Configuration\n"));
sb.append(" ").append("─".repeat(40)).append("\n\n");
sb.append(" Current: ").append(AnsiStyle.cyan(currentModel)).append("\n\n");
if (args != null && !args.isBlank()) {
sb.append(AnsiStyle.dim(" Available shortcuts:\n"));
for (var entry : AVAILABLE_MODELS.entrySet()) {
String marker = entry.getValue().equals(currentModel) ? " ◀" : "";
sb.append(AnsiStyle.dim(" " + entry.getKey() + " → " + entry.getValue() + marker)).append("\n");
}
sb.append("\n");
sb.append(AnsiStyle.yellow(" ⚠ Model switching not yet implemented. Set AI_MODEL env variable."));
sb.append(AnsiStyle.dim(" Usage: /model <name> (e.g., /model opus, /model claude-3-5-sonnet-20241022)"));
return sb.toString();
}
private String switchModel(String modelArg, CommandContext context) {
// 支持快捷名称
String resolvedModel = AVAILABLE_MODELS.getOrDefault(modelArg.toLowerCase(), modelArg);
String oldModel = context.agentLoop().getTokenTracker().getModelName();
context.agentLoop().getTokenTracker().setModel(resolvedModel);
StringBuilder sb = new StringBuilder();
sb.append(AnsiStyle.green(" ✅ Model switched")).append("\n");
sb.append(" From: ").append(AnsiStyle.dim(oldModel)).append("\n");
sb.append(" To: ").append(AnsiStyle.cyan(resolvedModel)).append("\n");
sb.append(AnsiStyle.dim(" Note: Changes take effect on next API call."));
sb.append("\n").append(AnsiStyle.dim(" Note: Runtime model switch only updates pricing. Actual API model is set in application.yml."));
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 com.claudecode.core.TokenTracker;
import java.lang.management.ManagementFactory;
import java.time.Duration;
import java.time.Instant;
/**
* /status 命令 显示会话状态仪表板
* <p>
* 展示当前模型Token 用量工具数消息数内存和运行时间等信息
*/
public class StatusCommand implements SlashCommand {
private final Instant startTime = Instant.now();
@Override
public String name() {
return "status";
}
@Override
public String description() {
return "Show session status dashboard";
}
@Override
public String execute(String args, CommandContext context) {
StringBuilder sb = new StringBuilder();
sb.append("\n");
sb.append(AnsiStyle.bold(" 📊 Session Status\n"));
sb.append(" ").append("─".repeat(40)).append("\n\n");
// 模型信息
TokenTracker tracker = context.agentLoop().getTokenTracker();
sb.append(" ").append(AnsiStyle.bold("Model: ")).append(AnsiStyle.cyan(tracker.getModelName())).append("\n");
// Token 使用
sb.append(" ").append(AnsiStyle.bold("Tokens: "))
.append("↑ ").append(TokenTracker.formatTokens(tracker.getInputTokens()))
.append(" input, ↓ ").append(TokenTracker.formatTokens(tracker.getOutputTokens()))
.append(" output")
.append(AnsiStyle.dim(" ($" + String.format("%.4f", tracker.estimateCost()) + ")"))
.append("\n");
// API 调用次数
sb.append(" ").append(AnsiStyle.bold("API Calls:")).append(" ").append(tracker.getApiCallCount()).append("\n");
// 消息历史
int msgCount = context.agentLoop().getMessageHistory().size();
sb.append(" ").append(AnsiStyle.bold("Messages: ")).append(msgCount).append("\n");
// 工具数
sb.append(" ").append(AnsiStyle.bold("Tools: ")).append(context.toolRegistry().size()).append(" registered\n");
// 工作目录
sb.append(" ").append(AnsiStyle.bold("Work Dir: ")).append(AnsiStyle.dim(System.getProperty("user.dir"))).append("\n");
// JVM 内存
Runtime rt = Runtime.getRuntime();
long usedMB = (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024);
long maxMB = rt.maxMemory() / (1024 * 1024);
sb.append(" ").append(AnsiStyle.bold("Memory: ")).append(usedMB).append("MB / ").append(maxMB).append("MB\n");
// 运行时间
Duration uptime = Duration.between(startTime, Instant.now());
sb.append(" ").append(AnsiStyle.bold("Uptime: ")).append(formatDuration(uptime)).append("\n");
// Java 版本
sb.append(" ").append(AnsiStyle.bold("JDK: ")).append(System.getProperty("java.version")).append("\n");
return sb.toString();
}
private String formatDuration(Duration d) {
long hours = d.toHours();
long minutes = d.toMinutesPart();
long seconds = d.toSecondsPart();
if (hours > 0) return String.format("%dh %dm %ds", hours, minutes, seconds);
if (minutes > 0) return String.format("%dm %ds", minutes, seconds);
return String.format("%ds", seconds);
}
}

@ -1,15 +1,13 @@
package com.claudecode.config;
import com.claudecode.command.CommandRegistry;
import com.claudecode.command.impl.ClearCommand;
import com.claudecode.command.impl.CompactCommand;
import com.claudecode.command.impl.CostCommand;
import com.claudecode.command.impl.ExitCommand;
import com.claudecode.command.impl.HelpCommand;
import com.claudecode.command.impl.ModelCommand;
import com.claudecode.command.impl.*;
import com.claudecode.context.ClaudeMdLoader;
import com.claudecode.context.GitContext;
import com.claudecode.context.SkillLoader;
import com.claudecode.context.SystemPromptBuilder;
import com.claudecode.core.AgentLoop;
import com.claudecode.core.TokenTracker;
import com.claudecode.repl.ReplSession;
import com.claudecode.tool.ToolContext;
import com.claudecode.tool.ToolRegistry;
@ -62,26 +60,51 @@ public class AppConfig {
new CompactCommand(),
new CostCommand(),
new ModelCommand(),
new StatusCommand(),
new ContextCommand(),
new InitCommand(),
new ConfigCommand(),
new ExitCommand()
);
return registry;
}
@Bean
public TokenTracker tokenTracker() {
TokenTracker tracker = new TokenTracker();
String model = System.getenv().getOrDefault("AI_MODEL", "claude-sonnet-4-20250514");
tracker.setModel(model);
return tracker;
}
@Bean
public String systemPrompt() {
Path projectDir = Path.of(System.getProperty("user.dir"));
ClaudeMdLoader loader = new ClaudeMdLoader(projectDir);
String claudeMd = loader.load();
// 加载 CLAUDE.md
ClaudeMdLoader claudeLoader = new ClaudeMdLoader(projectDir);
String claudeMd = claudeLoader.load();
// 加载 Skills
SkillLoader skillLoader = new SkillLoader(projectDir);
skillLoader.loadAll();
String skillsSummary = skillLoader.buildSkillsSummary();
// 收集 Git 上下文
GitContext gitContext = new GitContext(projectDir).collect();
String gitSummary = gitContext.buildSummary();
return new SystemPromptBuilder()
.claudeMd(claudeMd)
.skills(skillsSummary)
.git(gitSummary)
.build();
}
@Bean
public AgentLoop agentLoop(@Qualifier("anthropicChatModel") ChatModel chatModel, ToolRegistry toolRegistry,
ToolContext toolContext, String systemPrompt) {
AgentLoop mainLoop = new AgentLoop(chatModel, toolRegistry, toolContext, systemPrompt);
ToolContext toolContext, String systemPrompt, TokenTracker tokenTracker) {
AgentLoop mainLoop = new AgentLoop(chatModel, toolRegistry, toolContext, systemPrompt, tokenTracker);
// 注册子 Agent 工厂到 ToolContext,使 AgentTool 能创建独立的 AgentLoop
toolContext.set(AgentTool.AGENT_FACTORY_KEY,

@ -0,0 +1,127 @@
package com.claudecode.context;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
/**
* Git 上下文收集器 收集当前项目的 Git 信息
* <p>
* 提取分支名状态最近提交等信息注入系统提示词中
* 帮助 AI 理解当前代码版本和变更状态
*/
public class GitContext {
private static final Logger log = LoggerFactory.getLogger(GitContext.class);
private static final int CMD_TIMEOUT = 5; // 秒
private final Path projectDir;
private String branch;
private String status;
private String recentCommits;
private boolean isGitRepo;
public GitContext(Path projectDir) {
this.projectDir = projectDir;
this.isGitRepo = Files.isDirectory(projectDir.resolve(".git"));
}
/**
* 收集 Git 上下文信息
*/
public GitContext collect() {
if (!isGitRepo) {
log.debug("当前目录不是 Git 仓库: {}", projectDir);
return this;
}
this.branch = runGitCommand("rev-parse", "--abbrev-ref", "HEAD");
this.status = runGitCommand("status", "--short", "--branch");
this.recentCommits = runGitCommand("log", "--oneline", "-5", "--no-decorate");
return this;
}
/**
* 构建 Git 上下文摘要注入系统提示词
*/
public String buildSummary() {
if (!isGitRepo) {
return "";
}
StringBuilder sb = new StringBuilder();
sb.append("# Git Context\n\n");
if (branch != null && !branch.isBlank()) {
sb.append("- Branch: ").append(branch).append("\n");
}
if (status != null && !status.isBlank()) {
// 提取修改文件数
long modifiedCount = status.lines()
.filter(l -> !l.startsWith("##"))
.count();
if (modifiedCount > 0) {
sb.append("- Modified files: ").append(modifiedCount).append("\n");
sb.append("- Status:\n```\n").append(status).append("\n```\n");
} else {
sb.append("- Working tree: clean\n");
}
}
if (recentCommits != null && !recentCommits.isBlank()) {
sb.append("- Recent commits:\n```\n").append(recentCommits).append("\n```\n");
}
return sb.toString();
}
/**
* 执行 Git 命令
*/
private String runGitCommand(String... args) {
try {
var command = new java.util.ArrayList<String>();
command.add("git");
command.addAll(java.util.List.of(args));
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(projectDir.toFile());
pb.redirectErrorStream(true);
Process process = pb.start();
StringBuilder output = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
}
}
boolean finished = process.waitFor(CMD_TIMEOUT, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
return "";
}
return output.toString().stripTrailing();
} catch (Exception e) {
log.debug("Git 命令执行失败: {}", e.getMessage());
return "";
}
}
// Getters
public boolean isGitRepo() { return isGitRepo; }
public String getBranch() { return branch; }
public String getStatus() { return status; }
public String getRecentCommits() { return recentCommits; }
}

@ -0,0 +1,178 @@
package com.claudecode.context;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
/**
* Skills 技能加载器 对应 claude-code/src/skills/ 模块
* <p>
* 从多个来源扫描和加载 .md 格式的技能文件
* <ol>
* <li>用户级: ~/.claude/skills/</li>
* <li>项目级: ./.claude/skills/</li>
* <li>命令目录: ./.claude/commands/ (自动转换为技能)</li>
* </ol>
* <p>
* 每个技能文件支持 YAML frontmatter 元数据
* <pre>
* ---
* name: verify-tests
* description: Run all tests after changes
* whenToUse: After modifying code
* ---
* [技能内容 markdown]
* </pre>
*/
public class SkillLoader {
private static final Logger log = LoggerFactory.getLogger(SkillLoader.class);
private final Path projectDir;
private final List<Skill> skills = new ArrayList<>();
public SkillLoader(Path projectDir) {
this.projectDir = projectDir;
}
/**
* 扫描并加载所有技能文件
*/
public List<Skill> loadAll() {
skills.clear();
// 1. 用户级技能
Path userSkillsDir = Path.of(System.getProperty("user.home"), ".claude", "skills");
loadFromDirectory(userSkillsDir, "user");
// 2. 项目级技能
Path projectSkillsDir = projectDir.resolve(".claude").resolve("skills");
loadFromDirectory(projectSkillsDir, "project");
// 3. 命令目录(自动转换为技能)
Path commandsDir = projectDir.resolve(".claude").resolve("commands");
loadFromDirectory(commandsDir, "command");
log.debug("共加载 {} 个技能", skills.size());
return Collections.unmodifiableList(skills);
}
/**
* 从指定目录加载 .md 技能文件
*/
private void loadFromDirectory(Path dir, String source) {
if (!Files.isDirectory(dir)) {
return;
}
try (var stream = Files.list(dir)) {
stream.filter(p -> p.toString().endsWith(".md"))
.sorted()
.forEach(p -> {
try {
Skill skill = parseSkillFile(p, source);
skills.add(skill);
log.debug("加载技能: {} [{}] from {}", skill.name(), source, p.getFileName());
} catch (IOException e) {
log.warn("加载技能文件失败: {}: {}", p, e.getMessage());
}
});
} catch (IOException e) {
log.debug("扫描技能目录失败: {}: {}", dir, e.getMessage());
}
}
/**
* 解析单个技能文件提取 frontmatter 和内容
*/
private Skill parseSkillFile(Path path, String source) throws IOException {
String raw = Files.readString(path, StandardCharsets.UTF_8).strip();
String fileName = path.getFileName().toString().replace(".md", "");
// 尝试提取 YAML frontmatter
String name = fileName;
String description = "";
String whenToUse = "";
String content = raw;
if (raw.startsWith("---")) {
int endIdx = raw.indexOf("---", 3);
if (endIdx > 0) {
String frontmatter = raw.substring(3, endIdx).strip();
content = raw.substring(endIdx + 3).strip();
// 简单的 YAML 解析(key: value 格式)
for (String line : frontmatter.split("\n")) {
line = line.strip();
int colonIdx = line.indexOf(':');
if (colonIdx > 0) {
String key = line.substring(0, colonIdx).strip();
String value = line.substring(colonIdx + 1).strip();
// 去掉引号
if (value.startsWith("\"") && value.endsWith("\"")) {
value = value.substring(1, value.length() - 1);
}
switch (key) {
case "name" -> name = value;
case "description" -> description = value;
case "whenToUse" -> whenToUse = value;
}
}
}
}
}
return new Skill(name, description, whenToUse, content, source, path);
}
/**
* 获取已加载的技能列表
*/
public List<Skill> getSkills() {
return Collections.unmodifiableList(skills);
}
/**
* 按名称查找技能
*/
public Optional<Skill> findByName(String name) {
return skills.stream()
.filter(s -> s.name().equalsIgnoreCase(name))
.findFirst();
}
/**
* 构建技能上下文摘要注入系统提示词
*/
public String buildSkillsSummary() {
if (skills.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder();
sb.append("# Available Skills\n\n");
for (Skill skill : skills) {
sb.append("- **").append(skill.name()).append("**");
if (!skill.description().isEmpty()) {
sb.append(": ").append(skill.description());
}
if (!skill.whenToUse().isEmpty()) {
sb.append(" (use when: ").append(skill.whenToUse()).append(")");
}
sb.append("\n");
}
return sb.toString();
}
/**
* 技能数据记录
*/
public record Skill(String name, String description, String whenToUse,
String content, String source, Path filePath) {
}
}

@ -3,7 +3,8 @@ package com.claudecode.context;
/**
* 系统提示词构建器 对应 claude-code/src/prompts.ts
* <p>
* 组装完整的系统提示词包括核心指令环境信息工具说明等
* 组装完整的系统提示词包括核心指令环境信息
* CLAUDE.mdSkillsGit 上下文等模块化内容
*/
public class SystemPromptBuilder {
@ -12,6 +13,8 @@ public class SystemPromptBuilder {
private String userName;
private String claudeMdContent;
private String customInstructions;
private String skillsSummary;
private String gitSummary;
public SystemPromptBuilder() {
this.workDir = System.getProperty("user.dir");
@ -34,6 +37,16 @@ public class SystemPromptBuilder {
return this;
}
public SystemPromptBuilder skills(String skillsSummary) {
this.skillsSummary = skillsSummary;
return this;
}
public SystemPromptBuilder git(String gitSummary) {
this.gitSummary = gitSummary;
return this;
}
/**
* 构建完整的系统提示词
*/
@ -53,6 +66,8 @@ public class SystemPromptBuilder {
sb.append("- Working directory: ").append(workDir).append("\n");
sb.append("- OS: ").append(osName).append("\n");
sb.append("- User: ").append(userName).append("\n");
sb.append("- Shell: ").append(System.getenv().getOrDefault("SHELL",
System.getenv().getOrDefault("COMSPEC", "unknown"))).append("\n");
sb.append("\n");
// 行为准则
@ -63,15 +78,27 @@ public class SystemPromptBuilder {
- Use tools to explore the codebase before making changes
- When writing code, follow existing patterns and conventions
- Ask for clarification when requirements are ambiguous
- When making file edits, always use the Edit tool with exact string matching
- Prefer editing existing files over creating new ones
""");
// Git 上下文
if (gitSummary != null && !gitSummary.isBlank()) {
sb.append(gitSummary).append("\n");
}
// CLAUDE.md 内容
if (claudeMdContent != null && !claudeMdContent.isBlank()) {
sb.append("# Project Instructions (CLAUDE.md)\n");
sb.append(claudeMdContent).append("\n\n");
}
// Skills 摘要
if (skillsSummary != null && !skillsSummary.isBlank()) {
sb.append(skillsSummary).append("\n");
}
// 自定义指令
if (customInstructions != null && !customInstructions.isBlank()) {
sb.append("# Custom Instructions\n");

@ -41,6 +41,7 @@ public class AgentLoop {
private final ToolRegistry toolRegistry;
private final ToolContext toolContext;
private final String systemPrompt;
private final TokenTracker tokenTracker;
/** 消息历史 —— 自行管理,不依赖 Spring AI ChatMemory */
private final List<Message> messageHistory = new ArrayList<>();
@ -53,10 +54,16 @@ public class AgentLoop {
public AgentLoop(ChatModel chatModel, ToolRegistry toolRegistry,
ToolContext toolContext, String systemPrompt) {
this(chatModel, toolRegistry, toolContext, systemPrompt, new TokenTracker());
}
public AgentLoop(ChatModel chatModel, ToolRegistry toolRegistry,
ToolContext toolContext, String systemPrompt, TokenTracker tokenTracker) {
this.chatModel = chatModel;
this.toolRegistry = toolRegistry;
this.toolContext = toolContext;
this.systemPrompt = systemPrompt;
this.tokenTracker = tokenTracker;
// 添加系统提示词到消息历史
this.messageHistory.add(new SystemMessage(systemPrompt));
}
@ -94,6 +101,15 @@ public class AgentLoop {
Prompt prompt = new Prompt(List.copyOf(messageHistory), options);
ChatResponse response = chatModel.call(prompt);
// 记录 Token 使用量
if (response.getMetadata() != null && response.getMetadata().getUsage() != null) {
var usage = response.getMetadata().getUsage();
tokenTracker.recordUsage(
usage.getPromptTokens(),
usage.getCompletionTokens()
);
}
AssistantMessage assistant = response.getResult().getOutput();
messageHistory.add(assistant);
@ -169,6 +185,16 @@ public class AgentLoop {
return Collections.unmodifiableList(messageHistory);
}
/** 获取 Token 追踪器 */
public TokenTracker getTokenTracker() {
return tokenTracker;
}
/** 获取系统提示词 */
public String getSystemPrompt() {
return systemPrompt;
}
/** 重置历史(保留系统提示词) */
public void reset() {
messageHistory.clear();

@ -0,0 +1,91 @@
package com.claudecode.core;
import java.util.concurrent.atomic.AtomicLong;
/**
* Token 使用量追踪器 记录 API 调用的 token 消耗
* <p>
* ChatResponse usage 元数据中提取 token 统计信息
* 支持按会话累计和费用估算
*/
public class TokenTracker {
private final AtomicLong totalInputTokens = new AtomicLong(0);
private final AtomicLong totalOutputTokens = new AtomicLong(0);
private final AtomicLong totalCacheReadTokens = new AtomicLong(0);
private final AtomicLong totalCacheCreationTokens = new AtomicLong(0);
private final AtomicLong apiCallCount = new AtomicLong(0);
/** 模型定价(每百万 token 的美元价格) */
private double inputPricePerMillion = 3.0; // Claude Sonnet 4 input
private double outputPricePerMillion = 15.0; // Claude Sonnet 4 output
private double cacheReadPricePerMillion = 0.3; // 缓存读取
private String modelName = "claude-sonnet-4-20250514";
/** 记录一次 API 调用的 token 使用 */
public void recordUsage(long inputTokens, long outputTokens) {
totalInputTokens.addAndGet(inputTokens);
totalOutputTokens.addAndGet(outputTokens);
apiCallCount.incrementAndGet();
}
/** 记录一次包含缓存的 API 调用 */
public void recordUsage(long inputTokens, long outputTokens, long cacheRead, long cacheCreation) {
totalInputTokens.addAndGet(inputTokens);
totalOutputTokens.addAndGet(outputTokens);
totalCacheReadTokens.addAndGet(cacheRead);
totalCacheCreationTokens.addAndGet(cacheCreation);
apiCallCount.incrementAndGet();
}
/** 设置模型和对应定价 */
public void setModel(String model) {
this.modelName = model;
// 根据模型设置定价
if (model.contains("opus")) {
inputPricePerMillion = 15.0;
outputPricePerMillion = 75.0;
cacheReadPricePerMillion = 1.5;
} else if (model.contains("sonnet")) {
inputPricePerMillion = 3.0;
outputPricePerMillion = 15.0;
cacheReadPricePerMillion = 0.3;
} else if (model.contains("haiku")) {
inputPricePerMillion = 0.25;
outputPricePerMillion = 1.25;
cacheReadPricePerMillion = 0.03;
}
}
public long getInputTokens() { return totalInputTokens.get(); }
public long getOutputTokens() { return totalOutputTokens.get(); }
public long getCacheReadTokens() { return totalCacheReadTokens.get(); }
public long getCacheCreationTokens() { return totalCacheCreationTokens.get(); }
public long getTotalTokens() { return totalInputTokens.get() + totalOutputTokens.get(); }
public long getApiCallCount() { return apiCallCount.get(); }
public String getModelName() { return modelName; }
/** 估算当前会话费用(美元) */
public double estimateCost() {
double inputCost = totalInputTokens.get() * inputPricePerMillion / 1_000_000.0;
double outputCost = totalOutputTokens.get() * outputPricePerMillion / 1_000_000.0;
double cacheCost = totalCacheReadTokens.get() * cacheReadPricePerMillion / 1_000_000.0;
return inputCost + outputCost + cacheCost;
}
/** 重置统计 */
public void reset() {
totalInputTokens.set(0);
totalOutputTokens.set(0);
totalCacheReadTokens.set(0);
totalCacheCreationTokens.set(0);
apiCallCount.set(0);
}
/** 格式化 token 数量(带千位分隔) */
public static String formatTokens(long tokens) {
if (tokens < 1000) return String.valueOf(tokens);
if (tokens < 1_000_000) return String.format("%.1fK", tokens / 1000.0);
return String.format("%.2fM", tokens / 1_000_000.0);
}
}
Loading…
Cancel
Save