diff --git a/skills/firecrawl-search/SKILL.md b/skills/firecrawl-search/SKILL.md index 578badaa97..e099189f01 100644 --- a/skills/firecrawl-search/SKILL.md +++ b/skills/firecrawl-search/SKILL.md @@ -96,6 +96,7 @@ Search costs 2 credits. After you've actually used the results (or decided they - **Time window:** must be sent within ~2 minutes of the search. Late feedback is rejected. - **`--missing-content` is the most important field.** It's a list of _specific pieces_ of content you expected but did not find. One topic per entry, each in its own string. These aggregate across teams and tell us what to index next. +- **Include `--objective`:** the underlying goal behind the search, in one sentence — what you or your user were ultimately trying to accomplish, not only what the query looked for. - **Substantive content required** (zero-effort feedback is rejected with HTTP 400): - `good` → must include at least one `--valuable-sources` entry. - `partial` → must include `--valuable-sources` or `--missing-content`. @@ -115,6 +116,7 @@ if SEARCH_ID=$(jq -er 'select(any(.data[]; length > 0)) | .id' .firecrawl/search --rating "" \ --valuable-sources '[{"url":"https://react.dev/reference/react/hooks","reason":"Most authoritative"}]' \ --missing-content '[{"topic":"useDeferredValue","description":"No example of useDeferredValue with Suspense"}]' \ + --objective "Pick the right React hook to keep a filtered list responsive" \ --silent & fi ``` diff --git a/src/__tests__/search-feedback-objective.test.ts b/src/__tests__/search-feedback-objective.test.ts new file mode 100644 index 0000000000..fb141002f8 --- /dev/null +++ b/src/__tests__/search-feedback-objective.test.ts @@ -0,0 +1,101 @@ +import { execFile } from 'node:child_process'; +import { createServer, type Server } from 'node:http'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { afterAll, beforeAll, beforeEach, expect, it } from 'vitest'; + +const exec = promisify(execFile); +const requests: { url?: string; body: any }[] = []; +let server: Server; +let baseUrl: string; +const home = mkdtempSync(join(tmpdir(), 'search-feedback-cli-')); +const searchId = '00000000-0000-4000-8000-000000000001'; + +beforeAll(async () => { + server = createServer(async (req, res) => { + let raw = ''; + for await (const chunk of req) raw += chunk; + requests.push({ url: req.url, body: raw ? JSON.parse(raw) : undefined }); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ success: true, feedbackId: 'f-1', creditsRefunded: 1 }) + ); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`; +}); +afterAll(async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); + rmSync(home, { recursive: true, force: true }); +}); +beforeEach(() => { + requests.length = 0; +}); + +async function cli(args: string[]) { + try { + return { + code: 0, + ...(await exec(process.execPath, ['dist/index.js', ...args], { + timeout: 10000, + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + FIRECRAWL_API_KEY: 'fc-test', + FIRECRAWL_API_URL: baseUrl, + FIRECRAWL_NO_UPDATE_CHECK: '1', + }, + })), + }; + } catch (error) { + return error as { code: number; stdout: string; stderr: string }; + } +} + +const feedbackArgs = [ + 'search-feedback', + searchId, + '--rating', + 'bad', + '--missing-content', + 'Contract attachments', + '--objective', + ' Shortlist federal IT contracts to bid on this quarter ', + '--json', +]; + +it('sends the trimmed objective with search feedback', async () => { + const result = await cli(feedbackArgs); + + expect(result.code).toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0].url).toBe(`/v2/search/${searchId}/feedback`); + expect(requests[0].body).toMatchObject({ + rating: 'bad', + objective: 'Shortlist federal IT contracts to bid on this quarter', + }); +}); + +it('still sends feedback without an objective', async () => { + const index = feedbackArgs.indexOf('--objective'); + const withoutObjective = feedbackArgs.filter( + (_, i) => i !== index && i !== index + 1 + ); + + expect((await cli(withoutObjective)).code).toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0].body).not.toHaveProperty('objective'); +}); + +it('rejects a blank objective before sending feedback', async () => { + const blank = [...feedbackArgs]; + blank[feedbackArgs.indexOf('--objective') + 1] = ' '; + + expect((await cli(blank)).code).not.toBe(0); + expect(requests).toHaveLength(0); +}); diff --git a/src/commands/search-feedback.ts b/src/commands/search-feedback.ts index 35898e78b4..05d38567ca 100644 --- a/src/commands/search-feedback.ts +++ b/src/commands/search-feedback.ts @@ -19,6 +19,7 @@ export interface SearchFeedbackOptions { valuableSources?: ValuableSourceInput[]; missingContent?: MissingContentInput[]; querySuggestions?: string; + objective?: string; apiKey?: string; apiUrl?: string; output?: string; @@ -129,6 +130,9 @@ export async function executeSearchFeedback( if (options.querySuggestions) { body.querySuggestions = options.querySuggestions; } + if (options.objective) { + body.objective = options.objective; + } const response = await fetch(url, { method: 'POST', diff --git a/src/index.ts b/src/index.ts index aef4eb4bd0..07828f0618 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ * Entry point for the CLI application */ -import { Command, Option } from 'commander'; +import { Command, InvalidArgumentError, Option } from 'commander'; import { createSqlCommand } from './commands/sql'; import { addFormatsAlias } from './utils/format-option'; import { @@ -1401,6 +1401,13 @@ Examples: return researchCmd; } +function searchFeedbackObjective(value: string): string { + const text = value.trim(); + if (!text || text.length > 2000) + throw new InvalidArgumentError('Use 1–2000 characters.'); + return text; +} + /** * Create the search-feedback command. Used by agents (CLI, MCP, skills) to * report search-result quality after a `firecrawl search` call. The first @@ -1429,6 +1436,11 @@ function createSearchFeedbackCommand(): Command { '--query-suggestions ', 'How the query or result set could be improved' ) + .option( + '--objective ', + 'The underlying goal: what you or your user were ultimately trying to accomplish', + searchFeedbackObjective + ) .option( '-k, --api-key ', 'Firecrawl API key (overrides global --api-key)' @@ -1471,6 +1483,7 @@ function createSearchFeedbackCommand(): Command { valuableSources, missingContent, querySuggestions: options.querySuggestions, + objective: options.objective, apiKey: options.apiKey, apiUrl: options.apiUrl, output: options.output,