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