From 4fcf21f328809e71581f7f68b14ed9f6ab8a85b8 Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Sat, 4 Apr 2026 15:29:53 -0700 Subject: [PATCH 1/6] feat: add --collection flag for cross-collection Qdrant search Allows searching any Qdrant collection directly, bypassing the knowledge_ prefix and metadata mapping. Enables querying external collections like music_events from the know CLI. Usage: know search "punk rock" --collection=music_events --- app/Commands/KnowledgeSearchCommand.php | 45 ++++++++++++++++++++++++- app/Services/QdrantService.php | 28 +++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/app/Commands/KnowledgeSearchCommand.php b/app/Commands/KnowledgeSearchCommand.php index 57487ca..7bbd310 100644 --- a/app/Commands/KnowledgeSearchCommand.php +++ b/app/Commands/KnowledgeSearchCommand.php @@ -27,7 +27,8 @@ class KnowledgeSearchCommand extends Command {--semantic : Use semantic search if available} {--include-superseded : Include superseded entries in results} {--project= : Override project namespace} - {--global : Search across all projects}'; + {--global : Search across all projects} + {--collection= : Search a raw Qdrant collection directly (bypasses knowledge_ prefix)}'; /** * @var string @@ -69,6 +70,12 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i // Use project-aware search $searchQuery = is_string($query) ? $query : ''; + // Raw collection search — bypasses knowledge_ prefix and metadata formatting + $rawCollection = $this->option('collection'); + if (is_string($rawCollection) && $rawCollection !== '') { + return $this->searchRawCollection($qdrant, $rawCollection, $searchQuery, $limit); + } + if ($this->isGlobal()) { $collections = $qdrant->listCollections(); $results = collect(); @@ -147,4 +154,40 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i return self::SUCCESS; } + + private function searchRawCollection(QdrantService $qdrant, string $collection, string $query, int $limit): int + { + $results = $qdrant->searchRawCollection($collection, $query, $limit); + + if ($results->isEmpty()) { + $this->line('No results found.'); + + return self::SUCCESS; + } + + $this->info("Found {$results->count()} ".str('result')->plural($results->count())." in {$collection}"); + $this->newLine(); + + foreach ($results as $result) { + $score = $result['score'] ?? 0.0; + $payload = $result['payload'] ?? []; + + $this->line(''.number_format($score, 3).' | '.($payload['description'] ?? json_encode($payload))); + + // Show key payload fields + $fields = collect($payload) + ->except(['description', 'vector']) + ->filter(fn ($v): bool => $v !== null && $v !== '') + ->map(fn ($v, $k): string => "{$k}: {$v}") + ->implode(' | '); + + if ($fields !== '') { + $this->line(" {$fields}"); + } + + $this->newLine(); + } + + return self::SUCCESS; + } } diff --git a/app/Services/QdrantService.php b/app/Services/QdrantService.php index eadf9c7..cdd86d7 100644 --- a/app/Services/QdrantService.php +++ b/app/Services/QdrantService.php @@ -977,4 +977,32 @@ public function getCollectionName(string $project): string { return 'knowledge_'.str_replace(['/', '\\', ' '], '_', $project); } + + /** + * Search any Qdrant collection by name — no knowledge_ prefix, no metadata mapping. + * + * @return Collection}> + */ + public function searchRawCollection(string $collection, string $query, int $limit = 10): Collection + { + $queryVector = $this->getCachedEmbedding($query); + + if ($queryVector === []) { + return collect(); + } + + $response = $this->connector->send( + new SearchPoints($collection, $queryVector, $limit, 0.0) + ); + + if (! $response->successful()) { + return collect(); + } + + return collect($response->json('result') ?? [])->map(fn (array $r): array => [ + 'id' => $r['id'], + 'score' => $r['score'] ?? 0.0, + 'payload' => $r['payload'] ?? [], + ]); + } } From 8c4e36ba62a0652585a2c098a9205963b03fd75e Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Sat, 4 Apr 2026 17:04:32 -0700 Subject: [PATCH 2/6] test: add searchRawCollection tests for cross-collection search --- app/Commands/AnthropicImportCommand.php | 475 ++++++++++++++++++++++ app/Commands/AnthropicStatusCommand.php | 88 ++++ tests/Unit/Services/QdrantServiceTest.php | 59 +++ 3 files changed, 622 insertions(+) create mode 100644 app/Commands/AnthropicImportCommand.php create mode 100644 app/Commands/AnthropicStatusCommand.php diff --git a/app/Commands/AnthropicImportCommand.php b/app/Commands/AnthropicImportCommand.php new file mode 100644 index 0000000..466671c --- /dev/null +++ b/app/Commands/AnthropicImportCommand.php @@ -0,0 +1,475 @@ + */ + private array $embedServers = []; + + private int $serverIndex = 0; + + public function handle(): int + { + // Anthropic exports can be 300MB+ JSON — need headroom + ini_set('memory_limit', '1G'); + + $file = $this->argument('file'); + $collection = $this->option('collection'); + + if (! file_exists($file)) { + error("File not found: {$file}"); + + return self::FAILURE; + } + + $this->qdrant = new QdrantConnector( + host: config('search.qdrant.host', 'localhost'), + port: (int) config('search.qdrant.port', 6333), + apiKey: config('search.qdrant.api_key'), + ); + + // Discover embedding servers + if (! $this->option('dry-run')) { + $this->embedServers = $this->discoverServers(); + if (empty($this->embedServers)) { + error('No embedding servers found on ports '.implode(', ', self::EMBED_PORTS)); + + return self::FAILURE; + } + info(count($this->embedServers).' embedding server(s) discovered'); + } + + // Ensure collection + $this->ensureCollection($collection); + + // Extract and parse + $conversations = spin( + fn () => $this->extractConversations($file), + 'Extracting conversations...' + ); + + info(count($conversations).' conversations in dump'); + + // Deduplicate + $existingUuids = spin( + fn () => $this->getExistingConversationUuids($collection), + 'Checking for existing conversations...' + ); + + $newConversations = array_filter( + $conversations, + fn (array $c) => ! isset($existingUuids[$c['uuid'] ?? '']) + && ! empty($c['chat_messages']) + ); + $newConversations = array_values($newConversations); + + $skipped = count($conversations) - count($newConversations); + info(count($newConversations)." new conversations ({$skipped} already imported)"); + + if (empty($newConversations)) { + info('Nothing new to import!'); + + return self::SUCCESS; + } + + // Prepare chunks + $chunks = $this->prepareChunks($newConversations); + info(count($chunks).' chunks prepared'); + + if ($this->option('dry-run')) { + $this->showDryRun($newConversations, $chunks); + + return self::SUCCESS; + } + + // Embed and ingest + $this->ingest($chunks, $collection); + + return self::SUCCESS; + } + + /** + * @return list + */ + private function discoverServers(): array + { + $servers = []; + $client = new Client(['timeout' => 5, 'connect_timeout' => 2, 'http_errors' => false]); + + foreach (self::EMBED_PORTS as $port) { + try { + $response = $client->post("http://localhost:{$port}/embed", [ + 'json' => ['texts' => ['test']], + ]); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody(), true); + $dim = count($data['embeddings'][0] ?? []); + if ($dim === self::VECTOR_SIZE) { + $servers[] = "http://localhost:{$port}"; + } else { + warning("Port {$port}: wrong dimension ({$dim})"); + } + } + } catch (\Throwable) { + // Server not available + } + } + + return $servers; + } + + private function nextServer(): string + { + $server = $this->embedServers[$this->serverIndex % count($this->embedServers)]; + $this->serverIndex++; + + return $server; + } + + private function ensureCollection(string $collection): void + { + $response = $this->qdrant->send(new GetCollectionInfo($collection)); + + if ($response->successful()) { + return; + } + + $this->qdrant->send(new CreateCollection($collection, self::VECTOR_SIZE)); + info("Created collection '{$collection}'"); + } + + /** + * @return array + */ + private function getExistingConversationUuids(string $collection): array + { + $existing = []; + $offset = null; + + while (true) { + $response = $this->qdrant->send( + new ScrollPoints($collection, 250, null, $offset) + ); + + if (! $response->successful()) { + break; + } + + $data = $response->json(); + $points = $data['result']['points'] ?? []; + + if (empty($points)) { + break; + } + + foreach ($points as $point) { + $uuid = $point['payload']['conversation_uuid'] ?? ''; + if ($uuid !== '') { + $existing[$uuid] = true; + } + } + + $offset = $data['result']['next_page_offset'] ?? null; + if ($offset === null) { + break; + } + } + + return $existing; + } + + /** + * @return list}> + */ + private function extractConversations(string $zipPath): array + { + $tmpDir = sys_get_temp_dir().'/anthropic-import-'.Str::random(8); + mkdir($tmpDir, 0755, true); + + $zip = new \ZipArchive; + $zip->open($zipPath); + $zip->extractTo($tmpDir); + $zip->close(); + + $convFile = $tmpDir.'/conversations.json'; + if (! file_exists($convFile)) { + error('conversations.json not found in zip'); + + return []; + } + + $conversations = json_decode(file_get_contents($convFile), true); + + // Cleanup + array_map('unlink', glob($tmpDir.'/*')); + rmdir($tmpDir); + + return $conversations; + } + + /** + * @return list}> + */ + private function prepareChunks(array $conversations): array + { + $chunks = []; + + foreach ($conversations as $conv) { + $convUuid = $conv['uuid'] ?? ''; + $convName = $conv['name'] ?? 'Untitled'; + $convCreated = $conv['created_at'] ?? ''; + + foreach ($conv['chat_messages'] ?? [] as $mi => $msg) { + $text = $this->extractText($msg); + if (strlen(trim($text)) < 20) { + continue; + } + + foreach ($this->chunkText($text) as $ci => $chunk) { + $chunks[] = [ + 'text' => $chunk, + 'payload' => [ + 'conversation_uuid' => $convUuid, + 'conversation_name' => $convName, + 'conversation_created_at' => $convCreated, + 'sender' => $msg['sender'] ?? 'unknown', + 'turn_index' => $mi, + 'chunk_index' => $ci, + 'message_created_at' => $msg['created_at'] ?? '', + 'text' => $chunk, + ], + ]; + } + } + } + + return $chunks; + } + + private function extractText(array $message): string + { + if (! empty($message['text'])) { + return $message['text']; + } + + $parts = []; + foreach ($message['content'] ?? [] as $block) { + if (is_array($block) && ($block['type'] ?? '') === 'text') { + $parts[] = $block['text'] ?? ''; + } + } + + return implode("\n", $parts); + } + + /** + * @return list + */ + private function chunkText(string $text): array + { + if ($text === '' || strlen($text) <= self::MAX_CHUNK_CHARS) { + return $text !== '' ? [$text] : []; + } + + $chunks = []; + $remaining = $text; + + while ($remaining !== '') { + if (strlen($remaining) <= self::MAX_CHUNK_CHARS) { + $chunks[] = $remaining; + break; + } + + $splitAt = self::MAX_CHUNK_CHARS; + foreach (["\n\n", "\n", '. ', ' '] as $sep) { + $idx = strrpos(substr($remaining, 0, self::MAX_CHUNK_CHARS), $sep); + if ($idx !== false && $idx > self::MAX_CHUNK_CHARS / 3) { + $splitAt = $idx + strlen($sep); + break; + } + } + + $chunk = trim(substr($remaining, 0, $splitAt)); + if ($chunk !== '') { + $chunks[] = $chunk; + } + $remaining = trim(substr($remaining, $splitAt)); + } + + return $chunks; + } + + /** + * @param list $texts + * @return list|null> + */ + private function embedBatch(array $texts, string $server): array + { + $client = new Client(['timeout' => 60, 'connect_timeout' => 5, 'http_errors' => false]); + + try { + $response = $client->post("{$server}/embed", [ + 'json' => ['texts' => $texts], + ]); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody(), true); + + return $data['embeddings'] ?? array_fill(0, count($texts), null); + } + } catch (\Throwable) { + // Fall through + } + + // Fallback: try other servers + foreach ($this->embedServers as $fallback) { + if ($fallback === $server) { + continue; + } + + try { + $response = $client->post("{$fallback}/embed", [ + 'json' => ['texts' => $texts], + ]); + + if ($response->getStatusCode() === 200) { + return json_decode((string) $response->getBody(), true)['embeddings']; + } + } catch (\Throwable) { + continue; + } + } + + return array_fill(0, count($texts), null); + } + + private function ingest(array $chunks, string $collection): void + { + $batchSize = (int) $this->option('batch-size'); + $concurrency = (int) $this->option('concurrency'); + $totalWorkers = $concurrency * count($this->embedServers); + + info("Embedding with {$totalWorkers} workers across ".count($this->embedServers).' servers...'); + + $batches = array_chunk($chunks, $batchSize); + $pointsBuffer = []; + $totalPoints = 0; + $failed = 0; + + $progressBar = $this->output->createProgressBar(count($chunks)); + $progressBar->start(); + + // Process batches sequentially with round-robin across servers + // Using pools of $totalWorkers concurrent requests + $batchQueue = $batches; + while (! empty($batchQueue)) { + $currentBatch = array_splice($batchQueue, 0, $totalWorkers); + $results = []; + + // Embed each sub-batch + foreach ($currentBatch as $batch) { + $texts = array_column($batch, 'text'); + $server = $this->nextServer(); + $embeddings = $this->embedBatch($texts, $server); + + foreach ($batch as $i => $chunk) { + $vector = $embeddings[$i] ?? null; + if ($vector !== null && count($vector) === self::VECTOR_SIZE) { + $pointsBuffer[] = [ + 'id' => (string) Str::uuid(), + 'vector' => $vector, + 'payload' => $chunk['payload'], + ]; + $totalPoints++; + } else { + $failed++; + } + $progressBar->advance(); + } + } + + // Flush to Qdrant in batches of 200 + if (count($pointsBuffer) >= 200) { + $this->qdrant->send(new UpsertPoints($collection, $pointsBuffer)); + $pointsBuffer = []; + } + } + + // Flush remaining + if (! empty($pointsBuffer)) { + $this->qdrant->send(new UpsertPoints($collection, $pointsBuffer)); + } + + $progressBar->finish(); + $this->newLine(2); + + info("Ingested {$totalPoints} points from ".count($chunks)." chunks"); + if ($failed > 0) { + warning("{$failed} chunks failed to embed"); + } + } + + private function showDryRun(array $conversations, array $chunks): void + { + $this->newLine(); + info('Dry run summary:'); + $this->line(" Conversations: ".count($conversations)); + $this->line(" Chunks: ".count($chunks)); + $this->newLine(); + + $rows = []; + foreach (array_slice($conversations, 0, 15) as $c) { + $rows[] = [ + Str::limit($c['name'] ?? 'Untitled', 50), + count($c['chat_messages'] ?? []), + $c['created_at'] ?? '', + ]; + } + + if (! empty($rows)) { + table(['Conversation', 'Messages', 'Created'], $rows); + } + + if (count($conversations) > 15) { + $this->line(' ...and '.(count($conversations) - 15).' more'); + } + } +} diff --git a/app/Commands/AnthropicStatusCommand.php b/app/Commands/AnthropicStatusCommand.php new file mode 100644 index 0000000..8b3ed87 --- /dev/null +++ b/app/Commands/AnthropicStatusCommand.php @@ -0,0 +1,88 @@ +option('collection'); + + $qdrant = new QdrantConnector( + host: config('search.qdrant.host', 'localhost'), + port: (int) config('search.qdrant.port', 6333), + apiKey: config('search.qdrant.api_key'), + ); + + $response = $qdrant->send(new GetCollectionInfo($collection)); + + if (! $response->successful()) { + warning("Collection '{$collection}' does not exist"); + + return self::FAILURE; + } + + $result = $response->json('result'); + $pointsCount = $result['points_count'] ?? 0; + $indexedVectors = $result['indexed_vectors_count'] ?? 0; + $status = $result['status'] ?? 'unknown'; + + // Count unique conversations + $conversations = []; + $offset = null; + while (true) { + $scrollResponse = $qdrant->send(new ScrollPoints($collection, 250, null, $offset)); + if (! $scrollResponse->successful()) { + break; + } + + $data = $scrollResponse->json(); + $points = $data['result']['points'] ?? []; + if (empty($points)) { + break; + } + + foreach ($points as $point) { + $uuid = $point['payload']['conversation_uuid'] ?? ''; + if ($uuid !== '') { + $conversations[$uuid] = $point['payload']['conversation_name'] ?? 'Untitled'; + } + } + + $offset = $data['result']['next_page_offset'] ?? null; + if ($offset === null) { + break; + } + } + + $this->newLine(); + table( + ['Metric', 'Value'], + [ + ['Collection', $collection], + ['Status', $status], + ['Total Points', number_format($pointsCount)], + ['Indexed Vectors', number_format($indexedVectors)], + ['Conversations', number_format(count($conversations))], + ] + ); + + return self::SUCCESS; + } +} diff --git a/tests/Unit/Services/QdrantServiceTest.php b/tests/Unit/Services/QdrantServiceTest.php index 5252e8c..43cc95d 100644 --- a/tests/Unit/Services/QdrantServiceTest.php +++ b/tests/Unit/Services/QdrantServiceTest.php @@ -1723,3 +1723,62 @@ function mockCollectionExists(Mockery\MockInterface $connector, int $times = 1): expect($result)->toBe($embedding); }); }); + +describe('searchRawCollection', function (): void { + it('returns results from any collection by name', function (): void { + $embedding = array_fill(0, 384, 0.1); + + $this->mockEmbedding->shouldReceive('generate') + ->once() + ->with('punk rock') + ->andReturn($embedding); + + $mockResponse = createMockResponse(true, 200, [ + 'result' => [ + ['id' => 'abc', 'score' => 0.95, 'payload' => ['track' => 'Punkrocker', 'artist' => 'Teddybears']], + ['id' => 'def', 'score' => 0.85, 'payload' => ['track' => 'Blitzkrieg Bop', 'artist' => 'Ramones']], + ], + ]); + + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(SearchPoints::class)) + ->once() + ->andReturn($mockResponse); + + $results = $this->service->searchRawCollection('music_events', 'punk rock', 10); + + expect($results)->toHaveCount(2) + ->and($results[0]['payload']['track'])->toBe('Punkrocker') + ->and($results[0]['score'])->toBe(0.95) + ->and($results[1]['payload']['artist'])->toBe('Ramones'); + }); + + it('returns empty collection when embedding fails', function (): void { + $this->mockEmbedding->shouldReceive('generate') + ->once() + ->andReturn([]); + + $results = $this->service->searchRawCollection('music_events', 'test', 10); + + expect($results)->toBeEmpty(); + }); + + it('returns empty collection on failed response', function (): void { + $embedding = array_fill(0, 384, 0.1); + + $this->mockEmbedding->shouldReceive('generate') + ->once() + ->andReturn($embedding); + + $mockResponse = createMockResponse(false, 500); + + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(SearchPoints::class)) + ->once() + ->andReturn($mockResponse); + + $results = $this->service->searchRawCollection('music_events', 'test', 10); + + expect($results)->toBeEmpty(); + }); +}); From 909baeacf47a41294109c3066b014bcf20c6d0c7 Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Sat, 4 Apr 2026 18:10:15 -0700 Subject: [PATCH 3/6] chore: upgrade Pest to 4.4.5, add Saloon CVE audit ignores --- composer.json | 2 +- composer.lock | 298 ++++++++++++++++++++++++-------------------------- 2 files changed, 141 insertions(+), 159 deletions(-) diff --git a/composer.json b/composer.json index 4707cf3..e83ff29 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "fakerphp/faker": "^1.23", "laravel/pint": "^1.25.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^3.8.4|^4.1.2", + "pestphp/pest": "^4.4", "phpstan/extension-installer": "*", "phpstan/phpstan": "^1.0", "phpstan/phpstan-deprecation-rules": "^1.2", diff --git a/composer.lock b/composer.lock index 95abd29..b5eaa81 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "81a5724d1efaf7499f2dea856b5b6775", + "content-hash": "e9ecfa60de9c7281c7d3186295c777de", "packages": [ { "name": "brick/math", @@ -3206,39 +3206,36 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.3", + "version": "v8.9.2", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" + "reference": "6eb16883e74fd725ac64dbe81544c961ab448ba5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/6eb16883e74fd725ac64dbe81544c961ab448ba5", + "reference": "6eb16883e74fd725ac64dbe81544c961ab448ba5", "shasum": "" }, "require": { - "filp/whoops": "^2.18.1", - "nunomaduro/termwind": "^2.3.1", + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.3.0" + "symfony/console": "^7.4.8 || ^8.0.4" }, "conflict": { - "laravel/framework": "<11.44.2 || >=13.0.0", - "phpunit/phpunit": "<11.5.15 || >=13.0.0" + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "brianium/paratest": "^7.8.3", - "larastan/larastan": "^3.4.2", - "laravel/framework": "^11.44.2 || ^12.18", - "laravel/pint": "^1.22.1", - "laravel/sail": "^1.43.1", - "laravel/sanctum": "^4.1.1", - "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0 || ^10.4", - "pestphp/pest": "^3.8.2 || ^4.0.0", - "sebastian/environment": "^7.2.1 || ^8.0" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.3", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.2.0", + "laravel/pint": "^1.29.0", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.0.0", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.0.0" }, "type": "library", "extra": { @@ -3301,7 +3298,7 @@ "type": "patreon" } ], - "time": "2025-11-20T02:55:25+00:00" + "time": "2026-03-31T21:51:27+00:00" }, { "name": "nunomaduro/laravel-console-summary", @@ -3499,31 +3496,31 @@ }, { "name": "nunomaduro/termwind", - "version": "v2.3.3", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.3.6" + "symfony/console": "^7.4.4 || ^8.0.4" }, "require-dev": { - "illuminate/console": "^11.46.1", - "laravel/pint": "^1.25.1", + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.3.5", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -3555,7 +3552,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Its like Tailwind CSS, but for the console.", + "description": "It's like Tailwind CSS, but for the console.", "keywords": [ "cli", "console", @@ -3566,7 +3563,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" }, "funding": [ { @@ -3582,7 +3579,7 @@ "type": "github" } ], - "time": "2025-11-20T02:34:59+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "phpoption/phpoption", @@ -4429,16 +4426,16 @@ }, { "name": "symfony/console", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" + "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "url": "https://api.github.com/repos/symfony/console/zipball/1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", + "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", "shasum": "" }, "require": { @@ -4503,7 +4500,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.4" + "source": "https://github.com/symfony/console/tree/v7.4.8" }, "funding": [ { @@ -4523,7 +4520,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T11:36:38+00:00" + "time": "2026-03-30T13:54:39+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4837,16 +4834,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.5", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" + "reference": "e0be088d22278583a82da281886e8c3592fbf149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", + "reference": "e0be088d22278583a82da281886e8c3592fbf149", "shasum": "" }, "require": { @@ -4881,7 +4878,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.5" + "source": "https://github.com/symfony/finder/tree/v7.4.8" }, "funding": [ { @@ -4901,7 +4898,7 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/http-foundation", @@ -6024,16 +6021,16 @@ }, { "name": "symfony/process", - "version": "v7.4.5", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "608476f4604102976d687c483ac63a79ba18cc97" + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", - "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a", + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a", "shasum": "" }, "require": { @@ -6065,7 +6062,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.5" + "source": "https://github.com/symfony/process/tree/v7.4.8" }, "funding": [ { @@ -6085,7 +6082,7 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/routing", @@ -6261,16 +6258,16 @@ }, { "name": "symfony/string", - "version": "v8.0.4", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "758b372d6882506821ed666032e43020c4f57194" + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", - "reference": "758b372d6882506821ed666032e43020c4f57194", + "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", "shasum": "" }, "require": { @@ -6327,7 +6324,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.4" + "source": "https://github.com/symfony/string/tree/v8.0.8" }, "funding": [ { @@ -6347,7 +6344,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/translation", @@ -6851,16 +6848,16 @@ "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.16.1", + "version": "v7.20.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b" + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", "shasum": "" }, "require": { @@ -6871,24 +6868,24 @@ "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6", - "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.5.4", - "sebastian/environment": "^8.0.3", - "symfony/console": "^7.3.4 || ^8.0.0", - "symfony/process": "^7.3.4 || ^8.0.0" + "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", + "phpunit/php-file-iterator": "^6.0.1 || ^7", + "phpunit/php-timer": "^8 || ^9", + "phpunit/phpunit": "^12.5.14 || ^13.0.5", + "sebastian/environment": "^8.0.3 || ^9", + "symfony/console": "^7.4.7 || ^8.0.7", + "symfony/process": "^7.4.5 || ^8.0.5" }, "require-dev": { "doctrine/coding-standard": "^14.0.0", "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.33", - "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.11", - "phpstan/phpstan-strict-rules": "^2.0.7", - "symfony/filesystem": "^7.3.2 || ^8.0.0" + "phpstan/phpstan": "^2.1.44", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "symfony/filesystem": "^7.4.6 || ^8.0.6" }, "bin": [ "bin/paratest", @@ -6928,7 +6925,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.16.1" + "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" }, "funding": [ { @@ -6940,33 +6937,33 @@ "type": "paypal" } ], - "time": "2026-01-08T07:23:06+00:00" + "time": "2026-03-29T15:46:14+00:00" }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -6986,9 +6983,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "fakerphp/faker", @@ -7495,41 +7492,41 @@ }, { "name": "pestphp/pest", - "version": "v4.3.2", + "version": "v4.4.5", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "3a4329ddc7a2b67c19fca8342a668b39be3ae398" + "reference": "9797a71dbc776f46d6fcacb708b002755da6f37a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/3a4329ddc7a2b67c19fca8342a668b39be3ae398", - "reference": "3a4329ddc7a2b67c19fca8342a668b39be3ae398", + "url": "https://api.github.com/repos/pestphp/pest/zipball/9797a71dbc776f46d6fcacb708b002755da6f37a", + "reference": "9797a71dbc776f46d6fcacb708b002755da6f37a", "shasum": "" }, "require": { - "brianium/paratest": "^7.16.1", - "nunomaduro/collision": "^8.8.3", - "nunomaduro/termwind": "^2.3.3", + "brianium/paratest": "^7.20.0", + "nunomaduro/collision": "^8.9.2", + "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin-arch": "^4.0.0", "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.8", - "symfony/process": "^7.4.4|^8.0.0" + "phpunit/phpunit": "^12.5.16", + "symfony/process": "^7.4.8|^8.0.8" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.8", + "phpunit/phpunit": ">12.5.16", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { - "pestphp/pest-dev-tools": "^4.0.0", - "pestphp/pest-plugin-browser": "^4.2.1", + "pestphp/pest-dev-tools": "^4.1.0", + "pestphp/pest-plugin-browser": "^4.3.0", "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.18" + "psy/psysh": "^0.12.22" }, "bin": [ "bin/pest" @@ -7595,7 +7592,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.3.2" + "source": "https://github.com/pestphp/pest/tree/v4.4.5" }, "funding": [ { @@ -7607,7 +7604,7 @@ "type": "github" } ], - "time": "2026-01-28T01:01:19+00:00" + "time": "2026-04-03T13:43:28+00:00" }, { "name": "pestphp/pest-plugin", @@ -8054,16 +8051,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "6.0.1", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "2f5cbed597cb261d1ea458f3da3a9ad32e670b1e" + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/2f5cbed597cb261d1ea458f3da3a9ad32e670b1e", - "reference": "2f5cbed597cb261d1ea458f3da3a9ad32e670b1e", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", "shasum": "" }, "require": { @@ -8113,9 +8110,9 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.1" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" }, - "time": "2026-01-20T15:30:42+00:00" + "time": "2026-03-18T20:49:53+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -8421,16 +8418,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "12.5.2", + "version": "12.5.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", "shasum": "" }, "require": { @@ -8486,7 +8483,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" }, "funding": [ { @@ -8506,7 +8503,7 @@ "type": "tidelift" } ], - "time": "2025-12-24T07:03:04+00:00" + "time": "2026-02-06T06:01:44+00:00" }, { "name": "phpunit/php-file-iterator", @@ -8767,16 +8764,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.8", + "version": "12.5.16", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889" + "reference": "b2429f58ae75cae980b5bb9873abe4de6aac8b58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/37ddb96c14bfee10304825edbb7e66d341ec6889", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b2429f58ae75cae980b5bb9873abe4de6aac8b58", + "reference": "b2429f58ae75cae980b5bb9873abe4de6aac8b58", "shasum": "" }, "require": { @@ -8790,18 +8787,19 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6.0.0", + "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", "sebastian/cli-parser": "^4.2.0", "sebastian/comparator": "^7.1.4", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", + "sebastian/environment": "^8.0.4", "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", "sebastian/type": "^6.0.3", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" @@ -8844,31 +8842,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.8" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.16" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-01-27T06:12:29+00:00" + "time": "2026-04-03T05:26:42+00:00" }, { "name": "rector/rector", @@ -9217,16 +9199,16 @@ }, { "name": "sebastian/environment", - "version": "8.0.3", + "version": "8.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" + "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", + "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", "shasum": "" }, "require": { @@ -9269,7 +9251,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3" + "source": "https://github.com/sebastianbergmann/environment/tree/8.0.4" }, "funding": [ { @@ -9289,7 +9271,7 @@ "type": "tidelift" } ], - "time": "2025-08-12T14:11:56+00:00" + "time": "2026-03-15T07:05:40+00:00" }, { "name": "sebastian/exporter", @@ -9880,23 +9862,23 @@ }, { "name": "ta-tikoma/phpunit-architecture-test", - "version": "0.8.6", + "version": "0.8.7", "source": { "type": "git", "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", - "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e" + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/ad48430b92901fd7d003fdaf2d7b139f96c0906e", - "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", "shasum": "" }, "require": { "nikic/php-parser": "^4.18.0 || ^5.0.0", "php": "^8.1.0", "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", - "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "require-dev": { @@ -9933,9 +9915,9 @@ ], "support": { "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", - "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.6" + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" }, - "time": "2026-01-30T07:16:00+00:00" + "time": "2026-02-17T17:25:14+00:00" }, { "name": "theseer/tokenizer", @@ -9989,16 +9971,16 @@ }, { "name": "webmozart/assert", - "version": "2.1.2", + "version": "2.1.6", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", "shasum": "" }, "require": { @@ -10045,9 +10027,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.2" + "source": "https://github.com/webmozarts/assert/tree/2.1.6" }, - "time": "2026-01-13T14:02:24+00:00" + "time": "2026-02-27T10:28:38+00:00" } ], "aliases": [], From a1a0f553109bc5977a060a1d42a3fb995606ee2b Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Sun, 5 Apr 2026 05:51:21 -0700 Subject: [PATCH 4/6] fix: pin Pest/PHPUnit compat, fix phpunit.xml for v12, fix test mock --- app/Commands/AnthropicSearchCommand.php | 88 ++++++++++++++++++ composer.json | 3 +- composer.lock | 107 ++++++++++++---------- phpunit.xml | 7 -- tests/Unit/Services/QdrantServiceTest.php | 14 +-- 5 files changed, 159 insertions(+), 60 deletions(-) create mode 100644 app/Commands/AnthropicSearchCommand.php diff --git a/app/Commands/AnthropicSearchCommand.php b/app/Commands/AnthropicSearchCommand.php new file mode 100644 index 0000000..fe46668 --- /dev/null +++ b/app/Commands/AnthropicSearchCommand.php @@ -0,0 +1,88 @@ +argument('query'); + $limit = (int) $this->option('limit'); + $collection = $this->option('collection'); + $sender = $this->option('sender'); + + $vector = $embedding->generate($query); + if (empty($vector)) { + warning('Failed to generate embedding for query.'); + + return self::FAILURE; + } + + $qdrant = new QdrantConnector( + host: config('search.qdrant.host', 'localhost'), + port: (int) config('search.qdrant.port', 6333), + apiKey: config('search.qdrant.api_key'), + ); + + $response = $qdrant->send(new SearchPoints($collection, $vector, $limit, 0.0)); + + if (! $response->successful()) { + warning('Search failed.'); + + return self::FAILURE; + } + + $results = collect($response->json('result') ?? []); + + if ($results->isEmpty()) { + warning('No results found.'); + + return self::SUCCESS; + } + + // Filter by sender if specified + if ($sender) { + $results = $results->filter(fn (array $r) => ($r['payload']['sender'] ?? '') === $sender); + } + + info($results->count()." results for \"{$query}\":\n"); + + foreach ($results as $i => $result) { + $payload = $result['payload']; + $score = round($result['score'], 3); + $name = $payload['conversation_name'] ?? 'Untitled'; + $role = $payload['sender'] ?? '?'; + $date = substr($payload['conversation_created_at'] ?? '', 0, 10); + $text = $payload['text'] ?? ''; + + // Truncate text for display + if (strlen($text) > 300) { + $text = substr($text, 0, 300).'...'; + } + + $this->line("[{$score}] {$name} ({$role}, {$date})"); + $this->line(" {$text}"); + $this->newLine(); + } + + return self::SUCCESS; + } +} diff --git a/composer.json b/composer.json index e83ff29..6fd93c7 100644 --- a/composer.json +++ b/composer.json @@ -27,11 +27,12 @@ "fakerphp/faker": "^1.23", "laravel/pint": "^1.25.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^4.4", + "pestphp/pest": "4.3.2", "phpstan/extension-installer": "*", "phpstan/phpstan": "^1.0", "phpstan/phpstan-deprecation-rules": "^1.2", "phpstan/phpstan-strict-rules": "^1.6", + "phpunit/phpunit": "12.5.8", "rector/rector": "^1.2" }, "autoload": { diff --git a/composer.lock b/composer.lock index b5eaa81..3383bf0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e9ecfa60de9c7281c7d3186295c777de", + "content-hash": "abe51ff9884d9ff190b93fc4a67fe168", "packages": [ { "name": "brick/math", @@ -6848,16 +6848,16 @@ "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.20.0", + "version": "v7.17.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" + "reference": "53cb90a6aa3ef3840458781600628ade058a18b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", - "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/53cb90a6aa3ef3840458781600628ade058a18b9", + "reference": "53cb90a6aa3ef3840458781600628ade058a18b9", "shasum": "" }, "require": { @@ -6868,24 +6868,24 @@ "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", - "phpunit/php-file-iterator": "^6.0.1 || ^7", - "phpunit/php-timer": "^8 || ^9", - "phpunit/phpunit": "^12.5.14 || ^13.0.5", - "sebastian/environment": "^8.0.3 || ^9", - "symfony/console": "^7.4.7 || ^8.0.7", - "symfony/process": "^7.4.5 || ^8.0.5" + "phpunit/php-code-coverage": "^12.5.2", + "phpunit/php-file-iterator": "^6", + "phpunit/php-timer": "^8", + "phpunit/phpunit": "^12.5.8", + "sebastian/environment": "^8.0.3", + "symfony/console": "^7.3.4 || ^8.0.0", + "symfony/process": "^7.3.4 || ^8.0.0" }, "require-dev": { "doctrine/coding-standard": "^14.0.0", "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.44", - "phpstan/phpstan-deprecation-rules": "^2.0.4", - "phpstan/phpstan-phpunit": "^2.0.16", - "phpstan/phpstan-strict-rules": "^2.0.10", - "symfony/filesystem": "^7.4.6 || ^8.0.6" + "phpstan/phpstan": "^2.1.38", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.12", + "phpstan/phpstan-strict-rules": "^2.0.8", + "symfony/filesystem": "^7.3.2 || ^8.0.0" }, "bin": [ "bin/paratest", @@ -6925,7 +6925,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" + "source": "https://github.com/paratestphp/paratest/tree/v7.17.0" }, "funding": [ { @@ -6937,7 +6937,7 @@ "type": "paypal" } ], - "time": "2026-03-29T15:46:14+00:00" + "time": "2026-02-05T09:14:44+00:00" }, { "name": "doctrine/deprecations", @@ -7492,41 +7492,41 @@ }, { "name": "pestphp/pest", - "version": "v4.4.5", + "version": "v4.3.2", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "9797a71dbc776f46d6fcacb708b002755da6f37a" + "reference": "3a4329ddc7a2b67c19fca8342a668b39be3ae398" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/9797a71dbc776f46d6fcacb708b002755da6f37a", - "reference": "9797a71dbc776f46d6fcacb708b002755da6f37a", + "url": "https://api.github.com/repos/pestphp/pest/zipball/3a4329ddc7a2b67c19fca8342a668b39be3ae398", + "reference": "3a4329ddc7a2b67c19fca8342a668b39be3ae398", "shasum": "" }, "require": { - "brianium/paratest": "^7.20.0", - "nunomaduro/collision": "^8.9.2", - "nunomaduro/termwind": "^2.4.0", + "brianium/paratest": "^7.16.1", + "nunomaduro/collision": "^8.8.3", + "nunomaduro/termwind": "^2.3.3", "pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin-arch": "^4.0.0", "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.16", - "symfony/process": "^7.4.8|^8.0.8" + "phpunit/phpunit": "^12.5.8", + "symfony/process": "^7.4.4|^8.0.0" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.16", + "phpunit/phpunit": ">12.5.8", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { - "pestphp/pest-dev-tools": "^4.1.0", - "pestphp/pest-plugin-browser": "^4.3.0", + "pestphp/pest-dev-tools": "^4.0.0", + "pestphp/pest-plugin-browser": "^4.2.1", "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.22" + "psy/psysh": "^0.12.18" }, "bin": [ "bin/pest" @@ -7592,7 +7592,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.4.5" + "source": "https://github.com/pestphp/pest/tree/v4.3.2" }, "funding": [ { @@ -7604,7 +7604,7 @@ "type": "github" } ], - "time": "2026-04-03T13:43:28+00:00" + "time": "2026-01-28T01:01:19+00:00" }, { "name": "pestphp/pest-plugin", @@ -8764,16 +8764,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.16", + "version": "12.5.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "b2429f58ae75cae980b5bb9873abe4de6aac8b58" + "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b2429f58ae75cae980b5bb9873abe4de6aac8b58", - "reference": "b2429f58ae75cae980b5bb9873abe4de6aac8b58", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/37ddb96c14bfee10304825edbb7e66d341ec6889", + "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889", "shasum": "" }, "require": { @@ -8787,19 +8787,18 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.3", - "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-code-coverage": "^12.5.2", + "phpunit/php-file-iterator": "^6.0.0", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", "sebastian/cli-parser": "^4.2.0", "sebastian/comparator": "^7.1.4", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.4", + "sebastian/environment": "^8.0.3", "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", - "sebastian/recursion-context": "^7.0.1", "sebastian/type": "^6.0.3", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" @@ -8842,15 +8841,31 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.16" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.8" }, "funding": [ { - "url": "https://phpunit.de/sponsoring.html", - "type": "other" + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" } ], - "time": "2026-04-03T05:26:42+00:00" + "time": "2026-01-27T06:12:29+00:00" }, { "name": "rector/rector", diff --git a/phpunit.xml b/phpunit.xml index c122375..777ee2b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -17,11 +17,4 @@ ./app - - - - - - - diff --git a/tests/Unit/Services/QdrantServiceTest.php b/tests/Unit/Services/QdrantServiceTest.php index 43cc95d..0a1bdc9 100644 --- a/tests/Unit/Services/QdrantServiceTest.php +++ b/tests/Unit/Services/QdrantServiceTest.php @@ -1733,12 +1733,14 @@ function mockCollectionExists(Mockery\MockInterface $connector, int $times = 1): ->with('punk rock') ->andReturn($embedding); - $mockResponse = createMockResponse(true, 200, [ - 'result' => [ - ['id' => 'abc', 'score' => 0.95, 'payload' => ['track' => 'Punkrocker', 'artist' => 'Teddybears']], - ['id' => 'def', 'score' => 0.85, 'payload' => ['track' => 'Blitzkrieg Bop', 'artist' => 'Ramones']], - ], - ]); + $searchResults = [ + ['id' => 'abc', 'score' => 0.95, 'payload' => ['track' => 'Punkrocker', 'artist' => 'Teddybears']], + ['id' => 'def', 'score' => 0.85, 'payload' => ['track' => 'Blitzkrieg Bop', 'artist' => 'Ramones']], + ]; + + $mockResponse = Mockery::mock(\Saloon\Http\Response::class); + $mockResponse->shouldReceive('successful')->andReturn(true); + $mockResponse->shouldReceive('json')->with('result')->andReturn($searchResults); $this->mockConnector->shouldReceive('send') ->with(Mockery::type(SearchPoints::class)) From 26f77ebd387e6dd30c74a71143d9fb6743fed69e Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Sun, 5 Apr 2026 05:57:54 -0700 Subject: [PATCH 5/6] fix: remove accidentally committed Anthropic commands --- app/Commands/AnthropicImportCommand.php | 475 ------------------------ app/Commands/AnthropicSearchCommand.php | 88 ----- app/Commands/AnthropicStatusCommand.php | 88 ----- 3 files changed, 651 deletions(-) delete mode 100644 app/Commands/AnthropicImportCommand.php delete mode 100644 app/Commands/AnthropicSearchCommand.php delete mode 100644 app/Commands/AnthropicStatusCommand.php diff --git a/app/Commands/AnthropicImportCommand.php b/app/Commands/AnthropicImportCommand.php deleted file mode 100644 index 466671c..0000000 --- a/app/Commands/AnthropicImportCommand.php +++ /dev/null @@ -1,475 +0,0 @@ - */ - private array $embedServers = []; - - private int $serverIndex = 0; - - public function handle(): int - { - // Anthropic exports can be 300MB+ JSON — need headroom - ini_set('memory_limit', '1G'); - - $file = $this->argument('file'); - $collection = $this->option('collection'); - - if (! file_exists($file)) { - error("File not found: {$file}"); - - return self::FAILURE; - } - - $this->qdrant = new QdrantConnector( - host: config('search.qdrant.host', 'localhost'), - port: (int) config('search.qdrant.port', 6333), - apiKey: config('search.qdrant.api_key'), - ); - - // Discover embedding servers - if (! $this->option('dry-run')) { - $this->embedServers = $this->discoverServers(); - if (empty($this->embedServers)) { - error('No embedding servers found on ports '.implode(', ', self::EMBED_PORTS)); - - return self::FAILURE; - } - info(count($this->embedServers).' embedding server(s) discovered'); - } - - // Ensure collection - $this->ensureCollection($collection); - - // Extract and parse - $conversations = spin( - fn () => $this->extractConversations($file), - 'Extracting conversations...' - ); - - info(count($conversations).' conversations in dump'); - - // Deduplicate - $existingUuids = spin( - fn () => $this->getExistingConversationUuids($collection), - 'Checking for existing conversations...' - ); - - $newConversations = array_filter( - $conversations, - fn (array $c) => ! isset($existingUuids[$c['uuid'] ?? '']) - && ! empty($c['chat_messages']) - ); - $newConversations = array_values($newConversations); - - $skipped = count($conversations) - count($newConversations); - info(count($newConversations)." new conversations ({$skipped} already imported)"); - - if (empty($newConversations)) { - info('Nothing new to import!'); - - return self::SUCCESS; - } - - // Prepare chunks - $chunks = $this->prepareChunks($newConversations); - info(count($chunks).' chunks prepared'); - - if ($this->option('dry-run')) { - $this->showDryRun($newConversations, $chunks); - - return self::SUCCESS; - } - - // Embed and ingest - $this->ingest($chunks, $collection); - - return self::SUCCESS; - } - - /** - * @return list - */ - private function discoverServers(): array - { - $servers = []; - $client = new Client(['timeout' => 5, 'connect_timeout' => 2, 'http_errors' => false]); - - foreach (self::EMBED_PORTS as $port) { - try { - $response = $client->post("http://localhost:{$port}/embed", [ - 'json' => ['texts' => ['test']], - ]); - - if ($response->getStatusCode() === 200) { - $data = json_decode((string) $response->getBody(), true); - $dim = count($data['embeddings'][0] ?? []); - if ($dim === self::VECTOR_SIZE) { - $servers[] = "http://localhost:{$port}"; - } else { - warning("Port {$port}: wrong dimension ({$dim})"); - } - } - } catch (\Throwable) { - // Server not available - } - } - - return $servers; - } - - private function nextServer(): string - { - $server = $this->embedServers[$this->serverIndex % count($this->embedServers)]; - $this->serverIndex++; - - return $server; - } - - private function ensureCollection(string $collection): void - { - $response = $this->qdrant->send(new GetCollectionInfo($collection)); - - if ($response->successful()) { - return; - } - - $this->qdrant->send(new CreateCollection($collection, self::VECTOR_SIZE)); - info("Created collection '{$collection}'"); - } - - /** - * @return array - */ - private function getExistingConversationUuids(string $collection): array - { - $existing = []; - $offset = null; - - while (true) { - $response = $this->qdrant->send( - new ScrollPoints($collection, 250, null, $offset) - ); - - if (! $response->successful()) { - break; - } - - $data = $response->json(); - $points = $data['result']['points'] ?? []; - - if (empty($points)) { - break; - } - - foreach ($points as $point) { - $uuid = $point['payload']['conversation_uuid'] ?? ''; - if ($uuid !== '') { - $existing[$uuid] = true; - } - } - - $offset = $data['result']['next_page_offset'] ?? null; - if ($offset === null) { - break; - } - } - - return $existing; - } - - /** - * @return list}> - */ - private function extractConversations(string $zipPath): array - { - $tmpDir = sys_get_temp_dir().'/anthropic-import-'.Str::random(8); - mkdir($tmpDir, 0755, true); - - $zip = new \ZipArchive; - $zip->open($zipPath); - $zip->extractTo($tmpDir); - $zip->close(); - - $convFile = $tmpDir.'/conversations.json'; - if (! file_exists($convFile)) { - error('conversations.json not found in zip'); - - return []; - } - - $conversations = json_decode(file_get_contents($convFile), true); - - // Cleanup - array_map('unlink', glob($tmpDir.'/*')); - rmdir($tmpDir); - - return $conversations; - } - - /** - * @return list}> - */ - private function prepareChunks(array $conversations): array - { - $chunks = []; - - foreach ($conversations as $conv) { - $convUuid = $conv['uuid'] ?? ''; - $convName = $conv['name'] ?? 'Untitled'; - $convCreated = $conv['created_at'] ?? ''; - - foreach ($conv['chat_messages'] ?? [] as $mi => $msg) { - $text = $this->extractText($msg); - if (strlen(trim($text)) < 20) { - continue; - } - - foreach ($this->chunkText($text) as $ci => $chunk) { - $chunks[] = [ - 'text' => $chunk, - 'payload' => [ - 'conversation_uuid' => $convUuid, - 'conversation_name' => $convName, - 'conversation_created_at' => $convCreated, - 'sender' => $msg['sender'] ?? 'unknown', - 'turn_index' => $mi, - 'chunk_index' => $ci, - 'message_created_at' => $msg['created_at'] ?? '', - 'text' => $chunk, - ], - ]; - } - } - } - - return $chunks; - } - - private function extractText(array $message): string - { - if (! empty($message['text'])) { - return $message['text']; - } - - $parts = []; - foreach ($message['content'] ?? [] as $block) { - if (is_array($block) && ($block['type'] ?? '') === 'text') { - $parts[] = $block['text'] ?? ''; - } - } - - return implode("\n", $parts); - } - - /** - * @return list - */ - private function chunkText(string $text): array - { - if ($text === '' || strlen($text) <= self::MAX_CHUNK_CHARS) { - return $text !== '' ? [$text] : []; - } - - $chunks = []; - $remaining = $text; - - while ($remaining !== '') { - if (strlen($remaining) <= self::MAX_CHUNK_CHARS) { - $chunks[] = $remaining; - break; - } - - $splitAt = self::MAX_CHUNK_CHARS; - foreach (["\n\n", "\n", '. ', ' '] as $sep) { - $idx = strrpos(substr($remaining, 0, self::MAX_CHUNK_CHARS), $sep); - if ($idx !== false && $idx > self::MAX_CHUNK_CHARS / 3) { - $splitAt = $idx + strlen($sep); - break; - } - } - - $chunk = trim(substr($remaining, 0, $splitAt)); - if ($chunk !== '') { - $chunks[] = $chunk; - } - $remaining = trim(substr($remaining, $splitAt)); - } - - return $chunks; - } - - /** - * @param list $texts - * @return list|null> - */ - private function embedBatch(array $texts, string $server): array - { - $client = new Client(['timeout' => 60, 'connect_timeout' => 5, 'http_errors' => false]); - - try { - $response = $client->post("{$server}/embed", [ - 'json' => ['texts' => $texts], - ]); - - if ($response->getStatusCode() === 200) { - $data = json_decode((string) $response->getBody(), true); - - return $data['embeddings'] ?? array_fill(0, count($texts), null); - } - } catch (\Throwable) { - // Fall through - } - - // Fallback: try other servers - foreach ($this->embedServers as $fallback) { - if ($fallback === $server) { - continue; - } - - try { - $response = $client->post("{$fallback}/embed", [ - 'json' => ['texts' => $texts], - ]); - - if ($response->getStatusCode() === 200) { - return json_decode((string) $response->getBody(), true)['embeddings']; - } - } catch (\Throwable) { - continue; - } - } - - return array_fill(0, count($texts), null); - } - - private function ingest(array $chunks, string $collection): void - { - $batchSize = (int) $this->option('batch-size'); - $concurrency = (int) $this->option('concurrency'); - $totalWorkers = $concurrency * count($this->embedServers); - - info("Embedding with {$totalWorkers} workers across ".count($this->embedServers).' servers...'); - - $batches = array_chunk($chunks, $batchSize); - $pointsBuffer = []; - $totalPoints = 0; - $failed = 0; - - $progressBar = $this->output->createProgressBar(count($chunks)); - $progressBar->start(); - - // Process batches sequentially with round-robin across servers - // Using pools of $totalWorkers concurrent requests - $batchQueue = $batches; - while (! empty($batchQueue)) { - $currentBatch = array_splice($batchQueue, 0, $totalWorkers); - $results = []; - - // Embed each sub-batch - foreach ($currentBatch as $batch) { - $texts = array_column($batch, 'text'); - $server = $this->nextServer(); - $embeddings = $this->embedBatch($texts, $server); - - foreach ($batch as $i => $chunk) { - $vector = $embeddings[$i] ?? null; - if ($vector !== null && count($vector) === self::VECTOR_SIZE) { - $pointsBuffer[] = [ - 'id' => (string) Str::uuid(), - 'vector' => $vector, - 'payload' => $chunk['payload'], - ]; - $totalPoints++; - } else { - $failed++; - } - $progressBar->advance(); - } - } - - // Flush to Qdrant in batches of 200 - if (count($pointsBuffer) >= 200) { - $this->qdrant->send(new UpsertPoints($collection, $pointsBuffer)); - $pointsBuffer = []; - } - } - - // Flush remaining - if (! empty($pointsBuffer)) { - $this->qdrant->send(new UpsertPoints($collection, $pointsBuffer)); - } - - $progressBar->finish(); - $this->newLine(2); - - info("Ingested {$totalPoints} points from ".count($chunks)." chunks"); - if ($failed > 0) { - warning("{$failed} chunks failed to embed"); - } - } - - private function showDryRun(array $conversations, array $chunks): void - { - $this->newLine(); - info('Dry run summary:'); - $this->line(" Conversations: ".count($conversations)); - $this->line(" Chunks: ".count($chunks)); - $this->newLine(); - - $rows = []; - foreach (array_slice($conversations, 0, 15) as $c) { - $rows[] = [ - Str::limit($c['name'] ?? 'Untitled', 50), - count($c['chat_messages'] ?? []), - $c['created_at'] ?? '', - ]; - } - - if (! empty($rows)) { - table(['Conversation', 'Messages', 'Created'], $rows); - } - - if (count($conversations) > 15) { - $this->line(' ...and '.(count($conversations) - 15).' more'); - } - } -} diff --git a/app/Commands/AnthropicSearchCommand.php b/app/Commands/AnthropicSearchCommand.php deleted file mode 100644 index fe46668..0000000 --- a/app/Commands/AnthropicSearchCommand.php +++ /dev/null @@ -1,88 +0,0 @@ -argument('query'); - $limit = (int) $this->option('limit'); - $collection = $this->option('collection'); - $sender = $this->option('sender'); - - $vector = $embedding->generate($query); - if (empty($vector)) { - warning('Failed to generate embedding for query.'); - - return self::FAILURE; - } - - $qdrant = new QdrantConnector( - host: config('search.qdrant.host', 'localhost'), - port: (int) config('search.qdrant.port', 6333), - apiKey: config('search.qdrant.api_key'), - ); - - $response = $qdrant->send(new SearchPoints($collection, $vector, $limit, 0.0)); - - if (! $response->successful()) { - warning('Search failed.'); - - return self::FAILURE; - } - - $results = collect($response->json('result') ?? []); - - if ($results->isEmpty()) { - warning('No results found.'); - - return self::SUCCESS; - } - - // Filter by sender if specified - if ($sender) { - $results = $results->filter(fn (array $r) => ($r['payload']['sender'] ?? '') === $sender); - } - - info($results->count()." results for \"{$query}\":\n"); - - foreach ($results as $i => $result) { - $payload = $result['payload']; - $score = round($result['score'], 3); - $name = $payload['conversation_name'] ?? 'Untitled'; - $role = $payload['sender'] ?? '?'; - $date = substr($payload['conversation_created_at'] ?? '', 0, 10); - $text = $payload['text'] ?? ''; - - // Truncate text for display - if (strlen($text) > 300) { - $text = substr($text, 0, 300).'...'; - } - - $this->line("[{$score}] {$name} ({$role}, {$date})"); - $this->line(" {$text}"); - $this->newLine(); - } - - return self::SUCCESS; - } -} diff --git a/app/Commands/AnthropicStatusCommand.php b/app/Commands/AnthropicStatusCommand.php deleted file mode 100644 index 8b3ed87..0000000 --- a/app/Commands/AnthropicStatusCommand.php +++ /dev/null @@ -1,88 +0,0 @@ -option('collection'); - - $qdrant = new QdrantConnector( - host: config('search.qdrant.host', 'localhost'), - port: (int) config('search.qdrant.port', 6333), - apiKey: config('search.qdrant.api_key'), - ); - - $response = $qdrant->send(new GetCollectionInfo($collection)); - - if (! $response->successful()) { - warning("Collection '{$collection}' does not exist"); - - return self::FAILURE; - } - - $result = $response->json('result'); - $pointsCount = $result['points_count'] ?? 0; - $indexedVectors = $result['indexed_vectors_count'] ?? 0; - $status = $result['status'] ?? 'unknown'; - - // Count unique conversations - $conversations = []; - $offset = null; - while (true) { - $scrollResponse = $qdrant->send(new ScrollPoints($collection, 250, null, $offset)); - if (! $scrollResponse->successful()) { - break; - } - - $data = $scrollResponse->json(); - $points = $data['result']['points'] ?? []; - if (empty($points)) { - break; - } - - foreach ($points as $point) { - $uuid = $point['payload']['conversation_uuid'] ?? ''; - if ($uuid !== '') { - $conversations[$uuid] = $point['payload']['conversation_name'] ?? 'Untitled'; - } - } - - $offset = $data['result']['next_page_offset'] ?? null; - if ($offset === null) { - break; - } - } - - $this->newLine(); - table( - ['Metric', 'Value'], - [ - ['Collection', $collection], - ['Status', $status], - ['Total Points', number_format($pointsCount)], - ['Indexed Vectors', number_format($indexedVectors)], - ['Conversations', number_format(count($conversations))], - ] - ); - - return self::SUCCESS; - } -} From b3e88e45d3ae89e4081bbc39a79076873829ac8e Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Sun, 5 Apr 2026 05:59:25 -0700 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20upgrade=20Saloon=20to=20v4=20?= =?UTF-8?q?=E2=80=94=20resolves=20all=203=20CVEs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 2 +- composer.lock | 27 ++++++++++++++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/composer.json b/composer.json index 6fd93c7..f7c4000 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "illuminate/database": "^12.17", "laravel-zero/framework": "^12.0.2", "laravel/mcp": "^0.6.0", - "saloonphp/saloon": "^3.14", + "saloonphp/saloon": "^4.0", "symfony/uid": "^8.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 3383bf0..eb282d4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "abe51ff9884d9ff190b93fc4a67fe168", + "content-hash": "5c46758a9bbfbf48911a9147412e047d", "packages": [ { "name": "brick/math", @@ -848,16 +848,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.8.0", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "21dc724a0583619cd1652f673303492272778051" + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", - "reference": "21dc724a0583619cd1652f673303492272778051", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", "shasum": "" }, "require": { @@ -873,6 +873,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", + "jshttp/mime-db": "1.54.0.1", "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { @@ -944,7 +945,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.8.0" + "source": "https://github.com/guzzle/psr7/tree/2.9.0" }, "funding": [ { @@ -960,7 +961,7 @@ "type": "tidelift" } ], - "time": "2025-08-23T21:21:41+00:00" + "time": "2026-03-10T16:41:02+00:00" }, { "name": "guzzlehttp/uri-template", @@ -4268,16 +4269,16 @@ }, { "name": "saloonphp/saloon", - "version": "v3.14.2", + "version": "v4.0.0", "source": { "type": "git", "url": "https://github.com/saloonphp/saloon.git", - "reference": "634be16ca5eb0b71ab01533f58dc88d174a2e28b" + "reference": "1307b1d72cacdd2c9c20978cdf7a0b720b4bf3bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/saloonphp/saloon/zipball/634be16ca5eb0b71ab01533f58dc88d174a2e28b", - "reference": "634be16ca5eb0b71ab01533f58dc88d174a2e28b", + "url": "https://api.github.com/repos/saloonphp/saloon/zipball/1307b1d72cacdd2c9c20978cdf7a0b720b4bf3bb", + "reference": "1307b1d72cacdd2c9c20978cdf7a0b720b4bf3bb", "shasum": "" }, "require": { @@ -4337,7 +4338,7 @@ ], "support": { "issues": "https://github.com/saloonphp/saloon/issues", - "source": "https://github.com/saloonphp/saloon/tree/v3.14.2" + "source": "https://github.com/saloonphp/saloon/tree/v4.0.0" }, "funding": [ { @@ -4345,7 +4346,7 @@ "type": "github" } ], - "time": "2025-11-20T21:42:32+00:00" + "time": "2026-03-17T22:58:33+00:00" }, { "name": "symfony/clock",