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 @@ -488,20 +488,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` |

@cubic-dev-ai cubic-dev-ai Bot Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: The README documents only the compact syntax, leaving users of the advertised positionsOrJson form without its required JSON shape. Document the supported {source, position, reason} array here too.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At README.md, line 498:

<comment>The README documents only the compact syntax, leaving users of the advertised `positionsOrJson` form without its required JSON shape. Document the supported `{source, position, reason}` array here too.</comment>

<file context>
@@ -488,20 +488,21 @@ endpoint feedback calls silently.
+| `--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                                             |
</file context>
Suggested change
| `--valuable-results <positionsOrJson>` | Search only: every useful result as `source:position`, e.g. `web:1,news:2` |
| `--valuable-results <positionsOrJson>` | Search only: every useful result as `source:position` (`web:1,news:2`) or JSON array of `{source, position, reason}` |
Fix with cubic

| `--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
9 changes: 5 additions & 4 deletions skills/firecrawl-search/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Keep large search responses in `--json -o` output and select the relevant result
- **`--highlights` on by default:** results are query-relevant excerpts from the page. Use `--no-highlights` for the original snippets.
- **`--scrape` fetches full content** — reuse that content instead of re-scraping result URLs. This saves credits and avoids redundant fetches.
- For large results, use `-o` and bounded local reads when a filesystem is available. Do not dump the full response into context.
- 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`

@cubic-dev-ai cubic-dev-ai Bot Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This jq walks every top-level key of .data, not just the feedback sources. The skill itself documents that the default search returns data.tools (Alexandria matches) and --categories developer adds a developer group, so the snippet emits lines like tools:1 null and developer:1 <url>. Feeding those positions to --valuable-results fails parsing, because parseValuableResultsArg accepts only web | images | news sources. Scope the iteration to the three result groups and coalesce missing URLs so the output stays usable for feedback.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At skills/firecrawl-search/SKILL.md, line 86:

<comment>This jq walks every top-level key of `.data`, not just the feedback sources. The skill itself documents that the default search returns `data.tools` (Alexandria matches) and `--categories developer` adds a developer group, so the snippet emits lines like `tools:1	null` and `developer:1	<url>`. Feeding those positions to `--valuable-results` fails parsing, because `parseValuableResultsArg` accepts only `web | images | news` sources. Scope the iteration to the three result groups and coalesce missing URLs so the output stays usable for feedback.</comment>

<file context>
@@ -83,7 +83,7 @@ Keep large search responses in `--json -o` output and select the relevant result
 - **`--scrape` fetches full content** — reuse that content instead of re-scraping result URLs. This saves credits and avoids redundant fetches.
 - For large results, use `-o` and bounded local reads when a filesystem is available. Do not dump the full response into context.
-- 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`
 
</file context>
Suggested change
- 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`
- Use `jq` to extract URLs **with their source and 1-indexed position** (you'll need both for feedback): `jq -r '.data | {web, images, news} | to_entries[] | .key as $s | .value | to_entries[] | "\($s):\(.key + 1)\t\(.value.url // "-")"' .firecrawl/search.json`
Fix with cubic

- Naming convention: `.firecrawl/search-{query}.json` or `.firecrawl/search-{query}-scraped.json`

## Send feedback after every search (refunds 1 credit)
Expand All @@ -96,9 +96,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, each in its own 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 @@ -113,7 +114,7 @@ Verify the search returned results before reading its `id`. Zero-result searches
if SEARCH_ID=$(jq -er 'select(any(.data[]; length > 0)) | .id' .firecrawl/search-react-hooks.json); then
firecrawl search-feedback "$SEARCH_ID" \
--rating "<good|partial|bad>" \
--valuable-sources '[{"url":"https://react.dev/reference/react/hooks","reason":"Most authoritative"}]' \
--valuable-results "web:1,web:3" \

@cubic-dev-ai cubic-dev-ai Bot Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This reusable example hardcodes web:1,web:3, while its comment tells readers to replace only the rating; copying it for another search can falsely mark results as useful. Use an explicit placeholder for the exhaustive list of actually useful positions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At skills/firecrawl-search/SKILL.md, line 117:

<comment>This reusable example hardcodes `web:1,web:3`, while its comment tells readers to replace only the rating; copying it for another search can falsely mark results as useful. Use an explicit placeholder for the exhaustive list of actually useful positions.</comment>

<file context>
@@ -113,7 +114,7 @@ Verify the search returned results before reading its `id`. Zero-result searches
   firecrawl search-feedback "$SEARCH_ID" \
     --rating "<good|partial|bad>" \
-    --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"}]' \
     --silent &
</file context>
Suggested change
--valuable-results "web:1,web:3" \
--valuable-results "<source:position,...>" \
Fix with cubic

--missing-content '[{"topic":"useDeferredValue","description":"No example of useDeferredValue with Suspense"}]' \
--silent &
fi
Expand Down
92 changes: 92 additions & 0 deletions src/__tests__/commands/feedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import {
parseFeedbackListArg,
parsePageNumbersArg,
} from '../../commands/feedback';
import {
parseValuableResultsArg,
type ValuableResultInput,
} from '../../commands/search-feedback';
import { parseAlexandriaFeedbackArray } from '../../commands/alexandria-feedback';
import { getClient } from '../../utils/client';
import { initializeConfig } from '../../utils/config';
Expand Down Expand Up @@ -153,6 +157,45 @@ describe('executeEndpointFeedback', () => {
});
});

it('rejects --valuable-results on non-search endpoints', async () => {
const result = await executeEndpointFeedback({
endpoint: 'scrape',
jobId: '0193f6c5-1234-7890-abcd-1234567890ab',
rating: 'good',
valuableResults: [{ source: 'web', position: 1 }],
});

expect(result.success).toBe(false);
expect(result.error).toBe(
'--valuable-results is only supported for search feedback.'
);
expect(mockFetch).not.toHaveBeenCalled();
});

it('forwards valuableResults for search feedback', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ success: true, creditsRefunded: 1 }),
});

const valuableResults: ValuableResultInput[] = [
{ source: 'web', position: 1 },
{ source: 'news', position: 2 },
];
const result = await executeEndpointFeedback({
endpoint: 'search',
jobId: '0193f6c5-1234-7890-abcd-1234567890ab',
rating: 'good',
valuableResults,
});

expect(result.success).toBe(true);
const [, request] = mockFetch.mock.calls[0];
expect(JSON.parse(request.body).valuableResults).toEqual(valuableResults);
});

it('treats team opt-out as a disabled success', async () => {
mockFetch.mockResolvedValue({
ok: false,
Expand Down Expand Up @@ -255,6 +298,55 @@ 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();
});
});

describe('parseAlexandriaFeedbackArray capability issues', () => {
Expand Down
15 changes: 15 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 @@ -24,6 +26,7 @@ export interface EndpointFeedbackOptions {
tags?: string[];
note?: string;
valuableSources?: ValuableSourceInput[];
valuableResults?: ValuableResultInput[];
missingContent?: MissingContentInput[];
querySuggestions?: string;
url?: string;
Expand Down Expand Up @@ -211,6 +214,7 @@ export function parseEndpointFeedbackCliOptions(options: {
metadata?: string;
metadataFile?: string;
valuableSources?: string;
valuableResults?: string;
missingContent?: string | string[];
rating?: string;
}) {
Expand All @@ -221,6 +225,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 @@ -263,6 +268,15 @@ export async function executeEndpointFeedback(
if (options.endpoint !== 'alexandria' && !options.jobId) {
throw new Error('Job feedback requires a job ID.');
}
// Positions in `valuableResults` are 1-indexed within a search result
// group (web/images/news), so they are meaningless anywhere else. Reject
// rather than drop: silently ignoring the flag would let a caller believe
// the results were recorded.
if (options.endpoint !== 'search' && options.valuableResults?.length) {
throw new Error(
'--valuable-results is only supported for search feedback.'
);
}
const entries: Array<[string, unknown]> =
options.endpoint === 'alexandria'
? [
Expand All @@ -276,6 +290,7 @@ export async function executeEndpointFeedback(
['tags', normalizeList(options.tags)],
['note', options.note],
['valuableSources', options.valuableSources],
['valuableResults', options.valuableResults],
['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