From dfd3dde7b1eb847f3b4c1d12154ba84b5cb96dd5 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 3 Mar 2026 09:53:28 +0000 Subject: [PATCH] feat(brain): add brain:seed-memory artisan command Scans ~/.claude/projects/*/memory/ for MEMORY.md and topic markdown files, parses sections, infers memory types, and imports into OpenBrain via BrainService::remember(). Supports --dry-run, --workspace, --agent, and --path options. Co-Authored-By: Virgil --- Boot.php | 1 + Console/Commands/BrainSeedMemoryCommand.php | 230 ++++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 Console/Commands/BrainSeedMemoryCommand.php diff --git a/Boot.php b/Boot.php index 545afba..8672f9d 100644 --- a/Boot.php +++ b/Boot.php @@ -122,6 +122,7 @@ class Boot extends ServiceProvider $event->command(Console\Commands\PlanCommand::class); $event->command(Console\Commands\GenerateCommand::class); $event->command(Console\Commands\PlanRetentionCommand::class); + $event->command(Console\Commands\BrainSeedMemoryCommand::class); } /** diff --git a/Console/Commands/BrainSeedMemoryCommand.php b/Console/Commands/BrainSeedMemoryCommand.php new file mode 100644 index 0000000..c71b9e5 --- /dev/null +++ b/Console/Commands/BrainSeedMemoryCommand.php @@ -0,0 +1,230 @@ +option('workspace'); + if (! $workspaceId) { + $this->error('--workspace is required. Pass the workspace ID to import memories into.'); + + return self::FAILURE; + } + + $agentId = $this->option('agent') ?? 'virgil'; + $isDryRun = (bool) $this->option('dry-run'); + + $scanPath = $this->option('path') + ?? $this->expandHome('~/.claude/projects/*/memory/'); + + $files = glob($scanPath.'*.md'); + if (empty($files)) { + $this->info("No markdown files found in: {$scanPath}"); + + return self::SUCCESS; + } + + $this->info(sprintf('Found %d markdown file(s) to process.', count($files))); + + if (! $isDryRun) { + $brain->ensureCollection(); + } + + $imported = 0; + $skipped = 0; + + foreach ($files as $file) { + $filename = basename($file, '.md'); + $project = $this->extractProject($file); + $sections = $this->parseMarkdownSections($file); + + if (empty($sections)) { + $this->line(" Skipped {$filename} (no sections found)"); + $skipped++; + + continue; + } + + foreach ($sections as $section) { + $type = $this->inferType($section['heading'], $section['content']); + + if ($isDryRun) { + $this->line(sprintf( + ' [DRY RUN] %s :: %s (%s) — %d chars', + $filename, + $section['heading'], + $type, + strlen($section['content']), + )); + $imported++; + + continue; + } + + try { + $brain->remember([ + 'workspace_id' => (int) $workspaceId, + 'agent_id' => $agentId, + 'type' => $type, + 'content' => $section['heading']."\n\n".$section['content'], + 'tags' => $this->extractTags($section['heading'], $filename), + 'project' => $project, + 'confidence' => 0.7, + ]); + $imported++; + } catch (\Throwable $e) { + $this->warn(" Failed to import '{$section['heading']}': {$e->getMessage()}"); + $skipped++; + } + } + } + + $prefix = $isDryRun ? '[DRY RUN] ' : ''; + $this->info("{$prefix}Imported {$imported} memories, skipped {$skipped}."); + + return self::SUCCESS; + } + + /** + * Parse a markdown file into sections based on ## headings. + * + * @return array + */ + private function parseMarkdownSections(string $filePath): array + { + $content = file_get_contents($filePath); + if ($content === false || trim($content) === '') { + return []; + } + + $sections = []; + $lines = explode("\n", $content); + $currentHeading = ''; + $currentContent = []; + + foreach ($lines as $line) { + if (preg_match('/^#{1,3}\s+(.+)$/', $line, $matches)) { + if ($currentHeading !== '' && ! empty($currentContent)) { + $sections[] = [ + 'heading' => $currentHeading, + 'content' => trim(implode("\n", $currentContent)), + ]; + } + $currentHeading = trim($matches[1]); + $currentContent = []; + } else { + $currentContent[] = $line; + } + } + + // Flush last section + if ($currentHeading !== '' && ! empty($currentContent)) { + $text = trim(implode("\n", $currentContent)); + if ($text !== '') { + $sections[] = [ + 'heading' => $currentHeading, + 'content' => $text, + ]; + } + } + + return $sections; + } + + /** + * Extract a project name from the file path. + * + * Paths like ~/.claude/projects/-Users-snider-Code-eaas/memory/MEMORY.md + * yield "eaas". + */ + private function extractProject(string $filePath): ?string + { + if (preg_match('/projects\/[^\/]*-([^-\/]+)\/memory\//', $filePath, $matches)) { + return $matches[1]; + } + + return null; + } + + /** + * Infer the memory type from the heading and content. + */ + private function inferType(string $heading, string $content): string + { + $lower = strtolower($heading.' '.$content); + + $patterns = [ + 'architecture' => ['architecture', 'stack', 'infrastructure', 'layer', 'service mesh'], + 'convention' => ['convention', 'standard', 'naming', 'pattern', 'rule', 'coding'], + 'decision' => ['decision', 'chose', 'strategy', 'approach', 'domain'], + 'bug' => ['bug', 'fix', 'broken', 'error', 'issue', 'lesson'], + 'plan' => ['plan', 'todo', 'roadmap', 'milestone', 'phase'], + 'research' => ['research', 'finding', 'discovery', 'analysis', 'rfc'], + ]; + + foreach ($patterns as $type => $keywords) { + foreach ($keywords as $keyword) { + if (str_contains($lower, $keyword)) { + return $type; + } + } + } + + return 'observation'; + } + + /** + * Extract topic tags from the heading and filename. + * + * @return array + */ + private function extractTags(string $heading, string $filename): array + { + $tags = []; + + if ($filename !== 'MEMORY') { + $tags[] = str_replace(['-', '_'], ' ', $filename); + } + + $tags[] = 'memory-import'; + + return $tags; + } + + /** + * Expand ~ to the user's home directory. + */ + private function expandHome(string $path): string + { + if (str_starts_with($path, '~/')) { + $home = getenv('HOME') ?: ('/Users/'.get_current_user()); + + return $home.substr($path, 1); + } + + return $path; + } +}