From c8a2d62d27a5a5a7996c6f6663e1a44f4e6e6c7a Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 03:55:45 +0000 Subject: [PATCH] feat(brain): recurse seed-memory scans Co-Authored-By: Virgil --- .../Commands/BrainSeedMemoryCommand.php | 98 ++++++++++++++++++- .../Feature/BrainSeedMemoryCommandTest.php | 50 ++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 php/tests/Feature/BrainSeedMemoryCommandTest.php diff --git a/php/Console/Commands/BrainSeedMemoryCommand.php b/php/Console/Commands/BrainSeedMemoryCommand.php index a07dfa5..b3df478 100644 --- a/php/Console/Commands/BrainSeedMemoryCommand.php +++ b/php/Console/Commands/BrainSeedMemoryCommand.php @@ -40,7 +40,7 @@ class BrainSeedMemoryCommand extends Command $scanPath = $this->option('path') ?? $this->expandHome('~/.claude/projects/*/memory/'); - $files = glob($scanPath.'*.md'); + $files = $this->discoverMarkdownFiles($scanPath); if (empty($files)) { $this->info("No markdown files found in: {$scanPath}"); @@ -227,4 +227,100 @@ class BrainSeedMemoryCommand extends Command return $path; } + + /** + * Discover markdown files from a file path, directory, or glob. + * + * The command accepts a directory path like + * `--path=/root/.claude/projects/workspace/memory/` as well as a single + * file path like `--path=/root/.claude/projects/foo/memory/MEMORY.md`. + * + * @return array + */ + private function discoverMarkdownFiles(string $scanPath): array + { + $expandedPath = $this->expandHome($scanPath); + if ($expandedPath === '') { + return []; + } + + $files = []; + foreach ($this->expandScanTargets($expandedPath) as $target) { + $files = array_merge($files, $this->collectMarkdownFiles($target)); + } + + $files = array_values(array_unique($files)); + sort($files); + + return $files; + } + + /** + * Expand a directory path or glob pattern into concrete scan targets. + * + * @return array + */ + private function expandScanTargets(string $scanPath): array + { + if ($this->hasGlobMeta($scanPath)) { + return glob($scanPath) ?: []; + } + + return [$scanPath]; + } + + /** + * Collect markdown files from a file or directory. + * + * @return array + */ + private function collectMarkdownFiles(string $path): array + { + if (! file_exists($path)) { + return []; + } + + if (is_file($path)) { + return $this->isMarkdownFile($path) ? [$path] : []; + } + + if (! is_dir($path)) { + return []; + } + + $files = []; + $entries = scandir($path); + if ($entries === false) { + return []; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $childPath = $path.'/'.$entry; + if (is_dir($childPath)) { + $files = array_merge($files, $this->collectMarkdownFiles($childPath)); + + continue; + } + + if ($this->isMarkdownFile($childPath)) { + $files[] = $childPath; + } + } + + return $files; + } + + private function hasGlobMeta(string $path): bool + { + return str_contains($path, '*') || str_contains($path, '?') || str_contains($path, '['); + } + + private function isMarkdownFile(string $path): bool + { + return strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'md'; + } } diff --git a/php/tests/Feature/BrainSeedMemoryCommandTest.php b/php/tests/Feature/BrainSeedMemoryCommandTest.php new file mode 100644 index 0000000..480d964 --- /dev/null +++ b/php/tests/Feature/BrainSeedMemoryCommandTest.php @@ -0,0 +1,50 @@ +createSeedMemoryFixture(); + + $brain = Mockery::mock(BrainService::class); + $brain->shouldReceive('ensureCollection')->once(); + $brain->shouldReceive('remember') + ->twice() + ->andReturnUsing(static fn (): BrainMemory => new BrainMemory()); + + $this->app->instance(BrainService::class, $brain); + + $this->artisan('brain:seed-memory', [ + '--workspace' => $workspaceId, + '--agent' => 'virgil', + '--path' => $scanPath, + ]) + ->expectsOutputToContain('Found 2 markdown file(s) to process.') + ->expectsOutputToContain('Imported 2 memories, skipped 0.') + ->assertSuccessful(); + } + + private function createSeedMemoryFixture(): string + { + $scanPath = sys_get_temp_dir().'/brain-seed-'.bin2hex(random_bytes(6)); + $nestedPath = $scanPath.'/nested'; + + mkdir($nestedPath, 0777, true); + + file_put_contents($scanPath.'/MEMORY.md', "# Memory\n\n## Architecture\nUse Core.Process() for command execution.\n\n## Decision\nPrefer named actions."); + file_put_contents($nestedPath.'/notes.md', "# Notes\n\n## Convention\nUse UK English in user-facing output."); + file_put_contents($nestedPath.'/ignore.txt', 'This file should not be imported.'); + + return $scanPath; + } +}