This repository has been archived on 2026-03-09. You can view files and clone it, but cannot push or open issues or pull requests.
php-agentic/docs/plans/2026-03-03-openbrain-impl.md

1723 lines
49 KiB
Markdown
Raw Normal View History

# OpenBrain Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Shared vector-indexed knowledge store for all agents, accessible via 4 MCP tools (`brain_remember`, `brain_recall`, `brain_forget`, `brain_list`).
**Architecture:** MariaDB table in php-agentic for relational data. Qdrant collection for vector embeddings. Ollama for embedding generation. Go bridge in go-ai for CLI agents.
**Tech Stack:** PHP 8.3 / Laravel / Pest, Go 1.25, Qdrant REST API, Ollama embeddings API, MariaDB
**Prerequisites:**
- Qdrant container running on de1 (deploy via Ansible — separate task)
- Ollama with `nomic-embed-text` model pulled (`ollama pull nomic-embed-text`)
---
### Task 1: Migration + BrainMemory Model
**Files:**
- Create: `Migrations/0001_01_01_000004_create_brain_memories_table.php`
- Create: `Models/BrainMemory.php`
**Step 1: Write the migration**
```php
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::disableForeignKeyConstraints();
if (! Schema::hasTable('brain_memories')) {
Schema::create('brain_memories', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->string('agent_id', 64);
$table->string('type', 32)->index();
$table->text('content');
$table->json('tags')->nullable();
$table->string('project', 128)->nullable()->index();
$table->float('confidence')->default(1.0);
$table->uuid('supersedes_id')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index('workspace_id');
$table->index('agent_id');
$table->index(['workspace_id', 'type']);
$table->index(['workspace_id', 'project']);
$table->foreign('supersedes_id')
->references('id')
->on('brain_memories')
->nullOnDelete();
});
}
Schema::enableForeignKeyConstraints();
}
public function down(): void
{
Schema::dropIfExists('brain_memories');
}
};
```
**Step 2: Write the model**
```php
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Models;
use Core\Tenant\Concerns\BelongsToWorkspace;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class BrainMemory extends Model
{
use BelongsToWorkspace;
use HasUuids;
use SoftDeletes;
public const TYPE_DECISION = 'decision';
public const TYPE_OBSERVATION = 'observation';
public const TYPE_CONVENTION = 'convention';
public const TYPE_RESEARCH = 'research';
public const TYPE_PLAN = 'plan';
public const TYPE_BUG = 'bug';
public const TYPE_ARCHITECTURE = 'architecture';
public const VALID_TYPES = [
self::TYPE_DECISION,
self::TYPE_OBSERVATION,
self::TYPE_CONVENTION,
self::TYPE_RESEARCH,
self::TYPE_PLAN,
self::TYPE_BUG,
self::TYPE_ARCHITECTURE,
];
protected $table = 'brain_memories';
protected $fillable = [
'workspace_id',
'agent_id',
'type',
'content',
'tags',
'project',
'confidence',
'supersedes_id',
'expires_at',
];
protected $casts = [
'tags' => 'array',
'confidence' => 'float',
'expires_at' => 'datetime',
];
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function supersedes(): BelongsTo
{
return $this->belongsTo(self::class, 'supersedes_id');
}
public function supersededBy(): HasMany
{
return $this->hasMany(self::class, 'supersedes_id');
}
public function scopeForWorkspace(Builder $query, int $workspaceId): Builder
{
return $query->where('workspace_id', $workspaceId);
}
public function scopeOfType(Builder $query, string|array $type): Builder
{
return is_array($type)
? $query->whereIn('type', $type)
: $query->where('type', $type);
}
public function scopeForProject(Builder $query, ?string $project): Builder
{
return $project
? $query->where('project', $project)
: $query;
}
public function scopeByAgent(Builder $query, ?string $agentId): Builder
{
return $agentId
? $query->where('agent_id', $agentId)
: $query;
}
public function scopeActive(Builder $query): Builder
{
return $query->where(function (Builder $q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
public function scopeLatestVersions(Builder $query): Builder
{
return $query->whereDoesntHave('supersededBy', function (Builder $q) {
$q->whereNull('deleted_at');
});
}
public function getSupersessionDepth(): int
{
$count = 0;
$current = $this;
while ($current->supersedes_id) {
$count++;
$current = self::withTrashed()->find($current->supersedes_id);
if (! $current) {
break;
}
}
return $count;
}
public function toMcpContext(): array
{
return [
'id' => $this->id,
'agent_id' => $this->agent_id,
'type' => $this->type,
'content' => $this->content,
'tags' => $this->tags ?? [],
'project' => $this->project,
'confidence' => $this->confidence,
'supersedes_id' => $this->supersedes_id,
'supersedes_count' => $this->getSupersessionDepth(),
'expires_at' => $this->expires_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}
```
**Step 3: Run migration locally to verify**
Run: `cd /Users/snider/Code/php-agentic && php artisan migrate --path=Migrations`
Expected: Migration runs without errors (or skip if no local DB — verify on deploy)
**Step 4: Commit**
```bash
cd /Users/snider/Code/php-agentic
git add Migrations/0001_01_01_000004_create_brain_memories_table.php Models/BrainMemory.php
git commit -m "feat(brain): add BrainMemory model and migration"
```
---
### Task 2: BrainService — Ollama embeddings + Qdrant client
**Files:**
- Create: `Services/BrainService.php`
- Create: `tests/Unit/BrainServiceTest.php`
**Step 1: Write the failing test**
```php
<?php
declare(strict_types=1);
use Core\Mod\Agentic\Services\BrainService;
it('builds qdrant upsert payload correctly', function () {
$service = new BrainService(
ollamaUrl: 'http://localhost:11434',
qdrantUrl: 'http://localhost:6334',
collection: 'openbrain_test',
);
$payload = $service->buildQdrantPayload('test-uuid', [
'workspace_id' => 1,
'agent_id' => 'virgil',
'type' => 'decision',
'tags' => ['scoring'],
'project' => 'eaas',
'confidence' => 0.9,
'created_at' => '2026-03-03T00:00:00Z',
]);
expect($payload)->toHaveKey('id', 'test-uuid');
expect($payload)->toHaveKey('payload');
expect($payload['payload']['agent_id'])->toBe('virgil');
expect($payload['payload']['type'])->toBe('decision');
expect($payload['payload']['tags'])->toBe(['scoring']);
});
it('builds qdrant search filter correctly', function () {
$service = new BrainService(
ollamaUrl: 'http://localhost:11434',
qdrantUrl: 'http://localhost:6334',
collection: 'openbrain_test',
);
$filter = $service->buildQdrantFilter([
'workspace_id' => 1,
'project' => 'eaas',
'type' => ['decision', 'architecture'],
'min_confidence' => 0.5,
]);
expect($filter)->toHaveKey('must');
expect($filter['must'])->toHaveCount(4);
});
```
**Step 2: Run test to verify it fails**
Run: `cd /Users/snider/Code/php-agentic && ./vendor/bin/pest tests/Unit/BrainServiceTest.php`
Expected: FAIL — class not found
**Step 3: Write the service**
```php
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Services;
use Core\Mod\Agentic\Models\BrainMemory;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class BrainService
{
private const EMBEDDING_MODEL = 'nomic-embed-text';
private const VECTOR_DIMENSION = 768;
public function __construct(
private string $ollamaUrl = 'http://localhost:11434',
private string $qdrantUrl = 'http://localhost:6334',
private string $collection = 'openbrain',
) {}
/**
* Generate embedding vector for text content via Ollama.
*
* @return float[]
*/
public function embed(string $text): array
{
$response = Http::timeout(30)
->post("{$this->ollamaUrl}/api/embeddings", [
'model' => self::EMBEDDING_MODEL,
'prompt' => $text,
]);
if (! $response->successful()) {
throw new \RuntimeException("Ollama embedding failed: {$response->status()}");
}
return $response->json('embedding');
}
/**
* Store a memory: insert into MariaDB, embed, upsert into Qdrant.
*/
public function remember(BrainMemory $memory): void
{
$vector = $this->embed($memory->content);
$payload = $this->buildQdrantPayload($memory->id, [
'workspace_id' => $memory->workspace_id,
'agent_id' => $memory->agent_id,
'type' => $memory->type,
'tags' => $memory->tags ?? [],
'project' => $memory->project,
'confidence' => $memory->confidence,
'created_at' => $memory->created_at->toIso8601String(),
]);
$payload['vector'] = $vector;
$this->qdrantUpsert([$payload]);
// If superseding, remove old point from Qdrant
if ($memory->supersedes_id) {
$this->qdrantDelete([$memory->supersedes_id]);
BrainMemory::where('id', $memory->supersedes_id)->delete();
}
}
/**
* Semantic search: embed query, search Qdrant, hydrate from MariaDB.
*
* @return array{memories: array, scores: array}
*/
public function recall(string $query, int $topK, array $filter, int $workspaceId): array
{
$vector = $this->embed($query);
$filter['workspace_id'] = $workspaceId;
$qdrantFilter = $this->buildQdrantFilter($filter);
$response = Http::timeout(10)
->post("{$this->qdrantUrl}/collections/{$this->collection}/points/search", [
'vector' => $vector,
'filter' => $qdrantFilter,
'limit' => $topK,
'with_payload' => false,
]);
if (! $response->successful()) {
throw new \RuntimeException("Qdrant search failed: {$response->status()}");
}
$results = $response->json('result', []);
$ids = array_column($results, 'id');
$scoreMap = [];
foreach ($results as $r) {
$scoreMap[$r['id']] = $r['score'];
}
if (empty($ids)) {
return ['memories' => [], 'scores' => []];
}
$memories = BrainMemory::whereIn('id', $ids)
->active()
->latestVersions()
->get()
->sortBy(fn (BrainMemory $m) => array_search($m->id, $ids))
->values();
return [
'memories' => $memories->map(fn (BrainMemory $m) => $m->toMcpContext())->all(),
'scores' => $scoreMap,
];
}
/**
* Soft-delete a memory from MariaDB and remove from Qdrant.
*/
public function forget(string $id): void
{
$this->qdrantDelete([$id]);
BrainMemory::where('id', $id)->delete();
}
/**
* Ensure the Qdrant collection exists, create if not.
*/
public function ensureCollection(): void
{
$response = Http::timeout(5)
->get("{$this->qdrantUrl}/collections/{$this->collection}");
if ($response->status() === 404) {
Http::timeout(10)
->put("{$this->qdrantUrl}/collections/{$this->collection}", [
'vectors' => [
'size' => self::VECTOR_DIMENSION,
'distance' => 'Cosine',
],
]);
Log::info("OpenBrain: created Qdrant collection '{$this->collection}'");
}
}
/**
* Build a Qdrant point payload from memory metadata.
*/
public function buildQdrantPayload(string $id, array $metadata): array
{
return [
'id' => $id,
'payload' => $metadata,
];
}
/**
* Build a Qdrant filter from search criteria.
*/
public function buildQdrantFilter(array $criteria): array
{
$must = [];
if (isset($criteria['workspace_id'])) {
$must[] = ['key' => 'workspace_id', 'match' => ['value' => $criteria['workspace_id']]];
}
if (isset($criteria['project'])) {
$must[] = ['key' => 'project', 'match' => ['value' => $criteria['project']]];
}
if (isset($criteria['type'])) {
if (is_array($criteria['type'])) {
$must[] = ['key' => 'type', 'match' => ['any' => $criteria['type']]];
} else {
$must[] = ['key' => 'type', 'match' => ['value' => $criteria['type']]];
}
}
if (isset($criteria['agent_id'])) {
$must[] = ['key' => 'agent_id', 'match' => ['value' => $criteria['agent_id']]];
}
if (isset($criteria['min_confidence'])) {
$must[] = ['key' => 'confidence', 'range' => ['gte' => $criteria['min_confidence']]];
}
return ['must' => $must];
}
private function qdrantUpsert(array $points): void
{
$response = Http::timeout(10)
->put("{$this->qdrantUrl}/collections/{$this->collection}/points", [
'points' => $points,
]);
if (! $response->successful()) {
Log::error("Qdrant upsert failed: {$response->status()}", ['body' => $response->body()]);
throw new \RuntimeException("Qdrant upsert failed: {$response->status()}");
}
}
private function qdrantDelete(array $ids): void
{
Http::timeout(10)
->post("{$this->qdrantUrl}/collections/{$this->collection}/points/delete", [
'points' => $ids,
]);
}
}
```
**Step 4: Run tests to verify they pass**
Run: `cd /Users/snider/Code/php-agentic && ./vendor/bin/pest tests/Unit/BrainServiceTest.php`
Expected: PASS (unit tests only test payload/filter building, no external services)
**Step 5: Commit**
```bash
cd /Users/snider/Code/php-agentic
git add Services/BrainService.php tests/Unit/BrainServiceTest.php
git commit -m "feat(brain): add BrainService with Ollama embeddings and Qdrant client"
```
---
### Task 3: BrainRemember MCP Tool
**Files:**
- Create: `Mcp/Tools/Agent/Brain/BrainRemember.php`
- Create: `tests/Unit/Tools/BrainRememberTest.php`
**Step 1: Write the failing test**
```php
<?php
declare(strict_types=1);
use Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainRemember;
it('has correct name and category', function () {
$tool = new BrainRemember();
expect($tool->name())->toBe('brain_remember');
expect($tool->category())->toBe('brain');
});
it('requires write scope', function () {
$tool = new BrainRemember();
expect($tool->requiredScopes())->toContain('write');
});
it('requires content in input schema', function () {
$tool = new BrainRemember();
$schema = $tool->inputSchema();
expect($schema['required'])->toContain('content');
expect($schema['required'])->toContain('type');
});
it('returns error when content is missing', function () {
$tool = new BrainRemember();
$result = $tool->handle([], ['workspace_id' => 1, 'agent_id' => 'virgil']);
expect($result)->toHaveKey('error');
});
it('returns error when workspace_id is missing', function () {
$tool = new BrainRemember();
$result = $tool->handle([
'content' => 'Test memory',
'type' => 'observation',
], []);
expect($result)->toHaveKey('error');
});
```
**Step 2: Run test to verify it fails**
Run: `cd /Users/snider/Code/php-agentic && ./vendor/bin/pest tests/Unit/Tools/BrainRememberTest.php`
Expected: FAIL — class not found
**Step 3: Write the tool**
```php
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
class BrainRemember extends AgentTool
{
protected string $category = 'brain';
protected array $scopes = ['write'];
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required'),
];
}
public function name(): string
{
return 'brain_remember';
}
public function description(): string
{
return 'Store a memory in the shared agent knowledge graph. All agents can later recall this via semantic search.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'content' => [
'type' => 'string',
'description' => 'The knowledge to remember (markdown text)',
],
'type' => [
'type' => 'string',
'enum' => BrainMemory::VALID_TYPES,
'description' => 'Category: decision, observation, convention, research, plan, bug, architecture',
],
'tags' => [
'type' => 'array',
'items' => ['type' => 'string'],
'description' => 'Topic tags for filtering',
],
'project' => [
'type' => 'string',
'description' => 'Repo or project name (null for cross-project)',
],
'confidence' => [
'type' => 'number',
'description' => 'Confidence level 0.0-1.0 (default 1.0)',
],
'supersedes' => [
'type' => 'string',
'description' => 'UUID of an older memory this one replaces',
],
'expires_in' => [
'type' => 'integer',
'description' => 'Optional TTL in hours (for session-scoped context)',
],
],
'required' => ['content', 'type'],
];
}
public function handle(array $args, array $context = []): array
{
try {
$content = $this->requireString($args, 'content', 50000);
$type = $this->requireEnum($args, 'type', BrainMemory::VALID_TYPES);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
$agentId = $context['agent_id'] ?? 'unknown';
$expiresAt = null;
if (! empty($args['expires_in'])) {
$expiresAt = now()->addHours((int) $args['expires_in']);
}
return $this->withCircuitBreaker('brain', function () use ($args, $content, $type, $workspaceId, $agentId, $expiresAt) {
$memory = BrainMemory::create([
'workspace_id' => $workspaceId,
'agent_id' => $agentId,
'type' => $type,
'content' => $content,
'tags' => $args['tags'] ?? [],
'project' => $args['project'] ?? null,
'confidence' => $args['confidence'] ?? 1.0,
'supersedes_id' => $args['supersedes'] ?? null,
'expires_at' => $expiresAt,
]);
/** @var BrainService $brainService */
$brainService = app(BrainService::class);
$brainService->remember($memory);
return $this->success([
'id' => $memory->id,
'type' => $memory->type,
'agent_id' => $memory->agent_id,
'project' => $memory->project,
'supersedes' => $memory->supersedes_id,
]);
}, fn () => $this->error('Brain service temporarily unavailable', 'service_unavailable'));
}
}
```
**Step 4: Run tests to verify they pass**
Run: `cd /Users/snider/Code/php-agentic && ./vendor/bin/pest tests/Unit/Tools/BrainRememberTest.php`
Expected: PASS
**Step 5: Commit**
```bash
cd /Users/snider/Code/php-agentic
git add Mcp/Tools/Agent/Brain/BrainRemember.php tests/Unit/Tools/BrainRememberTest.php
git commit -m "feat(brain): add brain_remember MCP tool"
```
---
### Task 4: BrainRecall MCP Tool
**Files:**
- Create: `Mcp/Tools/Agent/Brain/BrainRecall.php`
- Create: `tests/Unit/Tools/BrainRecallTest.php`
**Step 1: Write the failing test**
```php
<?php
declare(strict_types=1);
use Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainRecall;
it('has correct name and category', function () {
$tool = new BrainRecall();
expect($tool->name())->toBe('brain_recall');
expect($tool->category())->toBe('brain');
});
it('requires read scope', function () {
$tool = new BrainRecall();
expect($tool->requiredScopes())->toContain('read');
});
it('requires query in input schema', function () {
$tool = new BrainRecall();
$schema = $tool->inputSchema();
expect($schema['required'])->toContain('query');
});
it('returns error when query is missing', function () {
$tool = new BrainRecall();
$result = $tool->handle([], ['workspace_id' => 1]);
expect($result)->toHaveKey('error');
});
it('returns error when workspace_id is missing', function () {
$tool = new BrainRecall();
$result = $tool->handle(['query' => 'test'], []);
expect($result)->toHaveKey('error');
});
```
**Step 2: Run test to verify it fails**
Run: `cd /Users/snider/Code/php-agentic && ./vendor/bin/pest tests/Unit/Tools/BrainRecallTest.php`
Expected: FAIL — class not found
**Step 3: Write the tool**
```php
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
class BrainRecall extends AgentTool
{
protected string $category = 'brain';
protected array $scopes = ['read'];
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required'),
];
}
public function name(): string
{
return 'brain_recall';
}
public function description(): string
{
return 'Semantic search across the shared agent knowledge graph. Returns the most relevant memories for a natural language query.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'query' => [
'type' => 'string',
'description' => 'Natural language query (e.g. "How does verdict classification work?")',
],
'top_k' => [
'type' => 'integer',
'description' => 'Number of results to return (default 5, max 20)',
],
'filter' => [
'type' => 'object',
'description' => 'Optional filters to narrow search',
'properties' => [
'project' => [
'type' => 'string',
'description' => 'Filter by project name',
],
'type' => [
'type' => 'array',
'items' => ['type' => 'string'],
'description' => 'Filter by memory types',
],
'agent_id' => [
'type' => 'string',
'description' => 'Filter by agent who created the memory',
],
'min_confidence' => [
'type' => 'number',
'description' => 'Minimum confidence threshold',
],
],
],
],
'required' => ['query'],
];
}
public function handle(array $args, array $context = []): array
{
try {
$query = $this->requireString($args, 'query', 2000);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
$topK = min($this->optionalInt($args, 'top_k', 5, 1, 20) ?? 5, 20);
$filter = $args['filter'] ?? [];
return $this->withCircuitBreaker('brain', function () use ($query, $topK, $filter, $workspaceId) {
/** @var BrainService $brainService */
$brainService = app(BrainService::class);
$results = $brainService->recall($query, $topK, $filter, $workspaceId);
return $this->success([
'count' => count($results['memories']),
'memories' => array_map(function ($memory) use ($results) {
$memory['similarity'] = $results['scores'][$memory['id']] ?? 0;
return $memory;
}, $results['memories']),
]);
}, fn () => $this->error('Brain service temporarily unavailable', 'service_unavailable'));
}
}
```
**Step 4: Run tests to verify they pass**
Run: `cd /Users/snider/Code/php-agentic && ./vendor/bin/pest tests/Unit/Tools/BrainRecallTest.php`
Expected: PASS
**Step 5: Commit**
```bash
cd /Users/snider/Code/php-agentic
git add Mcp/Tools/Agent/Brain/BrainRecall.php tests/Unit/Tools/BrainRecallTest.php
git commit -m "feat(brain): add brain_recall MCP tool"
```
---
### Task 5: BrainForget + BrainList MCP Tools
**Files:**
- Create: `Mcp/Tools/Agent/Brain/BrainForget.php`
- Create: `Mcp/Tools/Agent/Brain/BrainList.php`
- Create: `tests/Unit/Tools/BrainForgetTest.php`
- Create: `tests/Unit/Tools/BrainListTest.php`
**Step 1: Write the failing tests**
`tests/Unit/Tools/BrainForgetTest.php`:
```php
<?php
declare(strict_types=1);
use Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainForget;
it('has correct name and category', function () {
$tool = new BrainForget();
expect($tool->name())->toBe('brain_forget');
expect($tool->category())->toBe('brain');
});
it('requires write scope', function () {
$tool = new BrainForget();
expect($tool->requiredScopes())->toContain('write');
});
it('requires id in input schema', function () {
$tool = new BrainForget();
$schema = $tool->inputSchema();
expect($schema['required'])->toContain('id');
});
```
`tests/Unit/Tools/BrainListTest.php`:
```php
<?php
declare(strict_types=1);
use Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainList;
it('has correct name and category', function () {
$tool = new BrainList();
expect($tool->name())->toBe('brain_list');
expect($tool->category())->toBe('brain');
});
it('requires read scope', function () {
$tool = new BrainList();
expect($tool->requiredScopes())->toContain('read');
});
it('returns error when workspace_id is missing', function () {
$tool = new BrainList();
$result = $tool->handle([], []);
expect($result)->toHaveKey('error');
});
```
**Step 2: Run tests to verify they fail**
Run: `cd /Users/snider/Code/php-agentic && ./vendor/bin/pest tests/Unit/Tools/BrainForgetTest.php tests/Unit/Tools/BrainListTest.php`
Expected: FAIL
**Step 3: Write BrainForget**
```php
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
class BrainForget extends AgentTool
{
protected string $category = 'brain';
protected array $scopes = ['write'];
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required'),
];
}
public function name(): string
{
return 'brain_forget';
}
public function description(): string
{
return 'Soft-delete a memory from the knowledge graph. Keeps audit trail but removes from search.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'id' => [
'type' => 'string',
'description' => 'UUID of the memory to forget',
],
'reason' => [
'type' => 'string',
'description' => 'Why this memory is being removed',
],
],
'required' => ['id'],
];
}
public function handle(array $args, array $context = []): array
{
try {
$id = $this->requireString($args, 'id');
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
return $this->withCircuitBreaker('brain', function () use ($id, $workspaceId) {
$memory = BrainMemory::forWorkspace($workspaceId)->find($id);
if (! $memory) {
return $this->error("Memory not found: {$id}");
}
/** @var BrainService $brainService */
$brainService = app(BrainService::class);
$brainService->forget($id);
return $this->success([
'id' => $id,
'forgotten' => true,
]);
}, fn () => $this->error('Brain service temporarily unavailable', 'service_unavailable'));
}
}
```
**Step 4: Write BrainList**
```php
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
class BrainList extends AgentTool
{
protected string $category = 'brain';
protected array $scopes = ['read'];
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required'),
];
}
public function name(): string
{
return 'brain_list';
}
public function description(): string
{
return 'Browse memories by type, project, or agent. No vector search — pure relational filter for auditing and exploration.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'project' => [
'type' => 'string',
'description' => 'Filter by project name',
],
'type' => [
'type' => 'string',
'enum' => BrainMemory::VALID_TYPES,
'description' => 'Filter by memory type',
],
'agent_id' => [
'type' => 'string',
'description' => 'Filter by agent who created the memory',
],
'limit' => [
'type' => 'integer',
'description' => 'Max results (default 20, max 100)',
],
],
'required' => [],
];
}
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
$limit = min($this->optionalInt($args, 'limit', 20, 1, 100) ?? 20, 100);
$query = BrainMemory::forWorkspace($workspaceId)
->active()
->latestVersions();
if (! empty($args['project'])) {
$query->forProject($args['project']);
}
if (! empty($args['type'])) {
$query->ofType($args['type']);
}
if (! empty($args['agent_id'])) {
$query->byAgent($args['agent_id']);
}
$memories = $query->orderByDesc('created_at')
->limit($limit)
->get();
return $this->success([
'count' => $memories->count(),
'memories' => $memories->map(fn (BrainMemory $m) => $m->toMcpContext())->all(),
]);
}
}
```
**Step 5: Run tests to verify they pass**
Run: `cd /Users/snider/Code/php-agentic && ./vendor/bin/pest tests/Unit/Tools/`
Expected: PASS
**Step 6: Commit**
```bash
cd /Users/snider/Code/php-agentic
git add Mcp/Tools/Agent/Brain/ tests/Unit/Tools/BrainForgetTest.php tests/Unit/Tools/BrainListTest.php
git commit -m "feat(brain): add brain_forget and brain_list MCP tools"
```
---
### Task 6: Register Brain Tools + Config
**Files:**
- Modify: `Boot.php`
- Modify: `config.php`
**Step 1: Add BrainService config**
Add to `config.php`:
```php
'brain' => [
'ollama_url' => env('BRAIN_OLLAMA_URL', 'http://localhost:11434'),
'qdrant_url' => env('BRAIN_QDRANT_URL', 'http://localhost:6334'),
'collection' => env('BRAIN_COLLECTION', 'openbrain'),
],
```
**Step 2: Register BrainService singleton in Boot.php**
In the `register()` method, add:
```php
$this->app->singleton(\Core\Mod\Agentic\Services\BrainService::class, function ($app) {
return new \Core\Mod\Agentic\Services\BrainService(
ollamaUrl: config('mcp.brain.ollama_url', 'http://localhost:11434'),
qdrantUrl: config('mcp.brain.qdrant_url', 'http://localhost:6334'),
collection: config('mcp.brain.collection', 'openbrain'),
);
});
```
**Step 3: Register brain tools in the AgentToolRegistry**
The tools are auto-discovered by the registry when registered. In `Boot.php`, update the `onMcpTools` method or add brain tool registration wherever Session/Plan/State tools are registered. Check how existing tools are registered — likely in the MCP module's boot, not here. If tools are registered elsewhere, add them there.
Look at how Session/Plan tools are registered:
```bash
cd /Users/snider/Code/php-agentic && grep -r "BrainRemember\|SessionStart\|register.*Tool" Boot.php Mcp/ --include="*.php" -l
```
Follow the same pattern for the 4 brain tools:
```php
$registry->registerMany([
new \Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainRemember(),
new \Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainRecall(),
new \Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainForget(),
new \Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainList(),
]);
```
**Step 4: Commit**
```bash
cd /Users/snider/Code/php-agentic
git add Boot.php config.php
git commit -m "feat(brain): register BrainService and brain tools"
```
---
### Task 7: Go Brain Bridge Subsystem
**Files:**
- Create: `/Users/snider/Code/go-ai/mcp/brain/brain.go`
- Create: `/Users/snider/Code/go-ai/mcp/brain/tools.go`
- Create: `/Users/snider/Code/go-ai/mcp/brain/brain_test.go`
**Step 1: Write the failing test**
`brain_test.go`:
```go
package brain
import (
"testing"
)
func TestSubsystem_Name(t *testing.T) {
sub := New(nil)
if sub.Name() != "brain" {
t.Errorf("Name() = %q, want %q", sub.Name(), "brain")
}
}
func TestBuildRememberMessage(t *testing.T) {
msg := buildBridgeMessage("brain_remember", map[string]any{
"content": "test memory",
"type": "observation",
})
if msg.Type != "brain_remember" {
t.Errorf("Type = %q, want %q", msg.Type, "brain_remember")
}
if msg.Channel != "brain:remember" {
t.Errorf("Channel = %q, want %q", msg.Channel, "brain:remember")
}
}
```
**Step 2: Run test to verify it fails**
Run: `cd /Users/snider/Code/go-ai && go test ./mcp/brain/ -v`
Expected: FAIL — package not found
**Step 3: Write the subsystem**
`brain.go`:
```go
package brain
import (
"context"
"time"
"forge.lthn.ai/core/go-ai/mcp/ide"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Subsystem bridges brain_* MCP tools to the Laravel backend.
type Subsystem struct {
bridge *ide.Bridge
}
// New creates a brain subsystem using an existing IDE bridge.
func New(bridge *ide.Bridge) *Subsystem {
return &Subsystem{bridge: bridge}
}
// Name implements mcp.Subsystem.
func (s *Subsystem) Name() string { return "brain" }
// RegisterTools implements mcp.Subsystem.
func (s *Subsystem) RegisterTools(server *mcp.Server) {
s.registerTools(server)
}
// Shutdown implements mcp.SubsystemWithShutdown.
func (s *Subsystem) Shutdown(_ context.Context) error { return nil }
func buildBridgeMessage(toolName string, data any) ide.BridgeMessage {
channelMap := map[string]string{
"brain_remember": "brain:remember",
"brain_recall": "brain:recall",
"brain_forget": "brain:forget",
"brain_list": "brain:list",
}
return ide.BridgeMessage{
Type: toolName,
Channel: channelMap[toolName],
Data: data,
Timestamp: time.Now(),
}
}
```
`tools.go`:
```go
package brain
import (
"context"
"errors"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
var errBridgeNotAvailable = errors.New("brain: Laravel bridge not connected")
// Input/output types
type RememberInput struct {
Content string `json:"content"`
Type string `json:"type"`
Tags []string `json:"tags,omitempty"`
Project string `json:"project,omitempty"`
Confidence float64 `json:"confidence,omitempty"`
Supersedes string `json:"supersedes,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
}
type RememberOutput struct {
Sent bool `json:"sent"`
Timestamp time.Time `json:"timestamp"`
}
type RecallInput struct {
Query string `json:"query"`
TopK int `json:"top_k,omitempty"`
Filter map[string]any `json:"filter,omitempty"`
}
type RecallOutput struct {
Sent bool `json:"sent"`
Timestamp time.Time `json:"timestamp"`
}
type ForgetInput struct {
ID string `json:"id"`
Reason string `json:"reason,omitempty"`
}
type ForgetOutput struct {
Sent bool `json:"sent"`
Timestamp time.Time `json:"timestamp"`
}
type ListInput struct {
Project string `json:"project,omitempty"`
Type string `json:"type,omitempty"`
AgentID string `json:"agent_id,omitempty"`
Limit int `json:"limit,omitempty"`
}
type ListOutput struct {
Sent bool `json:"sent"`
Timestamp time.Time `json:"timestamp"`
}
func (s *Subsystem) registerTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "brain_remember",
Description: "Store a memory in the shared agent knowledge graph",
}, s.remember)
mcp.AddTool(server, &mcp.Tool{
Name: "brain_recall",
Description: "Semantic search across the shared agent knowledge graph",
}, s.recall)
mcp.AddTool(server, &mcp.Tool{
Name: "brain_forget",
Description: "Soft-delete a memory from the knowledge graph",
}, s.forget)
mcp.AddTool(server, &mcp.Tool{
Name: "brain_list",
Description: "Browse memories by type, project, or agent",
}, s.list)
}
func (s *Subsystem) remember(_ context.Context, _ *mcp.CallToolRequest, input RememberInput) (*mcp.CallToolResult, RememberOutput, error) {
if s.bridge == nil {
return nil, RememberOutput{}, errBridgeNotAvailable
}
err := s.bridge.Send(buildBridgeMessage("brain_remember", input))
if err != nil {
return nil, RememberOutput{}, err
}
return nil, RememberOutput{Sent: true, Timestamp: time.Now()}, nil
}
func (s *Subsystem) recall(_ context.Context, _ *mcp.CallToolRequest, input RecallInput) (*mcp.CallToolResult, RecallOutput, error) {
if s.bridge == nil {
return nil, RecallOutput{}, errBridgeNotAvailable
}
err := s.bridge.Send(buildBridgeMessage("brain_recall", input))
if err != nil {
return nil, RecallOutput{}, err
}
return nil, RecallOutput{Sent: true, Timestamp: time.Now()}, nil
}
func (s *Subsystem) forget(_ context.Context, _ *mcp.CallToolRequest, input ForgetInput) (*mcp.CallToolResult, ForgetOutput, error) {
if s.bridge == nil {
return nil, ForgetOutput{}, errBridgeNotAvailable
}
err := s.bridge.Send(buildBridgeMessage("brain_forget", input))
if err != nil {
return nil, ForgetOutput{}, err
}
return nil, ForgetOutput{Sent: true, Timestamp: time.Now()}, nil
}
func (s *Subsystem) list(_ context.Context, _ *mcp.CallToolRequest, input ListInput) (*mcp.CallToolResult, ListOutput, error) {
if s.bridge == nil {
return nil, ListOutput{}, errBridgeNotAvailable
}
err := s.bridge.Send(buildBridgeMessage("brain_list", input))
if err != nil {
return nil, ListOutput{}, err
}
return nil, ListOutput{Sent: true, Timestamp: time.Now()}, nil
}
```
**Step 4: Run tests to verify they pass**
Run: `cd /Users/snider/Code/go-ai && go test ./mcp/brain/ -v`
Expected: PASS
**Step 5: Register subsystem in the MCP service**
Find where the IDE subsystem is registered (likely in the CLI or main entry point) and add brain alongside it:
```go
brainSub := brain.New(ideSub.Bridge())
mcpSvc, err := mcp.New(
mcp.WithSubsystem(ideSub),
mcp.WithSubsystem(brainSub),
)
```
**Step 6: Commit**
```bash
cd /Users/snider/Code/go-ai
git add mcp/brain/
git commit -m "feat(brain): add Go brain bridge subsystem for OpenBrain MCP tools"
```
---
### Task 8: MEMORY.md Migration Seed Script
**Files:**
- Create: `Console/Commands/BrainSeedFromMemoryFiles.php`
**Step 1: Write the artisan command**
```php
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class BrainSeedFromMemoryFiles extends Command
{
protected $signature = 'brain:seed-memory
{path? : Base path to scan for MEMORY.md files (default: ~/.claude/projects)}
{--workspace= : Workspace ID to assign memories to}
{--agent=virgil : Agent ID for these memories}
{--dry-run : Show what would be imported without importing}';
protected $description = 'Import MEMORY.md files from Claude Code worktrees into OpenBrain';
public function handle(BrainService $brainService): int
{
$basePath = $this->argument('path')
?? rtrim($_SERVER['HOME'] ?? '', '/').'/.claude/projects';
$workspaceId = $this->option('workspace');
if (! $workspaceId) {
$this->error('--workspace is required');
return self::FAILURE;
}
$agentId = $this->option('agent');
$dryRun = $this->option('dry-run');
$brainService->ensureCollection();
$files = $this->findMemoryFiles($basePath);
$this->info("Found ".count($files)." MEMORY.md files");
$imported = 0;
foreach ($files as $file) {
$content = File::get($file);
$projectName = $this->guessProject($file);
$sections = $this->parseSections($content);
foreach ($sections as $section) {
if (strlen(trim($section['content'])) < 20) {
continue;
}
if ($dryRun) {
$this->line("[DRY RUN] Would import: {$section['title']} (project: {$projectName})");
continue;
}
$memory = BrainMemory::create([
'workspace_id' => (int) $workspaceId,
'agent_id' => $agentId,
'type' => $this->guessType($section['title']),
'content' => "## {$section['title']}\n\n{$section['content']}",
'tags' => $this->extractTags($section['content']),
'project' => $projectName,
'confidence' => 0.8,
]);
$brainService->remember($memory);
$imported++;
$this->line("Imported: {$section['title']} (project: {$projectName})");
}
}
$this->info("Imported {$imported} memories into OpenBrain");
return self::SUCCESS;
}
private function findMemoryFiles(string $basePath): array
{
$files = [];
if (! is_dir($basePath)) {
return $files;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($basePath, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $file) {
if ($file->getFilename() === 'MEMORY.md' || Str::endsWith($file->getPathname(), '/memory/MEMORY.md')) {
$files[] = $file->getPathname();
}
}
return $files;
}
private function guessProject(string $filepath): ?string
{
if (preg_match('#/projects/-Users-\w+-Code-([^/]+)/#', $filepath, $m)) {
return $m[1];
}
return null;
}
private function guessType(string $title): string
{
$lower = strtolower($title);
if (Str::contains($lower, ['decision', 'chose', 'approach'])) {
return BrainMemory::TYPE_DECISION;
}
if (Str::contains($lower, ['architecture', 'stack', 'infrastructure'])) {
return BrainMemory::TYPE_ARCHITECTURE;
}
if (Str::contains($lower, ['convention', 'rule', 'standard', 'pattern'])) {
return BrainMemory::TYPE_CONVENTION;
}
if (Str::contains($lower, ['bug', 'fix', 'issue', 'error'])) {
return BrainMemory::TYPE_BUG;
}
if (Str::contains($lower, ['plan', 'todo', 'roadmap'])) {
return BrainMemory::TYPE_PLAN;
}
if (Str::contains($lower, ['research', 'finding', 'analysis'])) {
return BrainMemory::TYPE_RESEARCH;
}
return BrainMemory::TYPE_OBSERVATION;
}
private function extractTags(string $content): array
{
$tags = [];
// Extract backtick-quoted identifiers as potential tags
if (preg_match_all('/`([a-z][a-z0-9_-]+)`/', $content, $matches)) {
$tags = array_unique(array_slice($matches[1], 0, 10));
}
return array_values($tags);
}
private function parseSections(string $content): array
{
$sections = [];
$lines = explode("\n", $content);
$currentTitle = null;
$currentContent = [];
foreach ($lines as $line) {
if (preg_match('/^#{1,3}\s+(.+)$/', $line, $m)) {
if ($currentTitle !== null) {
$sections[] = [
'title' => $currentTitle,
'content' => trim(implode("\n", $currentContent)),
];
}
$currentTitle = $m[1];
$currentContent = [];
} else {
$currentContent[] = $line;
}
}
if ($currentTitle !== null) {
$sections[] = [
'title' => $currentTitle,
'content' => trim(implode("\n", $currentContent)),
];
}
return $sections;
}
}
```
**Step 2: Register the command**
In `Boot.php`, the `onConsole` method (or `ConsoleBooting` listener) should register:
```php
$this->commands([
\Core\Mod\Agentic\Console\Commands\BrainSeedFromMemoryFiles::class,
]);
```
**Step 3: Test with dry run**
Run: `php artisan brain:seed-memory --workspace=1 --dry-run`
Expected: Lists found MEMORY.md files and sections without importing
**Step 4: Commit**
```bash
cd /Users/snider/Code/php-agentic
git add Console/Commands/BrainSeedFromMemoryFiles.php Boot.php
git commit -m "feat(brain): add brain:seed-memory command for MEMORY.md migration"
```
---
## Summary
| Task | Component | Files | Commit |
|------|-----------|-------|--------|
| 1 | Migration + Model | 2 created | `feat(brain): add BrainMemory model and migration` |
| 2 | BrainService | 2 created | `feat(brain): add BrainService with Ollama + Qdrant` |
| 3 | brain_remember tool | 2 created | `feat(brain): add brain_remember MCP tool` |
| 4 | brain_recall tool | 2 created | `feat(brain): add brain_recall MCP tool` |
| 5 | brain_forget + brain_list | 4 created | `feat(brain): add brain_forget and brain_list MCP tools` |
| 6 | Registration + config | 2 modified | `feat(brain): register BrainService and brain tools` |
| 7 | Go bridge subsystem | 3 created | `feat(brain): add Go brain bridge subsystem` |
| 8 | MEMORY.md migration | 1 created, 1 modified | `feat(brain): add brain:seed-memory command` |
**Total: 18 files across 2 repos, 8 commits.**