Skip to content
Open
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
29 changes: 15 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,20 +370,21 @@ endpoint feedback calls silently.

#### Feedback Options

| Option | Description |
| -------------------------------- | -------------------------------------------- |
| `--rating <rating>` | Required: `good`, `partial`, or `bad` |
| `--issues <codesOrJson>` | Comma-separated issue codes or JSON array |
| `--tags <codesOrJson>` | Comma-separated tags or JSON array |
| `--note <text>` | Short human-readable feedback |
| `--valuable-sources <json>` | JSON array of `{url, reason}` entries |
| `--missing-content <json>` | JSON array of `{topic, description}` entries |
| `--query-suggestions <text>` | Search/query improvement notes |
| `--url <url>` | Relevant URL for scrape or parse feedback |
| `--page-numbers <numbersOrJson>` | Comma-separated page numbers or JSON array |
| `--metadata <json>` | Small JSON object with extra context |
| `--metadata-file <path>` | Path to small metadata JSON object |
| `--silent` | Suppress output for background agent calls |
| Option | Description |
| -------------------------------------- | -------------------------------------------------------------------------- |
| `--rating <rating>` | Required: `good`, `partial`, or `bad` |
| `--issues <codesOrJson>` | Comma-separated issue codes or JSON array |
| `--tags <codesOrJson>` | Comma-separated tags or JSON array |
| `--note <text>` | Short human-readable feedback |
| `--valuable-sources <json>` | JSON array of `{url, reason}` entries |
| `--valuable-results <positionsOrJson>` | Search only: every useful result as `source:position`, e.g. `web:1,news:2` |
| `--missing-content <json>` | JSON array of `{topic, description}` entries |
| `--query-suggestions <text>` | Search/query improvement notes |
| `--url <url>` | Relevant URL for scrape or parse feedback |
| `--page-numbers <numbersOrJson>` | Comma-separated page numbers or JSON array |
| `--metadata <json>` | Small JSON object with extra context |
| `--metadata-file <path>` | Path to small metadata JSON object |
| `--silent` | Suppress output for background agent calls |

---

Expand Down
17 changes: 8 additions & 9 deletions skills/firecrawl-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,7 @@ The `check` response then carries a per-field diff (paths like `plans[0].price`)
},
"snapshot": {
"json": {
"plans": [
/* current full extraction */
]
"plans": [/* current full extraction */]
}
}
}
Expand Down Expand Up @@ -255,11 +253,12 @@ Single format outputs raw content. Multiple formats (e.g., `--format markdown,li
These patterns are useful when working with file-based output (`-o` flag) for complex tasks:

```bash
# Extract URLs from search
jq -r '.data.web[].url' .firecrawl/search.json
# Extract URLs with their source and 1-indexed position (needed for feedback).
# Each group is numbered from 1 independently, so keep the source with it.
jq -r '.data | to_entries[] | .key as $s | .value | to_entries[] | "\($s):\(.key + 1)\t\(.value.url)"' .firecrawl/search.json

# Get titles and URLs
jq -r '.data.web[] | "\(.title): \(.url)"' .firecrawl/search.json
# Web results only, with positions, titles, and URLs
jq -r '.data.web | to_entries[] | "web:\(.key + 1)\t\(.value.title): \(.value.url)"' .firecrawl/search.json
```

## After search: send feedback (refunds 1 credit)
Expand All @@ -271,13 +270,13 @@ SEARCH_ID=$(jq -r '.id' .firecrawl/search-react-hooks.json)

firecrawl search-feedback "$SEARCH_ID" \
--rating good \
--valuable-sources '[{"url":"https://react.dev/reference/react/hooks","reason":"Authoritative"}]' \
--valuable-results "web:1,web:3" \
--missing-content '[{"topic":"useDeferredValue example"},{"topic":"Server Components hooks"}]' \
--query-suggestions "Boost react.dev for react-hooks queries" \
--silent &
```

The most useful field is `--missing-content`: an _array_ of specific pieces of content you expected to find but didn't. Use one entry per missing topic. Bad/partial feedback with detailed `--missing-content` is just as valuable as good feedback.
The most useful field is `--missing-content`: an _array_ of specific pieces of content you expected to find but didn't. Use one entry per missing topic. Bad/partial feedback with detailed `--missing-content` is just as valuable as good feedback. Mark useful results with `--valuable-results` as `source:position` (e.g. `web:1,news:2`) — results come back grouped and each group is numbered from 1, so the source is required. List **every** useful one; unlisted results count as not useful. Reserve `--valuable-sources` for useful URLs that were not among the returned results.

**Opt out:** `export FIRECRAWL_NO_SEARCH_FEEDBACK=1` makes the CLI skip every feedback call silently. Respect that flag — do not try to work around it. See [firecrawl-search](../firecrawl-search/SKILL.md) for the full pattern.

Expand Down
11 changes: 6 additions & 5 deletions skills/firecrawl-search/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ firecrawl search "your query" --sources news --tbs qdr:d -o .firecrawl/news.json

- **`--scrape` fetches full content** — don't re-scrape URLs from search results. This saves credits and avoids redundant fetches.
- Always write results to `.firecrawl/` with `-o` to avoid context window bloat.
- Use `jq` to extract URLs or titles: `jq -r '.data.web[].url' .firecrawl/search.json`
- Use `jq` to extract URLs **with their source and 1-indexed position** (you'll need both for feedback): `jq -r '.data | to_entries[] | .key as $s | .value | to_entries[] | "\($s):\(.key + 1)\t\(.value.url)"' .firecrawl/search.json`
- Naming convention: `.firecrawl/search-{query}.json` or `.firecrawl/search-{query}-scraped.json`

## Send feedback after every search (refunds 1 credit)
Expand All @@ -62,9 +62,10 @@ 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 — do not pack multiple topics into one string. These aggregate across teams and tell us what to index next.
- **`--valuable-results` marks which results were useful.** Results come back grouped (`data.web`, `data.images`, `data.news`) and **each group is numbered from 1 independently**, so every entry is `source:position` — `web:1` and `news:1` are two different results. **Be exhaustive** — list every result that was actually useful; unlisted results are treated as not useful, so a partial list corrupts the signal. Reserve `--valuable-sources` for useful URLs that were NOT among the returned results (e.g. a page you found by following a result's link) — never report the same result in both.
- **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`.
- `good` → must include `--valuable-results` or at least one `--valuable-sources` entry.
- `partial` → must include `--valuable-results`, `--valuable-sources`, or `--missing-content`.
- `bad` → must include `--missing-content` or `--query-suggestions`.
- **Daily refund cap (per team, per UTC day, default 100 credits).** Once your team has been refunded 100 credits today, further submissions still record feedback but no longer refund credits. The response includes `creditsRefundedToday` / `dailyRefundCap` / `dailyCapReached`. **When `dailyCapReached: true`, stop calling `search-feedback` for the rest of the UTC day** — it won't refund anything and you're wasting bandwidth.
- **Idempotent:** re-submitting for the same search id returns success but no extra refund.
Expand All @@ -79,10 +80,10 @@ SEARCH_ID=$(jq -r '.id' .firecrawl/search-react-hooks.json)
Then send feedback. Pick the rating that matches what actually happened:

```bash
# Results were useful, with notes on what was still missing
# Results were useful — web positions 1 and 3 answered the question
firecrawl search-feedback "$SEARCH_ID" \
--rating good \
--valuable-sources '[{"url":"https://react.dev/reference/react/hooks","reason":"Most authoritative"}]' \
--valuable-results "web:1,web:3" \
--missing-content '[
{"topic":"useDeferredValue","description":"No example of useDeferredValue with Suspense"},
{"topic":"useTransition","description":"No coverage of useTransition for routing"}
Expand Down
50 changes: 50 additions & 0 deletions src/__tests__/commands/feedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
parseFeedbackListArg,
parsePageNumbersArg,
} from '../../commands/feedback';
import { parseValuableResultsArg } from '../../commands/search-feedback';
import { getClient } from '../../utils/client';
import { initializeConfig } from '../../utils/config';
import { setupTest, teardownTest } from '../utils/mock-client';
Expand Down Expand Up @@ -207,4 +208,53 @@ describe('feedback parsing', () => {
expect(parsePageNumbersArg('1, 2, bad, -1, 3')).toEqual([1, 2, 3]);
expect(parsePageNumbersArg('[4,5]')).toEqual([4, 5]);
});

it('parses valuable results as source:position pairs', () => {
expect(parseValuableResultsArg('web:1, news:2')).toEqual([
{ source: 'web', position: 1 },
{ source: 'news', position: 2 },
]);
expect(parseValuableResultsArg('images:3')).toEqual([
{ source: 'images', position: 3 },
]);
});

it('parses valuable results from JSON, keeping reasons', () => {
expect(
parseValuableResultsArg(
'[{"source":"web","position":1,"reason":"Answered it"},{"source":"news","position":2}]'
)
).toEqual([
{ source: 'web', position: 1, reason: 'Answered it' },
{ source: 'news', position: 2 },
]);
});

// Each group is numbered from 1 independently, so a bare position does not
// identify a result.
it('rejects valuable results without a source', () => {
expect(() => parseValuableResultsArg('1,3')).toThrow(
'must be "source:position"'
);
expect(() => parseValuableResultsArg('[{"position":1}]')).toThrow(
'source must be one of'
);
});

it('rejects unknown sources and non-positive positions', () => {
expect(() => parseValuableResultsArg('video:1')).toThrow(
'source must be one of'
);
expect(() => parseValuableResultsArg('web:0')).toThrow(
'positions must be integers of 1 or greater'
);
expect(() => parseValuableResultsArg('web:abc')).toThrow(
'positions must be integers of 1 or greater'
);
});

it('returns undefined for empty input', () => {
expect(parseValuableResultsArg(undefined)).toBeUndefined();
expect(parseValuableResultsArg(' ')).toBeUndefined();
});
});
6 changes: 6 additions & 0 deletions src/commands/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { getConfig, isCustomApiUrl, validateConfig } from '../utils/config';
import { getClient } from '../utils/client';
import {
parseMissingContentArg,
parseValuableResultsArg,
parseValuableSourcesArg,
type MissingContentInput,
type SearchFeedbackRating,
type ValuableResultInput,
type ValuableSourceInput,
} from './search-feedback';

Expand All @@ -20,6 +22,7 @@ export interface EndpointFeedbackOptions {
tags?: string[];
note?: string;
valuableSources?: ValuableSourceInput[];
valuableResults?: ValuableResultInput[];
missingContent?: MissingContentInput[];
querySuggestions?: string;
url?: string;
Expand Down Expand Up @@ -207,6 +210,7 @@ export function parseEndpointFeedbackCliOptions(options: {
metadata?: string;
metadataFile?: string;
valuableSources?: string;
valuableResults?: string;
missingContent?: string | string[];
rating?: string;
}) {
Expand All @@ -217,6 +221,7 @@ export function parseEndpointFeedbackCliOptions(options: {
pageNumbers: parsePageNumbersArg(options.pageNumbers),
metadata: parseMetadataArg(options.metadata, options.metadataFile),
valuableSources: parseValuableSourcesArg(options.valuableSources),
valuableResults: parseValuableResultsArg(options.valuableResults),
missingContent: parseMissingContentArg(options.missingContent),
};
}
Expand Down Expand Up @@ -261,6 +266,7 @@ export async function executeEndpointFeedback(
['tags', normalizeList(options.tags)],
['note', options.note],
['valuableSources', options.valuableSources],
['valuableResults', options.valuableResults],

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The PR goal is to forward the API's valuableResultPositions field by raising a new --valuable-result-positions flag that accepts bare 1-indexed positions ("1,3" or [1,3]), parsed like --page-numbers. This change instead reuses the existing --valuable-results flag and parseValuableResultsArg, which only accepts source:position objects (e.g. web:1) and rejects bare positions, then forwards them as the field valuableResults. As a result the stated capability is not delivered: bare-position input errors out and the intended valuableResultPositions field is never sent. Note this does match the in-repo README/help text (which document source:position), so the code and the PR description conflict; confirm which contract the API expects and align the flag, parser, and forwarded field name.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/feedback.ts, line 269:

<comment>The PR goal is to forward the API's `valuableResultPositions` field by raising a new `--valuable-result-positions` flag that accepts bare 1-indexed positions ("1,3" or [1,3]), parsed like `--page-numbers`. This change instead reuses the existing `--valuable-results` flag and `parseValuableResultsArg`, which only accepts `source:position` objects (e.g. `web:1`) and rejects bare positions, then forwards them as the field `valuableResults`. As a result the stated capability is not delivered: bare-position input errors out and the intended `valuableResultPositions` field is never sent. Note this does match the in-repo README/help text (which document `source:position`), so the code and the PR description conflict; confirm which contract the API expects and align the flag, parser, and forwarded field name.</comment>

<file context>
@@ -261,6 +266,7 @@ export async function executeEndpointFeedback(
       ['tags', normalizeList(options.tags)],
       ['note', options.note],
       ['valuableSources', options.valuableSources],
+      ['valuableResults', options.valuableResults],
       ['missingContent', options.missingContent],
       ['querySuggestions', options.querySuggestions],
</file context>
Fix with cubic

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The feedback command serves search, scrape, parse, and map endpoints, but valuableResults is only meaningful for search results, whose positions index into web/images/news groups. This change forwards valuableResults to every endpoint with no guard, so a user passing --valuable-results web:1 on a scrape/parse/map job sends a body referencing groups that do not exist for that job, producing an API error. The CLI help already labels the flag "Search only"; enforce that by rejecting valuableResults when the endpoint is not search.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/feedback.ts, line 269:

<comment>The `feedback` command serves `search`, `scrape`, `parse`, and `map` endpoints, but `valuableResults` is only meaningful for search results, whose positions index into `web`/`images`/`news` groups. This change forwards `valuableResults` to every endpoint with no guard, so a user passing `--valuable-results web:1` on a scrape/parse/map job sends a body referencing groups that do not exist for that job, producing an API error. The CLI help already labels the flag "Search only"; enforce that by rejecting `valuableResults` when the endpoint is not `search`.</comment>

<file context>
@@ -261,6 +266,7 @@ export async function executeEndpointFeedback(
       ['tags', normalizeList(options.tags)],
       ['note', options.note],
       ['valuableSources', options.valuableSources],
+      ['valuableResults', options.valuableResults],
       ['missingContent', options.missingContent],
       ['querySuggestions', options.querySuggestions],
</file context>
Fix with cubic

['missingContent', options.missingContent],
['querySuggestions', options.querySuggestions],
['url', options.url],
Expand Down
110 changes: 110 additions & 0 deletions src/commands/search-feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,28 @@ export interface MissingContentInput {
description?: string;
}

export type SearchResultSource = 'web' | 'images' | 'news';

export const SEARCH_RESULT_SOURCES: readonly SearchResultSource[] = [
'web',
'images',
'news',
];

// Search results come back grouped — data.web, data.images, data.news — and
// each group is numbered from 1 independently, so a position is only
// meaningful alongside the group it indexes into.
export interface ValuableResultInput {
source: SearchResultSource;
position: number;
reason?: string;
}

export interface SearchFeedbackOptions {
searchId: string;
rating: SearchFeedbackRating;
valuableSources?: ValuableSourceInput[];
valuableResults?: ValuableResultInput[];
missingContent?: MissingContentInput[];
querySuggestions?: string;
apiKey?: string;
Expand Down Expand Up @@ -118,6 +136,9 @@ export async function executeSearchFeedback(
...(s.reason ? { reason: s.reason } : {}),
}));
}
if (options.valuableResults && options.valuableResults.length > 0) {
body.valuableResults = options.valuableResults;
}
if (options.missingContent && options.missingContent.length > 0) {
body.missingContent = options.missingContent
.filter((m) => !!m.topic)
Expand Down Expand Up @@ -350,6 +371,95 @@ export function parseValuableSourcesArg(
.map((url) => ({ url }));
}

function isSearchResultSource(value: unknown): value is SearchResultSource {
return (
typeof value === 'string' &&
(SEARCH_RESULT_SOURCES as readonly string[]).includes(value)
);
}

function parsePositionValue(raw: unknown, flag: string): number {
const position = typeof raw === 'string' ? Number(raw.trim()) : raw;
if (
typeof position !== 'number' ||
!Number.isInteger(position) ||
position < 1
) {
throw new Error(`${flag} positions must be integers of 1 or greater.`);
}
return position;
}

// Accepts a compact "source:position" list (e.g. "web:1,news:2") or a JSON
// array of {source, position, reason} entries. The source is always required:
// results are grouped and each group is numbered from 1, so a bare position
// does not identify a result.
export function parseValuableResultsArg(
raw: string | undefined,
flag = '--valuable-results'
): ValuableResultInput[] | undefined {
if (!raw) return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;

const sourceList = SEARCH_RESULT_SOURCES.join(' | ');

if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
throw new Error(
`${flag} must be valid JSON or a comma-separated "source:position" list.`
);
}

const entries = Array.isArray(parsed) ? parsed : [parsed];
const cleaned = entries.map((entry: any) => {
if (!entry || typeof entry !== 'object') {
throw new Error(
`${flag} JSON entries must be objects with a source and a position.`
);
}
if (!isSearchResultSource(entry.source)) {
throw new Error(`${flag} source must be one of: ${sourceList}.`);
}
return {
source: entry.source,
position: parsePositionValue(entry.position, flag),
...(typeof entry.reason === 'string' && entry.reason.trim()
? { reason: entry.reason }
: {}),
};
});
return cleaned.length > 0 ? cleaned : undefined;
}

const cleaned = trimmed
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.map((entry) => {
const separator = entry.lastIndexOf(':');
if (separator === -1) {
throw new Error(
`${flag} entries must be "source:position" (e.g. web:1) — ` +
`results are grouped, so a bare position is ambiguous.`
);
}
const source = entry.slice(0, separator).trim();
if (!isSearchResultSource(source)) {
throw new Error(`${flag} source must be one of: ${sourceList}.`);
}
return {
source,
position: parsePositionValue(entry.slice(separator + 1), flag),
};
});

return cleaned.length > 0 ? cleaned : undefined;
}

// Accepts JSON arrays/objects, "topic: description" strings, comma-
// separated topic lists, or repeated values. Caps at 20 entries.
export function parseMissingContentArg(
Expand Down
Loading
Loading