feat: extract Brain operations into CorePHP Actions + API routes
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s

- Create 4 Actions in Actions/Brain/ (RememberKnowledge, RecallKnowledge,
  ForgetKnowledge, ListKnowledge) using the Action trait pattern
- Slim MCP tool handlers to thin wrappers calling Actions
- Add BrainController with REST endpoints (remember, recall, forget, list)
- Add API route file with api.auth + api.scope.enforce middleware
- Wire ApiRoutesRegistering in Boot.php
- Rename routes/ → Routes/ to match CorePHP convention
- Remove empty database/migrations/ (legacy Laravel boilerplate)

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-04 12:15:13 +00:00
parent 8bc6e62f13
commit 8b8a9c26e5
16 changed files with 515 additions and 141 deletions

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Brain;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Support\Facades\Log;
/**
* Remove a memory from the shared OpenBrain knowledge store.
*
* Deletes from both MariaDB and Qdrant. Workspace-scoped.
*
* Usage:
* ForgetKnowledge::run('uuid-here', 1, 'virgil', 'outdated info');
*/
class ForgetKnowledge
{
use Action;
public function __construct(
private BrainService $brain,
) {}
/**
* @return array{forgotten: string, type: string}
*
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/
public function handle(string $id, int $workspaceId, string $agentId = 'anonymous', ?string $reason = null): array
{
if ($id === '') {
throw new \InvalidArgumentException('id is required');
}
$memory = BrainMemory::where('id', $id)
->where('workspace_id', $workspaceId)
->first();
if (! $memory) {
throw new \InvalidArgumentException("Memory '{$id}' not found in this workspace");
}
Log::info('OpenBrain: memory forgotten', [
'id' => $id,
'type' => $memory->type,
'agent_id' => $agentId,
'reason' => $reason,
]);
$this->brain->forget($id);
return [
'forgotten' => $id,
'type' => $memory->type,
];
}
}

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Brain;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\BrainMemory;
use Illuminate\Support\Collection;
/**
* List memories in the shared OpenBrain knowledge store.
*
* Pure MariaDB query using model scopes no vector search.
* Use RecallKnowledge for semantic queries.
*
* Usage:
* $memories = ListKnowledge::run(1, ['type' => 'decision']);
*/
class ListKnowledge
{
use Action;
/**
* @param array{project?: string, type?: string, agent_id?: string, limit?: int} $filter
* @return array{memories: array, count: int}
*/
public function handle(int $workspaceId, array $filter = []): array
{
$limit = min(max((int) ($filter['limit'] ?? 20), 1), 100);
$query = BrainMemory::forWorkspace($workspaceId)
->active()
->latestVersions()
->forProject($filter['project'] ?? null)
->byAgent($filter['agent_id'] ?? null);
$type = $filter['type'] ?? null;
if ($type !== null) {
if (is_string($type) && ! in_array($type, BrainMemory::VALID_TYPES, true)) {
throw new \InvalidArgumentException(
sprintf('type must be one of: %s', implode(', ', BrainMemory::VALID_TYPES))
);
}
$query->ofType($type);
}
$memories = $query->orderByDesc('created_at')
->limit($limit)
->get();
return [
'memories' => $memories->map(fn (BrainMemory $m) => $m->toMcpContext())->all(),
'count' => $memories->count(),
];
}
}

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Brain;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
/**
* Semantic search across the shared OpenBrain knowledge store.
*
* Uses vector similarity to find memories relevant to a natural
* language query, with optional filtering by project, type, agent,
* or minimum confidence.
*
* Usage:
* $results = RecallKnowledge::run('how does auth work?', 1);
*/
class RecallKnowledge
{
use Action;
public function __construct(
private BrainService $brain,
) {}
/**
* @param array{project?: string, type?: string|array, agent_id?: string, min_confidence?: float} $filter
* @return array{memories: array, scores: array<string, float>, count: int}
*
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/
public function handle(string $query, int $workspaceId, array $filter = [], int $topK = 5): array
{
if ($query === '') {
throw new \InvalidArgumentException('query is required and must be a non-empty string');
}
if (mb_strlen($query) > 2000) {
throw new \InvalidArgumentException('query must not exceed 2,000 characters');
}
if ($topK < 1 || $topK > 20) {
throw new \InvalidArgumentException('top_k must be between 1 and 20');
}
if (isset($filter['type'])) {
$typeValue = $filter['type'];
$validTypes = BrainMemory::VALID_TYPES;
if (is_string($typeValue)) {
if (! in_array($typeValue, $validTypes, true)) {
throw new \InvalidArgumentException(
sprintf('filter.type must be one of: %s', implode(', ', $validTypes))
);
}
} elseif (is_array($typeValue)) {
foreach ($typeValue as $t) {
if (! is_string($t) || ! in_array($t, $validTypes, true)) {
throw new \InvalidArgumentException(
sprintf('Each filter.type value must be one of: %s', implode(', ', $validTypes))
);
}
}
}
}
if (isset($filter['min_confidence'])) {
$mc = $filter['min_confidence'];
if (! is_numeric($mc) || $mc < 0.0 || $mc > 1.0) {
throw new \InvalidArgumentException('filter.min_confidence must be between 0.0 and 1.0');
}
}
$result = $this->brain->recall($query, $topK, $filter, $workspaceId);
return [
'memories' => $result['memories'],
'scores' => $result['scores'],
'count' => count($result['memories']),
];
}
}

View file

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Brain;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
/**
* Store a memory in the shared OpenBrain knowledge store.
*
* Persists content with embeddings to both MariaDB and Qdrant.
* Handles supersession (replacing old memories) and expiry.
*
* Usage:
* $memory = RememberKnowledge::run($data, 1, 'virgil');
*/
class RememberKnowledge
{
use Action;
public function __construct(
private BrainService $brain,
) {}
/**
* @param array{content: string, type: string, tags?: array, project?: string, confidence?: float, supersedes?: string, expires_in?: int} $data
* @return BrainMemory The created memory
*
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/
public function handle(array $data, int $workspaceId, string $agentId = 'anonymous'): BrainMemory
{
$content = $data['content'] ?? null;
if (! is_string($content) || $content === '') {
throw new \InvalidArgumentException('content is required and must be a non-empty string');
}
if (mb_strlen($content) > 50000) {
throw new \InvalidArgumentException('content must not exceed 50,000 characters');
}
$type = $data['type'] ?? null;
if (! is_string($type) || ! in_array($type, BrainMemory::VALID_TYPES, true)) {
throw new \InvalidArgumentException(
sprintf('type must be one of: %s', implode(', ', BrainMemory::VALID_TYPES))
);
}
$confidence = (float) ($data['confidence'] ?? 0.8);
if ($confidence < 0.0 || $confidence > 1.0) {
throw new \InvalidArgumentException('confidence must be between 0.0 and 1.0');
}
$tags = $data['tags'] ?? null;
if (is_array($tags)) {
foreach ($tags as $tag) {
if (! is_string($tag)) {
throw new \InvalidArgumentException('Each tag must be a string');
}
}
}
$supersedes = $data['supersedes'] ?? null;
if ($supersedes !== null) {
$existing = BrainMemory::where('id', $supersedes)
->where('workspace_id', $workspaceId)
->first();
if (! $existing) {
throw new \InvalidArgumentException("Memory '{$supersedes}' not found in this workspace");
}
}
$expiresIn = isset($data['expires_in']) ? (int) $data['expires_in'] : null;
if ($expiresIn !== null && $expiresIn < 1) {
throw new \InvalidArgumentException('expires_in must be at least 1 hour');
}
return $this->brain->remember([
'workspace_id' => $workspaceId,
'agent_id' => $agentId,
'type' => $type,
'content' => $content,
'tags' => $tags,
'project' => $data['project'] ?? null,
'confidence' => $confidence,
'supersedes_id' => $supersedes,
'expires_at' => $expiresIn ? now()->addHours($expiresIn) : null,
]);
}
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Core\Mod\Agentic;
use Core\Events\AdminPanelBooting;
use Core\Events\ApiRoutesRegistering;
use Core\Events\ConsoleBooting;
use Core\Events\McpToolsRegistering;
use Illuminate\Cache\RateLimiting\Limit;
@ -24,6 +25,7 @@ class Boot extends ServiceProvider
*/
public static array $listens = [
AdminPanelBooting::class => 'onAdminPanel',
ApiRoutesRegistering::class => 'onApiRoutes',
ConsoleBooting::class => 'onConsole',
McpToolsRegistering::class => 'onMcpTools',
];
@ -134,6 +136,14 @@ class Boot extends ServiceProvider
// in the existing boot() method until we migrate to pure event-driven nav.
}
/**
* Handle API routes registration event.
*/
public function onApiRoutes(ApiRoutesRegistering $event): void
{
$event->routes(fn () => require __DIR__.'/Routes/api.php');
}
/**
* Handle console booting event.
*/

View file

@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Agentic\Actions\Brain\ForgetKnowledge;
use Core\Mod\Agentic\Actions\Brain\ListKnowledge;
use Core\Mod\Agentic\Actions\Brain\RecallKnowledge;
use Core\Mod\Agentic\Actions\Brain\RememberKnowledge;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BrainController extends Controller
{
/**
* POST /api/brain/remember
*
* Store a memory in OpenBrain.
*/
public function remember(Request $request): JsonResponse
{
$validated = $request->validate([
'content' => 'required|string|max:50000',
'type' => 'required|string',
'tags' => 'nullable|array',
'tags.*' => 'string',
'project' => 'nullable|string|max:255',
'confidence' => 'nullable|numeric|min:0|max:1',
'supersedes' => 'nullable|uuid',
'expires_in' => 'nullable|integer|min:1',
]);
$workspace = $request->attributes->get('workspace');
$apiKey = $request->attributes->get('api_key');
$agentId = $apiKey?->name ?? 'api';
try {
$memory = RememberKnowledge::run($validated, $workspace->id, $agentId);
return response()->json([
'data' => $memory->toMcpContext(),
], 201);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'validation_error',
'message' => $e->getMessage(),
], 422);
} catch (\RuntimeException $e) {
return response()->json([
'error' => 'service_error',
'message' => 'Brain service temporarily unavailable.',
], 503);
}
}
/**
* POST /api/brain/recall
*
* Semantic search across memories.
*/
public function recall(Request $request): JsonResponse
{
$validated = $request->validate([
'query' => 'required|string|max:2000',
'top_k' => 'nullable|integer|min:1|max:20',
'filter' => 'nullable|array',
'filter.project' => 'nullable|string',
'filter.type' => 'nullable',
'filter.agent_id' => 'nullable|string',
'filter.min_confidence' => 'nullable|numeric|min:0|max:1',
]);
$workspace = $request->attributes->get('workspace');
try {
$result = RecallKnowledge::run(
$validated['query'],
$workspace->id,
$validated['filter'] ?? [],
$validated['top_k'] ?? 5,
);
return response()->json([
'data' => $result,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'validation_error',
'message' => $e->getMessage(),
], 422);
} catch (\RuntimeException $e) {
return response()->json([
'error' => 'service_error',
'message' => 'Brain service temporarily unavailable.',
], 503);
}
}
/**
* DELETE /api/brain/forget/{id}
*
* Remove a memory.
*/
public function forget(Request $request, string $id): JsonResponse
{
$request->validate([
'reason' => 'nullable|string|max:500',
]);
$workspace = $request->attributes->get('workspace');
$apiKey = $request->attributes->get('api_key');
$agentId = $apiKey?->name ?? 'api';
try {
$result = ForgetKnowledge::run($id, $workspace->id, $agentId, $request->input('reason'));
return response()->json([
'data' => $result,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
} catch (\RuntimeException $e) {
return response()->json([
'error' => 'service_error',
'message' => 'Brain service temporarily unavailable.',
], 503);
}
}
/**
* GET /api/brain/list
*
* List memories with optional filters.
*/
public function list(Request $request): JsonResponse
{
$validated = $request->validate([
'project' => 'nullable|string',
'type' => 'nullable|string',
'agent_id' => 'nullable|string',
'limit' => 'nullable|integer|min:1|max:100',
]);
$workspace = $request->attributes->get('workspace');
try {
$result = ListKnowledge::run($workspace->id, $validated);
return response()->json([
'data' => $result,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'validation_error',
'message' => $e->getMessage(),
], 422);
}
}
}

View file

@ -5,10 +5,9 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Brain\ForgetKnowledge;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Support\Facades\Log;
/**
* Remove a memory from the shared OpenBrain knowledge store.
@ -61,43 +60,19 @@ class BrainForget extends AgentTool
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. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai');
}
$id = $args['id'] ?? '';
$reason = $this->optionalString($args, 'reason', null, 500);
$agentId = $context['agent_id'] ?? $context['session_id'] ?? 'anonymous';
return $this->withCircuitBreaker('brain', function () use ($id, $workspaceId, $reason, $agentId) {
// Verify memory exists and belongs to this workspace
$memory = BrainMemory::where('id', $id)
->where('workspace_id', $workspaceId)
->first();
return $this->withCircuitBreaker('brain', function () use ($id, $workspaceId, $agentId, $reason) {
$result = ForgetKnowledge::run($id, (int) $workspaceId, $agentId, $reason);
if (! $memory) {
return $this->error("Memory '{$id}' not found in this workspace");
}
Log::info('OpenBrain: memory forgotten', [
'id' => $id,
'type' => $memory->type,
'agent_id' => $agentId,
'reason' => $reason,
]);
app(BrainService::class)->forget($id);
return $this->success([
'forgotten' => $id,
'type' => $memory->type,
]);
return $this->success($result);
}, fn () => $this->error('Brain service temporarily unavailable. Memory could not be removed.', 'service_unavailable'));
}
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Brain\ListKnowledge;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
@ -73,28 +74,8 @@ class BrainList extends AgentTool
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai');
}
$project = $this->optionalString($args, 'project');
$type = $this->optionalEnum($args, 'type', BrainMemory::VALID_TYPES);
$agentId = $this->optionalString($args, 'agent_id');
$limit = $this->optionalInt($args, 'limit', 20, 1, 100);
$result = ListKnowledge::run((int) $workspaceId, $args);
$query = BrainMemory::forWorkspace((int) $workspaceId)
->active()
->latestVersions()
->forProject($project)
->byAgent($agentId);
if ($type !== null) {
$query->ofType($type);
}
$memories = $query->orderByDesc('created_at')
->limit($limit)
->get();
return $this->success([
'count' => $memories->count(),
'memories' => $memories->map(fn (BrainMemory $m) => $m->toMcpContext())->all(),
]);
return $this->success($result);
}
}

View file

@ -5,9 +5,9 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Brain\RecallKnowledge;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
/**
* Semantic search across the shared OpenBrain knowledge store.
@ -93,17 +93,12 @@ class BrainRecall extends AgentTool
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. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai');
}
$query = $args['query'] ?? '';
$topK = $this->optionalInt($args, 'top_k', 5, 1, 20);
$filter = $this->optional($args, 'filter', []);
@ -111,37 +106,11 @@ class BrainRecall extends AgentTool
return $this->error('filter must be an object');
}
// Validate filter type values if present
if (isset($filter['type'])) {
$typeValue = $filter['type'];
$validTypes = BrainMemory::VALID_TYPES;
if (is_string($typeValue)) {
if (! in_array($typeValue, $validTypes, true)) {
return $this->error(sprintf('filter.type must be one of: %s', implode(', ', $validTypes)));
}
} elseif (is_array($typeValue)) {
foreach ($typeValue as $t) {
if (! is_string($t) || ! in_array($t, $validTypes, true)) {
return $this->error(sprintf('Each filter.type value must be one of: %s', implode(', ', $validTypes)));
}
}
} else {
return $this->error('filter.type must be a string or array of strings');
}
}
if (isset($filter['min_confidence'])) {
if (! is_numeric($filter['min_confidence']) || $filter['min_confidence'] < 0.0 || $filter['min_confidence'] > 1.0) {
return $this->error('filter.min_confidence must be a number between 0.0 and 1.0');
}
}
return $this->withCircuitBreaker('brain', function () use ($query, $topK, $filter, $workspaceId) {
$result = app(BrainService::class)->recall($query, $topK, $filter, (int) $workspaceId);
return $this->withCircuitBreaker('brain', function () use ($query, $workspaceId, $filter, $topK) {
$result = RecallKnowledge::run($query, (int) $workspaceId, $filter, $topK);
return $this->success([
'count' => count($result['memories']),
'count' => $result['count'],
'memories' => $result['memories'],
'scores' => $result['scores'],
]);

View file

@ -5,9 +5,9 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Brain\RememberKnowledge;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
/**
* Store a memory in the shared OpenBrain knowledge store.
@ -85,62 +85,15 @@ class BrainRemember extends AgentTool
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. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai');
}
$agentId = $context['agent_id'] ?? $context['session_id'] ?? 'anonymous';
$tags = $this->optional($args, 'tags');
$project = $this->optionalString($args, 'project');
$supersedes = $this->optionalString($args, 'supersedes');
$expiresIn = $this->optionalInt($args, 'expires_in', null, 1);
$confidence = $this->optional($args, 'confidence', 0.8);
if (! is_numeric($confidence) || $confidence < 0.0 || $confidence > 1.0) {
return $this->error('confidence must be a number between 0.0 and 1.0');
}
$confidence = (float) $confidence;
if (is_array($tags)) {
foreach ($tags as $tag) {
if (! is_string($tag)) {
return $this->error('Each tag must be a string');
}
}
}
if ($supersedes !== null) {
$existing = BrainMemory::where('id', $supersedes)
->where('workspace_id', $workspaceId)
->first();
if (! $existing) {
return $this->error("Memory '{$supersedes}' not found in this workspace");
}
}
return $this->withCircuitBreaker('brain', function () use (
$content, $type, $workspaceId, $agentId, $tags, $project, $confidence, $supersedes, $expiresIn
) {
$memory = app(BrainService::class)->remember([
'workspace_id' => $workspaceId,
'agent_id' => $agentId,
'type' => $type,
'content' => $content,
'tags' => $tags,
'project' => $project,
'confidence' => $confidence,
'supersedes_id' => $supersedes,
'expires_at' => $expiresIn ? now()->addHours($expiresIn) : null,
]);
return $this->withCircuitBreaker('brain', function () use ($args, $workspaceId, $agentId) {
$memory = RememberKnowledge::run($args, (int) $workspaceId, $agentId);
return $this->success([
'memory' => $memory->toMcpContext(),

27
Routes/api.php Normal file
View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use Core\Mod\Agentic\Controllers\Api\BrainController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Agentic API Routes
|--------------------------------------------------------------------------
|
| Brain (OpenBrain knowledge store) endpoints.
| Auto-wrapped with 'api' middleware and /api prefix by ApiRoutesRegistering.
|
*/
Route::middleware(['api.auth', 'api.scope.enforce'])
->prefix('brain')
->name('brain.')
->group(function () {
Route::post('remember', [BrainController::class, 'remember'])->name('remember');
Route::post('recall', [BrainController::class, 'recall'])->name('recall');
Route::delete('forget/{id}', [BrainController::class, 'forget'])->name('forget')
->where('id', '[0-9a-f-]+');
Route::get('list', [BrainController::class, 'list'])->name('list');
});

View file

@ -1,3 +0,0 @@
<?php
// API routes are registered via Core modules