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'); $workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id); $apiKey = $request->attributes->get('api_key') ?? $request->attributes->get('agent_api_key'); $agentId = $apiKey?->name ?? 'api'; try { $memory = RememberKnowledge::run($validated, $workspaceId, $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, BrainService $brain): JsonResponse { $validated = $request->validate([ 'query' => 'required|string|max:2000', 'limit' => 'nullable|integer|min:1|max:20', 'top_k' => 'nullable|integer|min:1|max:20', 'workspace_id' => 'nullable|integer|min:1', 'org' => 'nullable|string', 'project' => 'nullable|string', 'type' => 'nullable', 'keywords' => 'nullable|array', 'keywords.*' => 'string|max:255', 'boost_keywords' => 'nullable|array', 'boost_keywords.*' => 'string|max:255', 'filter' => 'nullable|array', 'filter.org' => 'nullable|string', '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'); $workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id ?? $validated['workspace_id'] ?? 0); $filter = $validated['filter'] ?? []; foreach (['org', 'project', 'type'] as $field) { if (array_key_exists($field, $validated) && $validated[$field] !== null) { $filter[$field] = $validated[$field]; } } try { $this->assertValidTypeFilter($filter['type'] ?? null); $result = $brain->recall( $validated['query'], $validated['limit'] ?? $validated['top_k'] ?? 5, $filter, $workspaceId, $this->normaliseStringList($validated['keywords'] ?? []), $this->normaliseStringList($validated['boost_keywords'] ?? []), ); $result['count'] = count($result['memories'] ?? []); 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); } } /** * @param array $values * @return array */ private function normaliseStringList(array $values): array { return array_values(array_filter(array_map( static fn (mixed $value): string => is_string($value) ? trim($value) : '', $values, ), static fn (string $value): bool => $value !== '')); } private function assertValidTypeFilter(mixed $type): void { if ($type === null) { return; } $validTypes = BrainMemory::VALID_TYPES; if (is_string($type)) { if (! in_array($type, $validTypes, true)) { throw new \InvalidArgumentException( sprintf('filter.type must be one of: %s', implode(', ', $validTypes)) ); } return; } if (is_array($type)) { foreach ($type as $value) { if (! is_string($value) || ! in_array($value, $validTypes, true)) { throw new \InvalidArgumentException( sprintf('Each filter.type value must be one of: %s', implode(', ', $validTypes)) ); } } return; } throw new \InvalidArgumentException( sprintf('filter.type must be one of: %s', implode(', ', $validTypes)) ); } /** * GET /v1/brain/search * * Full-text search across memories. */ public function search(Request $request): JsonResponse { $validated = $request->validate([ 'q' => 'required|string|max:2000', 'org' => 'nullable|string|max:255', 'project' => 'nullable|string|max:255', 'limit' => 'nullable|integer|min:1|max:100', ]); $workspace = $request->attributes->get('workspace'); $workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id); $limit = min(max((int) ($validated['limit'] ?? 20), 1), 100); $filters = [ 'workspace_id' => $workspaceId, ]; foreach (['org', 'project'] as $field) { if (isset($validated[$field]) && $validated[$field] !== '') { $filters[$field] = $validated[$field]; } } try { $brain = app(BrainService::class); $result = $brain->elasticSearch($validated['q'], $filters); $hits = $result['hits']['hits'] ?? []; if (! is_array($hits)) { $hits = []; } $hitData = $this->normaliseSearchHits(array_slice($hits, 0, $limit)); if ($hitData['ids'] === []) { return response()->json([ 'data' => [ 'memories' => [], 'count' => 0, ], ]); } $query = BrainMemory::whereIn('id', $hitData['ids']) ->forWorkspace($workspaceId) ->active() ->latestVersions(); if (isset($filters['project'])) { $query->forProject((string) $filters['project']); } $memoryMap = $query->get()->keyBy('id'); $memories = []; foreach ($hitData['ids'] as $id) { $memory = $memoryMap->get($id); if ($memory instanceof BrainMemory) { $memories[] = $memory->toMcpContext((float) ($hitData['scores'][$id] ?? 0.0)); } } return response()->json([ 'data' => [ 'memories' => $memories, 'count' => count($memories), ], ]); } 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'); $workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id); $apiKey = $request->attributes->get('api_key') ?? $request->attributes->get('agent_api_key'); $agentId = $apiKey?->name ?? 'api'; try { $result = ForgetKnowledge::run($id, $workspaceId, $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'); $workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id); try { $result = ListKnowledge::run($workspaceId, $validated); return response()->json([ 'data' => $result, ]); } catch (\InvalidArgumentException $e) { return response()->json([ 'error' => 'validation_error', 'message' => $e->getMessage(), ], 422); } } /** * GET /v1/brain/tags * * List distinct memory tags and document counts. */ public function tags(BrainService $brain): JsonResponse { try { $result = $brain->elasticAggregate([ 'size' => 0, 'aggs' => [ 'tags' => [ 'terms' => [ 'field' => 'tags.keyword', 'size' => 1000, ], ], ], ]); $tags = []; $buckets = $result['aggregations']['tags']['buckets'] ?? []; if (is_array($buckets)) { foreach ($buckets as $bucket) { if (! is_array($bucket) || ! is_string($bucket['key'] ?? null)) { continue; } $tags[$bucket['key']] = (int) ($bucket['doc_count'] ?? 0); } } return response()->json([ 'data' => $tags, ]); } catch (\RuntimeException $e) { return response()->json([ 'error' => 'service_error', 'message' => 'Brain service temporarily unavailable.', ], 503); } } /** * GET /v1/brain/scopes * * List distinct organisation/project memory scopes. */ public function scopes(BrainService $brain): JsonResponse { try { $result = $brain->elasticAggregate([ 'size' => 0, 'aggs' => [ 'scopes' => [ 'composite' => [ 'size' => 1000, 'sources' => [ [ 'org' => [ 'terms' => [ 'field' => 'org.keyword', ], ], ], [ 'project' => [ 'terms' => [ 'field' => 'project.keyword', ], ], ], ], ], ], ], ]); $scopes = []; $buckets = $result['aggregations']['scopes']['buckets'] ?? []; if (is_array($buckets)) { foreach ($buckets as $bucket) { $key = is_array($bucket) ? ($bucket['key'] ?? null) : null; if (! is_array($key) || ! is_string($key['org'] ?? null) || ! is_string($key['project'] ?? null)) { continue; } $scopes[$key['org']][$key['project']] = (int) ($bucket['doc_count'] ?? 0); } } return response()->json([ 'data' => $scopes, ]); } catch (\RuntimeException $e) { return response()->json([ 'error' => 'service_error', 'message' => 'Brain service temporarily unavailable.', ], 503); } } /** * @param array $hits * @return array{ids: array, scores: array} */ private function normaliseSearchHits(array $hits): array { $ids = []; $scores = []; foreach ($hits as $hit) { if (! is_array($hit)) { continue; } $id = $hit['_id'] ?? ($hit['_source']['id'] ?? null); if (! is_string($id) || $id === '' || in_array($id, $ids, true)) { continue; } $ids[] = $id; $scores[$id] = (float) ($hit['_score'] ?? 0.0); } return [ 'ids' => $ids, 'scores' => $scores, ]; } }