Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 39 additions & 15 deletions app/Commands/KnowledgeAddCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,38 +91,40 @@ public function handle(GitContextService $gitService, QdrantService $qdrant, Wri
$force = (bool) $this->option('force');
/** @var bool $skipEnhance */
$skipEnhance = (bool) $this->option('skip-enhance');
// Built-in -q/--quiet; do not declare {--quiet} (Symfony collision).
$quiet = $this->output->isQuiet();

// Validate required fields
if ($content === null || $content === '') {
error('The content field is required.');
$this->emitError('The content field is required.', $quiet);

return self::FAILURE;
}

// Validate confidence
if (! is_numeric($confidence) || $confidence < 0 || $confidence > 100) {
error('The confidence must be between 0 and 100.');
$this->emitError('The confidence must be between 0 and 100.', $quiet);

return self::FAILURE;
}

// Validate category
if ($category !== null && ! in_array($category, self::VALID_CATEGORIES, true)) {
error('Invalid category. Valid: '.implode(', ', self::VALID_CATEGORIES));
$this->emitError('Invalid category. Valid: '.implode(', ', self::VALID_CATEGORIES), $quiet);

return self::FAILURE;
}

// Validate priority
if (! in_array($priority, self::VALID_PRIORITIES, true)) {
error('Invalid priority. Valid: '.implode(', ', self::VALID_PRIORITIES));
$this->emitError('Invalid priority. Valid: '.implode(', ', self::VALID_PRIORITIES), $quiet);

return self::FAILURE;
}

// Validate status
if (! in_array($status, self::VALID_STATUSES, true)) {
error('Invalid status. Valid: '.implode(', ', self::VALID_STATUSES));
$this->emitError('Invalid status. Valid: '.implode(', ', self::VALID_STATUSES), $quiet);

return self::FAILURE;
}
Expand Down Expand Up @@ -163,7 +165,7 @@ public function handle(GitContextService $gitService, QdrantService $qdrant, Wri
if (! $force) {
$gateResult = $writeGate->evaluate($data);
if (! $gateResult['passed']) {
error('Write gate rejected entry: '.$gateResult['reason']);
$this->emitError('Write gate rejected entry: '.$gateResult['reason'], $quiet);

return self::FAILURE;
}
Expand All @@ -182,12 +184,12 @@ public function handle(GitContextService $gitService, QdrantService $qdrant, Wri
);

if (! $success) {
error('Failed to create knowledge entry');
$this->emitError('Failed to create knowledge entry', $quiet);

return self::FAILURE;
}
} catch (DuplicateEntryException $e) {
return $this->handleDuplicate($e, $data, $qdrant, (int) $confidence);
return $this->handleDuplicate($e, $data, $qdrant, (int) $confidence, $quiet);
}

// Queue for Ollama enhancement unless skipped
Expand All @@ -211,22 +213,23 @@ private function handleDuplicate(
DuplicateEntryException $e,
array $data,
QdrantService $qdrant,
int $confidence
int $confidence,
bool $quiet = false
): int {
$existingId = $e->existingId;

if ($e->duplicateType === DuplicateEntryException::TYPE_HASH) {
error("Duplicate content detected: This exact content already exists as entry '{$existingId}'");
$this->emitError("Duplicate content detected: This exact content already exists as entry '{$existingId}'", $quiet);

return self::FAILURE;
}

$percentage = $e->similarityScore !== null ? round($e->similarityScore * 100, 1) : 95;
warning("Potential duplicate detected: {$percentage}% similar to existing entry '{$existingId}'");
$this->emitWarning("Potential duplicate detected: {$percentage}% similar to existing entry '{$existingId}'", $quiet);

// Require confirmation when confidence is low (below 70)
if ($confidence < 70) {
warning("Low confidence ({$confidence}%) - please confirm this supersedes the existing entry.");
$this->emitWarning("Low confidence ({$confidence}%) - please confirm this supersedes the existing entry.", $quiet);
}

$shouldSupersede = $this->confirm(
Expand All @@ -235,7 +238,7 @@ private function handleDuplicate(
);

if (! $shouldSupersede) {
error('Entry not created. Existing knowledge preserved.');
$this->emitError('Entry not created. Existing knowledge preserved.', $quiet);

return self::FAILURE;
}
Expand All @@ -247,7 +250,7 @@ private function handleDuplicate(
);

if (! $success) {
error('Failed to create knowledge entry');
$this->emitError('Failed to create knowledge entry', $quiet);

return self::FAILURE;
}
Expand All @@ -257,7 +260,7 @@ private function handleDuplicate(
$marked = $qdrant->markSuperseded($existingId, $data['id'], $reason);

if (! $marked) {
warning('New entry created but failed to mark old entry as superseded.');
$this->emitWarning('New entry created but failed to mark old entry as superseded.', $quiet);
}

info('Knowledge entry created! Previous entry marked as superseded.');
Expand All @@ -281,6 +284,27 @@ private function handleDuplicate(
*
* @param array<string>|null $tags
*/

private function emitError(string $message, bool $quiet = false): void
{
if ($quiet) {
return;
}

error($message);
fwrite(STDERR, $message.PHP_EOL);
}

private function emitWarning(string $message, bool $quiet = false): void
{
if ($quiet) {
return;
}

warning($message);
fwrite(STDERR, $message.PHP_EOL);
}

private function displayEntryTable(
string $id,
string $title,
Expand Down
10 changes: 6 additions & 4 deletions app/Mcp/Tools/RememberTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,14 @@ public function handle(Request $request): Response
try {
$this->qdrant->upsert($entry, $project, true);
} catch (DuplicateEntryException $e) {
return Response::text(json_encode([
$response = json_encode([
'status' => 'duplicate_detected',
'existing_id' => $e->existingId,
'similarity' => $e->similarityScore !== null ? round($e->similarityScore * 100, 1) : null,
'message' => "Similar entry already exists (ID: {$e->existingId}). Use the `correct` tool to update it, or add more distinct content.",
], JSON_THROW_ON_ERROR));
], JSON_THROW_ON_ERROR);

return Response::text($response);
}

// Queue for Ollama auto-tagging
Expand Down Expand Up @@ -111,8 +113,8 @@ public function schema(JsonSchema $schema): array
->description('Detailed description of the discovery or insight (10-10000 chars).')
->required(),
'category' => $schema->string()
->enum(['architecture', 'patterns', 'decisions', 'gotchas', 'debugging', 'testing', 'deployment', 'security'])
->description('Knowledge category. Omit to let auto-tagging classify it.'),
->description('Knowledge category. Omit to let auto-tagging classify it.')
->nullable(),
'tags' => $schema->array()
->description('Tags for categorization (max 10). e.g., ["laravel", "pest", "testing"]'),
'priority' => $schema->string()
Expand Down
18 changes: 18 additions & 0 deletions tests/Feature/Commands/KnowledgeAddCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -398,3 +398,21 @@
'--content' => 'Enhancement must look in the same collection the entry was stored in',
])->assertSuccessful();
});

it('does not queue enhancement when --skip-enhance flag is used', function (): void {
$this->mockQdrant->shouldReceive('upsert')
->once()
->andReturn(true);

$mockQueue = Mockery::mock(\App\Services\EnhancementQueueService::class);
$mockQueue->shouldNotReceive('queue');
$this->app->instance(\App\Services\EnhancementQueueService::class, $mockQueue);

config(['search.ollama.enabled' => true]);

$this->artisan('add', [
'title' => 'Skip Enhancement Entry',
'--content' => 'Content',
'--skip-enhance' => true,
])->assertSuccessful();
});
Loading