feat(brain): recurse seed-memory scans

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 03:55:45 +00:00
parent 547a481d7b
commit c8a2d62d27
2 changed files with 147 additions and 1 deletions

View file

@ -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<string>
*/
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<string>
*/
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<string>
*/
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';
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Mockery;
use Tests\TestCase;
class BrainSeedMemoryCommandTest extends TestCase
{
public function test_it_recursively_imports_markdown_files_from_a_directory(): void
{
$workspaceId = 42;
$scanPath = $this->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;
}
}