Add new features and update configurations for improved functionality

This commit is contained in:
ImBenji
2026-04-11 12:34:00 +01:00
parent fa4415553d
commit 0b6b604c56
125 changed files with 14119 additions and 1664 deletions
+696
View File
@@ -0,0 +1,696 @@
// CLAUDE.md loader — mirrors claude code's claudemd.ts behaviour.
//
// Loading order (later = higher priority, model pays more attention):
// 1. Managed (/Library/Application Support/ClaudeCode/CLAUDE.md on mac,
// /etc/claude-code/CLAUDE.md on linux,
// C:\Program Files\ClaudeCode\CLAUDE.md on windows)
// 2. User (~/.claude/CLAUDE.md, ~/.claude/rules/*.md)
// 3. Project (CLAUDE.md, .claude/CLAUDE.md, .claude/rules/*.md)
// walking up from cwd to root, root first (cwd wins)
// 4. Local (CLAUDE.local.md, same traversal)
//
// @include directive:
// @path, @./relative, @~/home, @/absolute — only in text nodes,
// not inside fenced code blocks. Max depth 5. Circular refs skipped.
//
// HTML comment stripping:
// Block-level HTML comments (lines starting with <!--) are stripped.
// Comments inside fenced code blocks are preserved.
import "dart:io";
import "package:path/path.dart" as p;
import "frontmatter_parser.dart";
import "memory_file_info.dart";
import "memory_types.dart";
const int _maxIncludeDepth = 5;
// Only text-like extensions are allowed in @include directives
// (prevents loading binary files like images, PDFs, etc.)
const _textFileExtensions = {
".md", ".txt", ".text",
".json", ".yaml", ".yml", ".toml", ".xml", ".csv",
".html", ".htm", ".css", ".scss", ".sass", ".less",
".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs", ".mts", ".cts",
".py", ".pyi", ".pyw",
".rb", ".erb", ".rake",
".go",
".rs",
".java", ".kt", ".kts", ".scala",
".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hxx",
".cs",
".swift",
".sh", ".bash", ".zsh", ".fish", ".ps1", ".bat", ".cmd",
".env", ".ini", ".cfg", ".conf", ".config", ".properties",
".sql", ".graphql", ".gql",
".proto",
".vue", ".svelte", ".astro",
".ejs", ".hbs", ".pug", ".jade",
".php", ".pl", ".pm", ".lua", ".r", ".R", ".dart",
".ex", ".exs", ".erl", ".hrl",
".clj", ".cljs", ".cljc", ".edn",
".hs", ".lhs", ".elm",
".ml", ".mli",
".f", ".f90", ".f95", ".for",
".cmake", ".make", ".makefile", ".gradle", ".sbt",
".rst", ".adoc", ".asciidoc", ".org", ".tex", ".latex",
".lock", ".log", ".diff", ".patch",
};
const _memoryInstructionPrompt =
"Codebase and user instructions are shown below. Be sure to adhere to these"
" instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you"
" MUST follow them exactly as written.";
// ──────────────────────────────────────────────────────────────────────────────
// Platform paths
// ──────────────────────────────────────────────────────────────────────────────
String _getManagedFilePath() {
if (Platform.isMacOS) return "/Library/Application Support/ClaudeCode";
if (Platform.isWindows) return r"C:\Program Files\ClaudeCode";
return "/etc/claude-code";
}
String _getClaudeConfigHomeDir() {
final override = Platform.environment["CLAUDE_CONFIG_DIR"];
if (override != null && override.isNotEmpty) return override;
final home = Platform.environment["HOME"] ?? Platform.environment["USERPROFILE"] ?? "";
return p.join(home, ".claude");
}
// ──────────────────────────────────────────────────────────────────────────────
// HTML comment stripping
// ──────────────────────────────────────────────────────────────────────────────
// Strips block-level HTML comments from markdown content.
// Comments inside fenced code blocks are preserved.
// Unclosed comments (<!-- with no -->) are left in place.
String stripHtmlComments(String content) {
if (!content.contains("<!--")) return content;
final lines = content.split("\n");
final result = StringBuffer();
var inFence = false;
String? fenceChar;
var inComment = false;
for (var i = 0; i < lines.length; i++) {
final line = lines[i];
final raw = line.trimLeft();
// Track fenced code blocks (``` or ~~~)
if (!inComment) {
if (!inFence) {
if (raw.startsWith("```") || raw.startsWith("~~~")) {
inFence = true;
fenceChar = raw.startsWith("```") ? "```" : "~~~";
result.write(line);
if (i < lines.length - 1) result.write("\n");
continue;
}
} else {
if (raw.startsWith(fenceChar!)) {
inFence = false;
fenceChar = null;
}
result.write(line);
if (i < lines.length - 1) result.write("\n");
continue;
}
}
if (inFence) {
result.write(line);
if (i < lines.length - 1) result.write("\n");
continue;
}
// block-level HTML comment starts with <!-- (up to 3 leading spaces allowed)
if (!inComment && raw.startsWith("<!--")) {
// check if it closes on the same line/block
final commentSpan = RegExp(r"<!--[\s\S]*?-->");
final residue = line.replaceAll(commentSpan, "");
if (line.contains("-->")) {
// comment closed — keep any residue
if (residue.trim().isNotEmpty) {
result.write(residue);
if (i < lines.length - 1) result.write("\n");
}
continue;
} else {
// multi-line comment — enter comment mode
inComment = true;
continue;
}
}
if (inComment) {
if (line.contains("-->")) {
inComment = false;
// strip up to and including -->
final after = line.substring(line.indexOf("-->") + 3);
if (after.trim().isNotEmpty) {
result.write(after);
if (i < lines.length - 1) result.write("\n");
}
}
// skip lines inside comment block
continue;
}
result.write(line);
if (i < lines.length - 1) result.write("\n");
}
return result.toString();
}
// ──────────────────────────────────────────────────────────────────────────────
// @include path extraction
// ──────────────────────────────────────────────────────────────────────────────
// Mirrors extractIncludePathsFromTokens from Claude Code.
// Finds @path patterns in text nodes (skips code blocks and HTML comments).
List<String> _extractIncludePaths(String content, String fileDir) {
final absolutePaths = <String>{};
final includeRegex = RegExp(r"(?:^|\s)@((?:[^\s\\]|\\ )+)");
final lines = content.split("\n");
var inFence = false;
String? fenceChar;
var inComment = false;
for (final line in lines) {
final raw = line.trimLeft();
// Track fenced code blocks
if (!inFence && !inComment) {
if (raw.startsWith("```") || raw.startsWith("~~~")) {
inFence = true;
fenceChar = raw.startsWith("```") ? "```" : "~~~";
continue;
}
} else if (inFence) {
if (raw.startsWith(fenceChar!)) {
inFence = false;
fenceChar = null;
}
continue;
}
// Track HTML comment blocks (skip @includes inside them)
if (!inComment && raw.startsWith("<!--")) {
if (line.contains("-->")) {
// single-line comment — strip and check residue
final residue = line.replaceAll(RegExp(r"<!--[\s\S]*?-->"), "");
_extractPathsFromText(residue, fileDir, includeRegex, absolutePaths);
} else {
inComment = true;
}
continue;
}
if (inComment) {
if (line.contains("-->")) inComment = false;
continue;
}
_extractPathsFromText(line, fileDir, includeRegex, absolutePaths);
}
return absolutePaths.toList();
}
void _extractPathsFromText(
String text,
String fileDir,
RegExp includeRegex,
Set<String> absolutePaths,
) {
for (final match in includeRegex.allMatches(text)) {
var path = match.group(1);
if (path == null || path.isEmpty) continue;
// strip fragment identifiers
final hashIdx = path.indexOf("#");
if (hashIdx != -1) path = path.substring(0, hashIdx);
if (path.isEmpty) continue;
// unescape spaces
path = path.replaceAll(r"\ ", " ");
final isValid = path.startsWith("./") ||
path.startsWith("~/") ||
(path.startsWith("/") && path != "/") ||
(!path.startsWith("@") &&
!RegExp(r"^[#%^&*()]+").hasMatch(path) &&
RegExp(r"^[a-zA-Z0-9._-]").hasMatch(path));
if (!isValid) continue;
final resolved = _expandPath(path, fileDir);
absolutePaths.add(resolved);
}
}
String _expandPath(String path, String baseDir) {
if (path.startsWith("~/")) {
final home = Platform.environment["HOME"] ?? Platform.environment["USERPROFILE"] ?? "";
return p.join(home, path.substring(2));
}
if (p.isAbsolute(path)) return path;
return p.normalize(p.join(baseDir, path));
}
// ──────────────────────────────────────────────────────────────────────────────
// Frontmatter paths extraction
// ──────────────────────────────────────────────────────────────────────────────
// Returns null if no paths restriction, empty/non-empty list if restricted.
// Strips /** suffix like Claude Code does.
List<String>? _parseFrontmatterPaths(Map<String, dynamic> frontmatter) {
final raw = frontmatter["paths"];
if (raw == null) return null;
final patterns = splitPathInFrontmatter(raw)
.map((p) => p.endsWith("/**") ? p.substring(0, p.length - 3) : p)
.where((p) => p.isNotEmpty)
.toList();
// if all patterns are ** (match-all), treat as no restriction
if (patterns.isEmpty || patterns.every((p) => p == "**")) return null;
return patterns;
}
// ──────────────────────────────────────────────────────────────────────────────
// Core file processor
// ──────────────────────────────────────────────────────────────────────────────
// Recursively processes a memory file and all its @include references.
// Returns includes-first list (same order as Claude Code).
Future<List<MemoryFileInfo>> processMemoryFile(
String filePath,
MemoryType type,
Set<String> processedPaths, {
bool includeExternal = false,
int depth = 0,
String? parent,
String? originalCwd,
}) async {
final normalizedPath = p.normalize(filePath);
if (processedPaths.contains(normalizedPath) || depth >= _maxIncludeDepth) {
return [];
}
// Extension check — skip non-text files (like Claude Code does for @include)
final ext = p.extension(filePath).toLowerCase();
if (ext.isNotEmpty && !_textFileExtensions.contains(ext)) return [];
processedPaths.add(normalizedPath);
String rawContent;
try {
final file = File(filePath);
// resolve symlinks
String resolvedPath = filePath;
try {
resolvedPath = await file.resolveSymbolicLinks();
if (resolvedPath != normalizedPath) {
processedPaths.add(p.normalize(resolvedPath));
}
} catch (_) {}
if (!await file.exists()) return [];
rawContent = await file.readAsString();
} catch (_) {
return [];
}
// parse frontmatter
final parsed = parseFrontmatter(rawContent);
final globs = _parseFrontmatterPaths(parsed.frontmatter);
// strip HTML block comments
final stripped = stripHtmlComments(parsed.content);
if (stripped.trim().isEmpty) return [];
final memFile = MemoryFileInfo(
path: filePath,
type: type,
content: stripped.trim(),
parent: parent,
globs: globs,
);
final result = <MemoryFileInfo>[memFile];
// process @include directives
final fileDir = p.dirname(filePath);
final includePaths = _extractIncludePaths(stripped, fileDir);
for (final includePath in includePaths) {
final isExternal = originalCwd != null &&
!_pathIsUnder(includePath, originalCwd);
if (isExternal && !includeExternal) continue;
final included = await processMemoryFile(
includePath,
type,
processedPaths,
includeExternal: includeExternal,
depth: depth + 1,
parent: filePath,
originalCwd: originalCwd,
);
result.addAll(included);
}
return result;
}
bool _pathIsUnder(String path, String root) {
final normPath = p.normalize(path);
final normRoot = p.normalize(root);
return normPath.startsWith(normRoot + p.separator) || normPath == normRoot;
}
// ──────────────────────────────────────────────────────────────────────────────
// rules directory processor
// ──────────────────────────────────────────────────────────────────────────────
// Processes all .md files in a .claude/rules/ directory (and subdirectories).
// conditionalRule=false → keep files WITHOUT a paths: frontmatter
// conditionalRule=true → keep files WITH a paths: frontmatter
Future<List<MemoryFileInfo>> processMdRules(
String rulesDir,
MemoryType type,
Set<String> processedPaths, {
bool includeExternal = false,
bool conditionalRule = false,
Set<String>? visitedDirs,
String? originalCwd,
}) async {
visitedDirs ??= {};
String resolvedDir;
try {
resolvedDir = await Directory(rulesDir).resolveSymbolicLinks();
} catch (_) {
resolvedDir = rulesDir;
}
if (visitedDirs.contains(resolvedDir)) return [];
visitedDirs.add(resolvedDir);
final dir = Directory(resolvedDir);
List<FileSystemEntity> entries;
try {
entries = dir.listSync();
} catch (_) {
return [];
}
entries.sort((a, b) => a.path.compareTo(b.path));
final result = <MemoryFileInfo>[];
for (final entry in entries) {
if (entry is Directory) {
result.addAll(await processMdRules(
entry.path,
type,
processedPaths,
includeExternal: includeExternal,
conditionalRule: conditionalRule,
visitedDirs: visitedDirs,
originalCwd: originalCwd,
));
} else if (entry is File && entry.path.endsWith(".md")) {
String resolvedEntry;
try {
resolvedEntry = await entry.resolveSymbolicLinks();
} catch (_) {
resolvedEntry = entry.path;
}
final files = await processMemoryFile(
resolvedEntry,
type,
processedPaths,
includeExternal: includeExternal,
originalCwd: originalCwd,
);
// filter by conditional/non-conditional
result.addAll(files.where((f) => conditionalRule ? f.globs != null : f.globs == null));
}
}
return result;
}
// ──────────────────────────────────────────────────────────────────────────────
// Git root detection (for nested worktree handling)
// ──────────────────────────────────────────────────────────────────────────────
String? _findGitRoot(String startDir) {
var current = p.normalize(startDir);
final root = p.rootPrefix(current);
while (true) {
final gitDir = Directory(p.join(current, ".git"));
if (gitDir.existsSync()) return current;
final parent = p.dirname(current);
if (parent == current || current == root) return null;
current = parent;
}
}
// In a git worktree, .git is a file (not a dir) containing the gitdir path.
// The canonical root is the main repo that owns the worktree.
String? _findCanonicalGitRoot(String startDir) {
var current = p.normalize(startDir);
final root = p.rootPrefix(current);
while (true) {
final gitEntity = p.join(current, ".git");
final gitFile = File(gitEntity);
final gitDir = Directory(gitEntity);
if (gitFile.existsSync() && !gitDir.existsSync()) {
// worktree: .git is a file like "gitdir: ../../.git/worktrees/name"
try {
final content = gitFile.readAsStringSync().trim();
if (content.startsWith("gitdir:")) {
final gitdirPath = content.substring("gitdir:".length).trim();
final resolved = p.normalize(p.isAbsolute(gitdirPath)
? gitdirPath
: p.join(current, gitdirPath));
// go up from .git/worktrees/<name> → main repo root
final mainGit = p.dirname(p.dirname(p.dirname(resolved)));
return mainGit;
}
} catch (_) {}
}
if (gitDir.existsSync()) return current;
final parent = p.dirname(current);
if (parent == current || current == root) return null;
current = parent;
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Main entry point
// ──────────────────────────────────────────────────────────────────────────────
// Loads all CLAUDE.md files in the same order as Claude Code.
// Returns a list of MemoryFileInfo objects, later entries have higher priority.
Future<List<MemoryFileInfo>> getMemoryFiles(String? workingDirectory) async {
final result = <MemoryFileInfo>[];
final processedPaths = <String>{};
final originalCwd = workingDirectory != null ? p.normalize(workingDirectory) : null;
// 1. Managed memory
final managedDir = _getManagedFilePath();
final managedMd = p.join(managedDir, "CLAUDE.md");
result.addAll(await processMemoryFile(
managedMd,
MemoryType.managed,
processedPaths,
includeExternal: true,
originalCwd: originalCwd,
));
result.addAll(await processMdRules(
p.join(managedDir, ".claude", "rules"),
MemoryType.managed,
processedPaths,
includeExternal: true,
originalCwd: originalCwd,
));
// 2. User memory
final userConfigDir = _getClaudeConfigHomeDir();
final userMd = p.join(userConfigDir, "CLAUDE.md");
result.addAll(await processMemoryFile(
userMd,
MemoryType.user,
processedPaths,
includeExternal: true,
originalCwd: originalCwd,
));
result.addAll(await processMdRules(
p.join(userConfigDir, "rules"),
MemoryType.user,
processedPaths,
includeExternal: true,
originalCwd: originalCwd,
));
if (originalCwd != null) {
// 3 & 4. Project + Local memory — walk up from cwd to root
final dirs = _buildAncestorChain(originalCwd);
// git worktree detection — same logic as Claude Code
final gitRoot = _findGitRoot(originalCwd);
final canonicalRoot = _findCanonicalGitRoot(originalCwd);
final isNestedWorktree = gitRoot != null &&
canonicalRoot != null &&
p.normalize(gitRoot) != p.normalize(canonicalRoot) &&
_pathIsUnder(gitRoot, canonicalRoot);
// process from root → cwd (so cwd files are loaded last = highest priority)
for (final dir in dirs) {
final skipProject = isNestedWorktree &&
_pathIsUnder(dir, canonicalRoot!) &&
!_pathIsUnder(dir, gitRoot!);
if (!skipProject) {
// CLAUDE.md
result.addAll(await processMemoryFile(
p.join(dir, "CLAUDE.md"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
));
// .claude/CLAUDE.md
result.addAll(await processMemoryFile(
p.join(dir, ".claude", "CLAUDE.md"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
));
// .claude/rules/*.md
result.addAll(await processMdRules(
p.join(dir, ".claude", "rules"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
));
}
// CLAUDE.local.md (not skipped even in nested worktrees)
result.addAll(await processMemoryFile(
p.join(dir, "CLAUDE.local.md"),
MemoryType.local,
processedPaths,
originalCwd: originalCwd,
));
}
// env var for additional directories
final additionalDirsEnv =
Platform.environment["CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD"];
if (additionalDirsEnv != null && additionalDirsEnv.isNotEmpty &&
_isEnvTruthy(additionalDirsEnv)) {
final additionalDirs = _getAdditionalDirs();
for (final dir in additionalDirs) {
result.addAll(await processMemoryFile(
p.join(dir, "CLAUDE.md"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
));
result.addAll(await processMemoryFile(
p.join(dir, ".claude", "CLAUDE.md"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
));
result.addAll(await processMdRules(
p.join(dir, ".claude", "rules"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
));
}
}
}
return result;
}
// Builds ancestor chain from filesystem root down to dir (inclusive)
List<String> _buildAncestorChain(String dir) {
final chain = <String>[];
var current = p.normalize(dir);
final root = p.rootPrefix(current);
while (true) {
chain.add(current);
final parent = p.dirname(current);
if (parent == current || current == root) break;
current = parent;
}
return chain.reversed.toList();
}
bool _isEnvTruthy(String value) {
final v = value.toLowerCase().trim();
return v == "1" || v == "true" || v == "yes";
}
List<String> _getAdditionalDirs() {
// Claude Code gets these from bootstrap state (--add-dir flag).
// The Agency doesn't support --add-dir yet, so return empty.
return [];
}
// ──────────────────────────────────────────────────────────────────────────────
// Assembler (mirrors getClaudeMds)
// ──────────────────────────────────────────────────────────────────────────────
// Assembles memory files into a single string for injection into the system prompt.
// Mirrors getClaudeMds() from Claude Code.
String getClaudeMds(List<MemoryFileInfo> memoryFiles) {
final memories = <String>[];
for (final file in memoryFiles) {
final content = file.content.trim();
if (content.isEmpty) continue;
memories.add("Contents of ${file.path}${file.type.label}:\n\n$content");
}
if (memories.isEmpty) return "";
return "$_memoryInstructionPrompt\n\n${memories.join("\n\n")}";
}
@@ -0,0 +1,98 @@
import "package:yaml/yaml.dart";
class ParsedFrontmatter {
final Map<String, dynamic> frontmatter;
final String content;
const ParsedFrontmatter({required this.frontmatter, required this.content});
}
final _frontmatterRegex = RegExp(r"^---\s*\n([\s\S]*?)---\s*\n?");
ParsedFrontmatter parseFrontmatter(String markdown) {
final match = _frontmatterRegex.firstMatch(markdown);
if (match == null) {
return ParsedFrontmatter(frontmatter: {}, content: markdown);
}
final frontmatterText = match.group(1) ?? "";
final content = markdown.substring(match.end);
Map<String, dynamic> frontmatter = {};
try {
final parsed = loadYaml(frontmatterText);
if (parsed is YamlMap) {
frontmatter = _deepConvert(parsed) as Map<String, dynamic>;
}
} catch (_) {}
return ParsedFrontmatter(frontmatter: frontmatter, content: content);
}
dynamic _deepConvert(dynamic value) {
if (value is YamlMap) {
return {
for (final entry in value.entries)
entry.key.toString(): _deepConvert(entry.value),
};
}
if (value is YamlList) {
return [for (final item in value) _deepConvert(item)];
}
return value;
}
// Splits the frontmatter `paths:` value into individual glob patterns.
// Accepts a comma-separated string or a list.
// Handles brace expansion: src/*.{ts,tsx} → [src/*.ts, src/*.tsx]
List<String> splitPathInFrontmatter(dynamic input) {
if (input is List) {
return input.expand((e) => splitPathInFrontmatter(e.toString())).toList();
}
if (input is! String) return [];
// split by comma while respecting braces
final parts = <String>[];
var current = StringBuffer();
var braceDepth = 0;
for (var i = 0; i < input.length; i++) {
final char = input[i];
if (char == "{") {
braceDepth++;
current.write(char);
} else if (char == "}") {
braceDepth--;
current.write(char);
} else if (char == "," && braceDepth == 0) {
final trimmed = current.toString().trim();
if (trimmed.isNotEmpty) parts.add(trimmed);
current.clear();
} else {
current.write(char);
}
}
final last = current.toString().trim();
if (last.isNotEmpty) parts.add(last);
return parts
.where((p) => p.isNotEmpty)
.expand(_expandBraces)
.toList();
}
List<String> _expandBraces(String pattern) {
final braceMatch = RegExp(r"^([^{]*)\{([^}]+)\}(.*)$").firstMatch(pattern);
if (braceMatch == null) return [pattern];
final prefix = braceMatch.group(1) ?? "";
final alternatives = braceMatch.group(2) ?? "";
final suffix = braceMatch.group(3) ?? "";
return alternatives
.split(",")
.map((alt) => alt.trim())
.expand((alt) => _expandBraces("$prefix$alt$suffix"))
.toList();
}
@@ -0,0 +1,22 @@
import "memory_types.dart";
class MemoryFileInfo {
final String path;
final MemoryType type;
final String content;
final String? parent;
// glob patterns from frontmatter `paths:` field
// null means no paths restriction (applies to all files)
final List<String>? globs;
const MemoryFileInfo({
required this.path,
required this.type,
required this.content,
this.parent,
this.globs,
});
}
+16
View File
@@ -0,0 +1,16 @@
enum MemoryType { managed, user, project, local }
extension MemoryTypeDescription on MemoryType {
String get label {
switch (this) {
case MemoryType.managed:
return " (user's private global instructions for all projects)";
case MemoryType.user:
return " (user's private global instructions for all projects)";
case MemoryType.project:
return " (project instructions, checked into the codebase)";
case MemoryType.local:
return " (user's private project instructions, not checked in)";
}
}
}
@@ -1,20 +1,27 @@
String buildDefaultSystemPrompt({
String? appendSystemPrompt,
String? customSystemPrompt,
String? claudeMd,
}) {
if (customSystemPrompt != null && customSystemPrompt.trim().isNotEmpty) {
final parts = <String>[customSystemPrompt];
if (appendSystemPrompt != null && appendSystemPrompt.trim().isNotEmpty) {
parts.add(appendSystemPrompt);
}
if (claudeMd != null && claudeMd.trim().isNotEmpty) {
parts.add(claudeMd);
}
return parts.join("\n\n");
}
final parts = <String>[
"You are Claude Code, an AI assistant for software engineering.",
"You are The Agency, an AI assistant for software engineering.",
];
if (appendSystemPrompt != null && appendSystemPrompt.trim().isNotEmpty) {
parts.add(appendSystemPrompt);
}
if (claudeMd != null && claudeMd.trim().isNotEmpty) {
parts.add(claudeMd);
}
return parts.join("\n\n");
}