@bbradar/mcp 0.2.2 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -6,7 +6,7 @@ import { NumericCursorStore } from "./cursorStore.js";
6
6
  import { stableStringify, stripUndefined } from "./json.js";
7
7
  import { FixedWindowRateLimiter } from "./rateLimit.js";
8
8
  import { readArray, readBoolean, readNumber, readObject, readString, sanitizeJson, sanitizeChange, sanitizeProgram, sanitizeRepositoryChange, sanitizeTarget, sanitizeTargetsForExport } from "./sanitize.js";
9
- const SERVER_VERSION = "0.2.2";
9
+ const SERVER_VERSION = "0.2.5";
10
10
  const MAX_PROGRAMS_PER_SEARCH = 100;
11
11
  const MAX_TARGET_EXPORT = 500;
12
12
  const MAX_RECENT_CHANGES = 100;
@@ -21,8 +21,13 @@ const MAX_FIND_TARGET_SAMPLE_PROGRAMS = 10;
21
21
  const MAX_FIND_TARGET_SAMPLES = 20;
22
22
  const MAX_FIND_UPSTREAM_REQUEST_BUDGET = 50;
23
23
  const DEFAULT_FIND_UPSTREAM_REQUEST_BUDGET = 20;
24
+ const DEFAULT_HUNT_PROGRAM_PAGES = 7;
25
+ const MAX_HUNT_RESULTS = 15;
26
+ const MAX_HUNT_TARGETS_PER_RESULT = 10;
27
+ const MAX_BULK_PROGRAM_RESOURCE_LINKS = 5;
24
28
  const DEFAULT_RESOLVE_UPSTREAM_REQUEST_BUDGET = 12;
25
29
  const MAX_ACCEPTED_NUMERIC_LIMIT = 1_000;
30
+ const MAX_UPSTREAM_PAGE = 500;
26
31
  const PROGRAM_COLLECTION_CONCURRENCY = 4;
27
32
  const TARGET_SAMPLE_CONCURRENCY = 3;
28
33
  const DEFAULT_FILTER_DISCOVERY_PAGES = 2;
@@ -50,6 +55,7 @@ const CORE_TOOL_NAMES = new Set([
50
55
  "get_program_targets",
51
56
  "get_recent_changes",
52
57
  "get_repository_changes",
58
+ "find_hunt_targets",
53
59
  "find_repository_hunt_targets",
54
60
  "get_latest_added_targets",
55
61
  "get_opportunities",
@@ -61,6 +67,7 @@ const CORE_TOOL_NAMES = new Set([
61
67
  "export_targets"
62
68
  ]);
63
69
  const TOOL_ENDPOINT_REQUIREMENTS = {
70
+ search_targets: "target_search",
64
71
  get_repository_changes: "repository_changes",
65
72
  find_repository_hunt_targets: "repository_changes",
66
73
  export_targets: "target_export",
@@ -178,6 +185,8 @@ const vdpModeSchema = z.enum(["include", "exclude", "only_likely", "only_known_n
178
185
  const targetMatchModeSchema = z
179
186
  .enum(["smart", "contains", "exact", "suffix", "wildcard"])
180
187
  .describe("How to match target_query. smart is forgiving for URLs, wildcards, target names, and partial words.");
188
+ const apiTargetMatchModeSchema = z.enum(["contains", "exact", "suffix", "wildcard"]);
189
+ const targetChangeTypeSchema = z.enum(["added", "readded", "updated", "removed"]);
181
190
  const programNameActionSchema = z.enum(["new_targets", "targets", "scope_delta", "brief", "target_breakdown"]);
182
191
  const guideTopicSchema = z.enum(["start", "program", "discovery", "freshness", "targets", "compare", "export", "filters"]).default("start");
183
192
  const readOnlyAnnotations = {
@@ -236,6 +245,15 @@ const targetQuerySchema = z
236
245
  })
237
246
  .describe("Target identifier, domain, URL, wildcard, app/package/repo name, or partial target text. URLs and wildcards are normalized automatically.");
238
247
  const isoDateTimeSchema = z.string().datetime({ offset: true });
248
+ const stableSnapshotSchema = z
249
+ .string()
250
+ .trim()
251
+ .min(1)
252
+ .max(1_024)
253
+ .refine((value) => !/[\u0000-\u001F\u007F]/.test(value), {
254
+ message: "Snapshot tokens cannot contain control characters."
255
+ })
256
+ .describe("Optional stable-page token. Send 'start' on page 1, then reuse meta.snapshot with identical filters. Snapshot creation is consistency-first and can be slower; omit it for the fastest live result.");
239
257
  const pageSchema = z.number().int().min(1).default(1);
240
258
  const programsPageSizeSchema = z.number().int().min(1).max(MAX_PROGRAMS_PER_SEARCH).default(20);
241
259
  const recentChangesPageSizeSchema = z.number().int().min(1).max(MAX_RECENT_CHANGES).default(50);
@@ -397,14 +415,16 @@ export function createBbradarServer(client, config) {
397
415
  "Use BBRadar only for passive bug bounty intelligence; never scan or contact targets.",
398
416
  "This server uses the compact core tool profile. Start with get_bbradar_guide when unsure.",
399
417
  "Use resolve_program or run_program_name_action for a named program.",
400
- "Use find_programs for general discovery and find_programs_by_target for reverse target lookup.",
418
+ "Use find_hunt_targets once for requests combining good rewards, broad scope, target quality, and recent target additions. Use find_repository_hunt_targets instead when Git or repository activity is explicitly required.",
419
+ "Use find_programs for lower-level program-only discovery and find_programs_by_target for reverse target lookup.",
401
420
  "Default to bounty-eligible in-scope data unless the user explicitly requests broader results.",
402
421
  "Prefer compact/identifier outputs and disclose sampled ranking scope when present."
403
422
  ]
404
423
  : [
405
424
  "Use BBRadar only for passive bug bounty intelligence; never scan or contact targets.",
406
425
  "When unsure which BBRadar tool to use, call get_bbradar_guide first. It is local-only and cheap.",
407
- "Simple default path: find_program_candidates for discovery, run_program_name_action for one named program, get_program_eligible_targets_by_name for target lists by program name.",
426
+ "Simple default path: find_hunt_targets once for ranked target/program discovery, run_program_name_action for one named program, get_program_eligible_targets_by_name for exhaustive target lists by program name.",
427
+ "Do not repeat program discovery with alternate sorts or fan out get_program_targets across a shortlist; find_hunt_targets already includes exact bounded samples and recent target additions.",
408
428
  "Use advanced tools only after the user's goal is clear or the guide points to them.",
409
429
  "Partial program name + action: run_program_name_action. Partial program name only: resolve_program.",
410
430
  "Default to bounty-eligible in-scope data. Include out-of-scope, ineligible, VDP, disclosure-only, or no-bounty data only when the user explicitly asks.",
@@ -412,7 +432,7 @@ export function createBbradarServer(client, config) {
412
432
  "Self-Hosted programs use Self-Hosted:slug ids and are available through program search, exact detail, and target tools. Respect data_capabilities; do not infer missing opportunity, report-count, target-change, submission-cost, or repository signals.",
413
433
  "Freshness: find_recently_updated_programs, get_latest_added_targets, check_program_new_targets, check_watchlist_new_targets, find_recent_by_type, get_program_scope_delta.",
414
434
  "Repository activity: get_repository_changes defaults to compact program-only events. Seed with after_id, then resume the in-memory per-filter cursor.",
415
- "Discovery: find_program_candidates, find_wildcard_programs, find_recently_added_wildcards, find_low_competition_high_reward, find_stack_matches, find_language_programs, find_target_type_programs, find_reward_programs, find_paid_programs, find_vdp_programs, find_web3_contests, find_low_noise_programs.",
435
+ "Discovery: find_hunt_targets is the one-call default; find_program_candidates, find_wildcard_programs, find_recently_added_wildcards, find_low_competition_high_reward, find_stack_matches, find_language_programs, find_target_type_programs, find_reward_programs, find_paid_programs, find_vdp_programs, find_web3_contests, and find_low_noise_programs are lower-level alternatives.",
416
436
  "Details: get_program_brief, get_program_scope_summary, get_program_target_breakdown, find_programs_by_target, compare_programs_compact.",
417
437
  "Prefer compact/identifier outputs and disclose sampled ranking scope when present."
418
438
  ];
@@ -429,6 +449,7 @@ export function createBbradarServer(client, config) {
429
449
  const feedCursorStore = new NumericCursorStore(config.cursorStatePath, MAX_IN_MEMORY_FEED_CURSORS);
430
450
  const maxProgramsPageSize = client.endpointNumber("programs", "max_page_size", MAX_PROGRAMS_PER_SEARCH);
431
451
  const maxProgramTargetsPageSize = client.endpointNumber("program_targets", "max_page_size", MAX_TARGETS_PER_PROGRAM);
452
+ const maxTargetSearchPageSize = client.endpointNumber("target_search", "max_page_size", MAX_TARGETS_PER_PROGRAM);
432
453
  const maxTargetChangesPageSize = client.endpointNumber("target_changes", "max_page_size", MAX_RECENT_CHANGES);
433
454
  const maxRepositoryChangesPageSize = client.endpointNumber("repository_changes", "max_page_size", MAX_RECENT_CHANGES);
434
455
  const maxTargetExport = client.endpointNumber("target_export", "max_targets", MAX_TARGET_EXPORT);
@@ -437,14 +458,16 @@ export function createBbradarServer(client, config) {
437
458
  const recentChangesPageSizeSchema = z.number().int().min(1).max(maxTargetChangesPageSize).default(Math.min(50, maxTargetChangesPageSize));
438
459
  const repositoryChangesPageSizeSchema = z.number().int().min(1).max(maxRepositoryChangesPageSize).default(Math.min(50, maxRepositoryChangesPageSize));
439
460
  const acceptedTargetLimitSchema = z.number().int().min(1).max(maxProgramTargetsPageSize);
461
+ const targetSearchPageSizeSchema = z.number().int().min(1).max(maxTargetSearchPageSize).default(Math.min(50, maxTargetSearchPageSize));
440
462
  registerTool("search_programs", {
441
463
  title: "Search BBRadar Programs",
442
- description: `Search or list active platform and Self-Hosted programs. page_size max ${MAX_PROGRAMS_PER_SEARCH}.`,
464
+ description: `Search or list active and confirmed paused platform programs plus active Self-Hosted programs. page_size max ${maxProgramsPageSize}. Stable snapshots are opt-in because creating one can be slower than live indexed pagination.`,
443
465
  inputSchema: {
444
466
  search: searchTextSchema.optional().describe("Server-side program name, handle, platform, policy-host, or target search."),
445
467
  platforms: platformListSchema(),
446
468
  tags: tagListSchema(),
447
469
  updated_since: isoDateTimeSchema.optional().describe("ISO datetime."),
470
+ snapshot: stableSnapshotSchema.optional(),
448
471
  vdp_mode: vdpModeSchema.default("exclude").describe("VDP/no-bounty filter mode."),
449
472
  page: pageSchema,
450
473
  page_size: programsPageSizeSchema
@@ -453,6 +476,7 @@ export function createBbradarServer(client, config) {
453
476
  annotations: readOnlyAnnotations
454
477
  }, (args) => runTool("search_programs", config, rateLimiter, async () => {
455
478
  const { vdp_mode, ...filters } = args;
479
+ assertStableSnapshotSupported(client, "programs", filters.snapshot);
456
480
  const api = await client.listPrograms(toQuery(filters));
457
481
  const data = readObject(api.data);
458
482
  const programs = readArray(data?.programs)
@@ -464,6 +488,47 @@ export function createBbradarServer(client, config) {
464
488
  meta: sanitizeJson(data?.meta)
465
489
  });
466
490
  }));
491
+ registerTool("search_targets", {
492
+ title: "Search BBRadar Targets",
493
+ description: `Low-level reverse target search and current target-inventory paging. q may be omitted for inventory traversal. page_size max ${maxTargetSearchPageSize}. Stable snapshots are opt-in and intentionally not used by find_hunt_targets.`,
494
+ inputSchema: {
495
+ q: targetQuerySchema.optional(),
496
+ match_mode: apiTargetMatchModeSchema.default("contains"),
497
+ snapshot: stableSnapshotSchema.optional(),
498
+ platforms: platformListSchema(),
499
+ tags: tagListSchema(),
500
+ include_out_of_scope: z.boolean().default(false),
501
+ include_ineligible: z.boolean().default(false),
502
+ page: pageSchema,
503
+ page_size: targetSearchPageSizeSchema,
504
+ output_mode: targetListModeSchema.default("compact")
505
+ },
506
+ outputSchema: {
507
+ ...apiEnvelopeOutputShape(),
508
+ results: z.array(jsonRecordSchema()).optional(),
509
+ meta: jsonRecordSchema().optional(),
510
+ continuation: jsonRecordSchema().optional()
511
+ },
512
+ annotations: readOnlyAnnotations
513
+ }, (args) => runTool("search_targets", config, rateLimiter, async () => {
514
+ const { output_mode, ...filters } = args;
515
+ assertStableSnapshotSupported(client, "target_search", filters.snapshot);
516
+ const api = await client.searchTargets(toQuery(filters));
517
+ const data = readObject(api.data);
518
+ const results = readArray(data?.results).map((value) => {
519
+ const row = readObject(value) ?? {};
520
+ const program = sanitizeProgram(row.program, config.webBaseUrl);
521
+ const target = sanitizeTarget(row.target);
522
+ return stripUndefined({
523
+ program: formatProgram(program, output_mode === "full" ? "full" : "compact"),
524
+ target: output_mode === "full" ? target : compactTarget(target)
525
+ });
526
+ });
527
+ return applyCollectionContinuation(withApiMetadata(api, {
528
+ results,
529
+ meta: sanitizeJson(data?.meta)
530
+ }), "results", "target search results", continuationStore, config);
531
+ }));
467
532
  registerTool("get_program", {
468
533
  title: "Get BBRadar Program",
469
534
  description: "Get one program by platform:handle program_id, handle, or human program name. Names are resolved automatically when unambiguous.",
@@ -622,7 +687,8 @@ export function createBbradarServer(client, config) {
622
687
  title: "Get Recent Target Changes",
623
688
  description: `List recent target changes. page_size max ${MAX_RECENT_CHANGES}.`,
624
689
  inputSchema: {
625
- change_type: z.enum(["added", "updated", "removed"]).optional(),
690
+ change_type: targetChangeTypeSchema.optional(),
691
+ snapshot: stableSnapshotSchema.optional(),
626
692
  after_id: z.number().int().min(0).optional().describe("Incremental cursor; returns changes after this id in ascending order."),
627
693
  resume_cursor: z.boolean().default(false).describe("Resume the in-memory cursor for this exact normalized filter set. Seed it once with after_id."),
628
694
  max_cursor_pages: z.number().int().min(1).max(10).default(2),
@@ -651,16 +717,24 @@ export function createBbradarServer(client, config) {
651
717
  annotations: readOnlyAnnotations
652
718
  }, (args) => runTool("get_recent_changes", config, rateLimiter, async () => {
653
719
  const { output_mode, resume_cursor, max_cursor_pages, cursor_time_budget_ms, ...filters } = args;
720
+ assertStableSnapshotSupported(client, "target_changes", filters.snapshot);
654
721
  const cursor = resolveFeedCursor(feedCursorStore, "target_changes", filters, resume_cursor);
655
- const { min_target_score, target_opportunity_label, competition_risk, ...upstreamFilters } = filters;
656
- const collected = await collectCursorFeedPages(feedCursorStore, cursor, max_cursor_pages, cursor_time_budget_ms, (afterId) => client.getRecentChanges(toQuery({
722
+ const { min_target_score, target_opportunity_label, competition_risk, snapshot, ...upstreamFilters } = filters;
723
+ const collected = await collectCursorFeedPages(feedCursorStore, cursor, max_cursor_pages, cursor_time_budget_ms, snapshot, (afterId, recoveryProbe, activeSnapshot) => client.getRecentChanges(toQuery({
657
724
  ...upstreamFilters,
658
725
  minTargetScore: min_target_score,
659
726
  targetOpportunityLabel: target_opportunity_label,
660
727
  competitionRisk: competition_risk,
728
+ snapshot: activeSnapshot,
661
729
  after_id: afterId,
662
730
  page: afterId === undefined ? filters.page : 1
663
- })));
731
+ }), recoveryProbe ? { cache: false, coalesce: false } : {}), (startPage, recoveryProbe) => refreshInventoryBaseline((page, pageProbe) => client.searchTargets(toQuery({
732
+ q: upstreamFilters.search,
733
+ platforms: upstreamFilters.platforms,
734
+ tags: upstreamFilters.tags,
735
+ page,
736
+ page_size: client.endpointNumber("target_search", "max_page_size", 100)
737
+ }), pageProbe ? { cache: false, coalesce: false } : {}), "results", max_cursor_pages, cursor_time_budget_ms, startPage, recoveryProbe));
664
738
  const api = collected.lastApi;
665
739
  const data = collected.lastData;
666
740
  const sanitizedMeta = readObject(sanitizeJson(data?.meta));
@@ -675,7 +749,9 @@ export function createBbradarServer(client, config) {
675
749
  pages_fetched: collected.pagesFetched,
676
750
  returned_across_pages: changes.length,
677
751
  cursor_page_budget_exhausted: collected.pageBudgetExhausted,
678
- cursor_time_budget_exhausted: collected.timeBudgetExhausted
752
+ cursor_time_budget_exhausted: collected.timeBudgetExhausted,
753
+ history_gap: collected.gapRecovery !== undefined,
754
+ inventory_baseline: collected.gapRecovery
679
755
  }),
680
756
  facets: sanitizeJson(data?.facets),
681
757
  cursor: collected.cursor,
@@ -687,6 +763,7 @@ export function createBbradarServer(client, config) {
687
763
  description: "Read-only repository push/change feed for direct inspection or polling. Defaults to lightweight program summaries; use after_id for efficient incremental polling. Use find_repository_hunt_targets instead when reward or target-quality ranking is also required.",
688
764
  inputSchema: {
689
765
  after_id: z.number().int().min(0).optional().describe("Incremental cursor; returns events after this id in ascending order."),
766
+ snapshot: stableSnapshotSchema.optional(),
690
767
  resume_cursor: z.boolean().default(false).describe("Resume the in-memory cursor for this exact normalized filter set. Seed it once with after_id."),
691
768
  max_cursor_pages: z.number().int().min(1).max(10).default(2),
692
769
  cursor_time_budget_ms: z.number().int().min(100).max(10_000).default(3_000),
@@ -717,12 +794,20 @@ export function createBbradarServer(client, config) {
717
794
  throw new Error("The negotiated BBRadar API contract does not expose repository changes.");
718
795
  }
719
796
  const { output_mode, resume_cursor, max_cursor_pages, cursor_time_budget_ms, ...filters } = args;
797
+ assertStableSnapshotSupported(client, "repository_changes", filters.snapshot);
720
798
  const cursor = resolveFeedCursor(feedCursorStore, "repository_changes", filters, resume_cursor);
721
- const collected = await collectCursorFeedPages(feedCursorStore, cursor, max_cursor_pages, cursor_time_budget_ms, (afterId) => client.getRepositoryChanges(toQuery({
722
- ...filters,
799
+ const { snapshot, ...upstreamFilters } = filters;
800
+ const collected = await collectCursorFeedPages(feedCursorStore, cursor, max_cursor_pages, cursor_time_budget_ms, snapshot, (afterId, recoveryProbe, activeSnapshot) => client.getRepositoryChanges(toQuery({
801
+ ...upstreamFilters,
802
+ snapshot: activeSnapshot,
723
803
  after_id: afterId,
724
804
  page: afterId === undefined ? filters.page : 1
725
- })));
805
+ }), recoveryProbe ? { cache: false, coalesce: false } : {}), (startPage, recoveryProbe) => refreshInventoryBaseline((page, pageProbe) => client.getRepositoryChanges(toQuery({
806
+ ...upstreamFilters,
807
+ after_id: undefined,
808
+ page,
809
+ page_size: client.endpointNumber("repository_changes", "max_page_size", 100)
810
+ }), pageProbe ? { cache: false, coalesce: false } : {}), "results", max_cursor_pages, cursor_time_budget_ms, startPage, recoveryProbe));
726
811
  const api = collected.lastApi;
727
812
  const data = collected.lastData;
728
813
  return applyCollectionContinuation(withApiMetadata(api, {
@@ -733,7 +818,9 @@ export function createBbradarServer(client, config) {
733
818
  pages_fetched: collected.pagesFetched,
734
819
  returned_across_pages: collected.results.length,
735
820
  cursor_page_budget_exhausted: collected.pageBudgetExhausted,
736
- cursor_time_budget_exhausted: collected.timeBudgetExhausted
821
+ cursor_time_budget_exhausted: collected.timeBudgetExhausted,
822
+ history_gap: collected.gapRecovery !== undefined,
823
+ inventory_baseline: collected.gapRecovery
737
824
  }),
738
825
  facets: sanitizeJson(data?.facets),
739
826
  available_facets: sanitizeJson(data?.available_facets),
@@ -741,9 +828,54 @@ export function createBbradarServer(client, config) {
741
828
  source_requests: collected.sourceRequests.length > 1 ? collected.sourceRequests : undefined
742
829
  }), "repository_changes", "repository changes", continuationStore, config);
743
830
  }));
831
+ registerTool("find_hunt_targets", {
832
+ title: "Find Best Hunt Targets",
833
+ description: "Preferred one-call path for requests combining good rewards, broad Web2/Web3 scope, target quality, competition, and recently added targets. Returns a final ranked shortlist with exact bounded target samples and recent additions; do not fan out into repeated find_programs/get_program_targets calls. Self-Hosted is supported through current inventory and automatically skips its unavailable change feed. Use find_repository_hunt_targets instead only when Git or repository activity is explicitly requested.",
834
+ inputSchema: {
835
+ platforms: platformListSchema(),
836
+ exclude_platforms: stringListSchema("Platforms to exclude from the final shortlist."),
837
+ tags: tagListSchema(),
838
+ scope_tags: scopeTagListSchema(),
839
+ target_types: targetTypeListSchema(),
840
+ language_tags: languageTagListSchema(),
841
+ surface_mode: z.enum(["any", "web2", "web3"]).default("any"),
842
+ min_bounty_max: z.number().int().min(0).optional(),
843
+ require_bounty: z.boolean().default(true),
844
+ max_public_report_count: z.number().int().min(0).optional(),
845
+ include_unknown_report_count: z.boolean().default(true),
846
+ min_eligible_targets: z.number().int().min(0).default(3),
847
+ min_total_targets: z.number().int().min(0).optional(),
848
+ min_wildcards: z.number().int().min(0).max(1000).default(0),
849
+ min_added_7d: z.number().int().min(0).optional(),
850
+ fresh_launch_days: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).optional(),
851
+ updated_since: isoDateTimeSchema.optional(),
852
+ min_target_score: z.number().int().min(0).max(100).optional(),
853
+ target_opportunity_label: targetOpportunityLabelSchema.optional(),
854
+ competition_risk: competitionRiskSchema.optional(),
855
+ include_recent_changes: z.boolean().default(true),
856
+ recent_target_days: z.number().int().min(1).max(365).default(30),
857
+ recent_changes_page_size: recentChangesPageSizeSchema.default(Math.min(MAX_RECENT_CHANGES, maxTargetChangesPageSize)),
858
+ max_program_pages: z.number().int().min(1).max(MAX_FIND_PROGRAM_PAGES).default(DEFAULT_HUNT_PROGRAM_PAGES),
859
+ max_results: z.number().int().min(1).max(MAX_HUNT_RESULTS).default(10),
860
+ targets_per_result: z.number().int().min(1).max(MAX_HUNT_TARGETS_PER_RESULT).default(5),
861
+ ranking_mode: z.enum(["balanced", "highest_reward", "widest_scope", "freshest_targets", "lowest_competition"]).default("balanced"),
862
+ upstream_request_budget: z.number().int().min(2).max(MAX_FIND_UPSTREAM_REQUEST_BUDGET).default(DEFAULT_FIND_UPSTREAM_REQUEST_BUDGET)
863
+ },
864
+ outputSchema: {
865
+ ...apiEnvelopeOutputShape(),
866
+ matches: z.array(jsonRecordSchema()).optional(),
867
+ filters: jsonRecordSchema().optional(),
868
+ errors: z.array(jsonRecordSchema()).optional(),
869
+ upstream_budget: jsonRecordSchema().optional(),
870
+ ranking_scope: jsonRecordSchema().optional(),
871
+ result_guidance: jsonRecordSchema().optional(),
872
+ meta: jsonRecordSchema().optional()
873
+ },
874
+ annotations: readOnlyAnnotations
875
+ }, (args) => runTool("find_hunt_targets", config, rateLimiter, async () => findHuntTargets(client, config, args)));
744
876
  registerTool("find_repository_hunt_targets", {
745
877
  title: "Find Repository-Aware Hunt Targets",
746
- description: "Fast one-call path for requests that combine recent repository activity with program rewards and target quality. Fetches program rankings and repository changes in parallel, joins them locally, and works with arbitrary platforms, Web2/Web3 modes, reward thresholds, target types, languages, and freshness criteria.",
878
+ description: "Fast one-call path for requests that combine recent repository activity with program rewards and target quality. Fetches program rankings and repository changes in parallel, joins them locally, and works with arbitrary supported platform catalogs, Web2/Web3 modes, reward thresholds, target types, languages, and freshness criteria. Self-Hosted has no repository stream and is rejected without upstream work.",
747
879
  inputSchema: {
748
880
  platforms: platformListSchema(),
749
881
  exclude_platforms: stringListSchema("Platforms to exclude from final matches."),
@@ -864,7 +996,7 @@ export function createBbradarServer(client, config) {
864
996
  inputSchema: {
865
997
  program_id: programIdentifierSchema,
866
998
  since: isoDateTimeSchema.optional(),
867
- change_type: z.enum(["added", "updated", "removed"]).optional(),
999
+ change_type: targetChangeTypeSchema.optional(),
868
1000
  target_type: filterValueSchema().optional(),
869
1001
  language_tags: languageTagListSchema(),
870
1002
  include_removed: z.boolean().default(true),
@@ -888,7 +1020,7 @@ export function createBbradarServer(client, config) {
888
1020
  title: "Get Recent Target Activity",
889
1021
  description: "Recent target changes grouped by program.",
890
1022
  inputSchema: {
891
- change_type: z.enum(["added", "updated", "removed"]).optional(),
1023
+ change_type: targetChangeTypeSchema.optional(),
892
1024
  target_type: filterValueSchema().optional(),
893
1025
  include_removed: z.boolean().default(true),
894
1026
  include_out_of_scope: z.boolean().default(false),
@@ -989,7 +1121,7 @@ export function createBbradarServer(client, config) {
989
1121
  }));
990
1122
  registerTool("find_programs", {
991
1123
  title: "Find BBRadar Programs",
992
- description: "Ranked program discovery with positive and negative platform/surface filters and compact output. Strict-filter retries reuse cached upstream pages, so relax one local filter at a time when a narrow query returns no candidates.",
1124
+ description: "Lower-level program-only discovery. For normal 'find good targets/programs' requests, prefer one find_hunt_targets call because it already combines ranking, exact bounded target samples, and recent additions. Use this tool when raw program filtering without target enrichment is specifically needed; keep output_mode=compact and avoid parallel alternate-sort calls.",
993
1125
  inputSchema: {
994
1126
  platforms: platformListSchema(),
995
1127
  exclude_platforms: stringListSchema("Platforms to exclude after retrieval. Use list_filters first when the user asks for a geographic/platform exclusion without naming exact platform values."),
@@ -1374,7 +1506,7 @@ export function createBbradarServer(client, config) {
1374
1506
  description: "Recent changes by target type grouped by program.",
1375
1507
  inputSchema: {
1376
1508
  target_type: filterValueSchema(),
1377
- change_type: z.enum(["added", "updated", "removed"]).default("added"),
1509
+ change_type: targetChangeTypeSchema.default("added"),
1378
1510
  include_removed: z.boolean().default(false),
1379
1511
  include_out_of_scope: z.boolean().default(false),
1380
1512
  include_ineligible: z.boolean().default(false),
@@ -1881,10 +2013,10 @@ function bbradarGuide(topic, profile, client) {
1881
2013
  }
1882
2014
  const primaryEntryPoints = [
1883
2015
  {
1884
- tool: "find_program_candidates",
1885
- use_when: "The user wants good programs to hunt, broad discovery, or does not name one exact program.",
2016
+ tool: "find_hunt_targets",
2017
+ use_when: "The user wants good targets or programs ranked by rewards, scope, competition, target quality, or recent target additions without requiring repository activity.",
1886
2018
  minimum_arguments: {},
1887
- useful_arguments: ["platforms", "target_types", "language_tags", "min_bounty_max", "max_public_report_count", "fresh_launch_days", "has_api_scope"]
2019
+ useful_arguments: ["platforms", "surface_mode", "target_types", "language_tags", "min_bounty_max", "max_public_report_count", "recent_target_days", "ranking_mode"]
1888
2020
  },
1889
2021
  {
1890
2022
  tool: "find_repository_hunt_targets",
@@ -1913,13 +2045,13 @@ function bbradarGuide(topic, profile, client) {
1913
2045
  const decisionTree = [
1914
2046
  {
1915
2047
  user_goal: "find programs to hunt",
1916
- first_tool: "find_program_candidates",
1917
- then: "Use get_program_brief on the best program_id if the user wants details."
2048
+ first_tool: "find_hunt_targets",
2049
+ then: "Treat its ranked matches, exact bounded target samples, and recent additions as final unless the user explicitly requests an exhaustive scope dump for one selected program."
1918
2050
  },
1919
2051
  {
1920
2052
  user_goal: "Web2 programs with broad scope, good rewards, recent target activity, and excluded platforms",
1921
- first_tool: "list_filters",
1922
- then: "Resolve the excluded platform names, then call find_programs with exclude_platforms, exclude_web3=true, exclude_non_web=true, reward/scope/freshness thresholds, and sort_by=most_new_targets. If strict local filters return zero, relax one threshold and retry; cached source pages make the retry cheap."
2053
+ first_tool: "find_hunt_targets",
2054
+ then: "Call once with surface_mode=web2 and the requested platforms/exclusions. Do not split the platforms or ranking dimensions into separate calls."
1923
2055
  },
1924
2056
  {
1925
2057
  user_goal: "named program summary",
@@ -1991,7 +2123,7 @@ function bbradarGuide(topic, profile, client) {
1991
2123
  }
1992
2124
  ];
1993
2125
  const advancedTools = {
1994
- discovery: ["find_wildcard_programs", "find_low_competition_high_reward", "find_stack_matches", "find_language_programs", "find_target_type_programs", "find_reward_programs", "find_paid_programs", "find_web3_contests", "find_low_noise_programs"],
2126
+ discovery: ["find_hunt_targets", "find_wildcard_programs", "find_low_competition_high_reward", "find_stack_matches", "find_language_programs", "find_target_type_programs", "find_reward_programs", "find_paid_programs", "find_web3_contests", "find_low_noise_programs"],
1995
2127
  freshness: ["find_recently_added_wildcards", "find_recently_updated_programs", "find_fresh_hunt_candidates", "get_latest_added_targets", "get_recent_target_activity", "get_program_scope_delta", "check_watchlist_new_targets"],
1996
2128
  details: ["get_program_brief", "get_program_scope_summary", "get_program_target_breakdown", "find_programs_by_target"],
1997
2129
  exports: ["export_targets"]
@@ -2064,9 +2196,10 @@ function bbradarGuide(topic, profile, client) {
2064
2196
  function coreBbradarGuide(topic, client) {
2065
2197
  const decisionTree = [
2066
2198
  {
2067
- user_goal: "find programs to hunt",
2068
- first_tool: "find_programs",
2069
- arguments: { max_results: 10, output_mode: "compact" }
2199
+ user_goal: "find good hunt targets or programs using rewards, scope, competition, target quality, or recent target additions",
2200
+ first_tool: "find_hunt_targets",
2201
+ arguments: { max_results: 10, ranking_mode: "balanced" },
2202
+ then: "Use this single result as the final shortlist; do not repeat find_programs with alternate sorts or fetch each program's targets."
2070
2203
  },
2071
2204
  {
2072
2205
  user_goal: "named program targets",
@@ -2102,7 +2235,7 @@ function coreBbradarGuide(topic, client) {
2102
2235
  {
2103
2236
  user_goal: "unknown filter values",
2104
2237
  first_tool: "list_filters",
2105
- then: "Reuse observed platforms, tags, target types, and language tags in find_programs."
2238
+ then: "Reuse observed platforms, tags, target types, and language tags in one find_hunt_targets call."
2106
2239
  }
2107
2240
  ];
2108
2241
  return {
@@ -2118,6 +2251,7 @@ function coreBbradarGuide(topic, client) {
2118
2251
  require_bounty: true
2119
2252
  },
2120
2253
  primary_entry_points: [
2254
+ "find_hunt_targets",
2121
2255
  "find_programs",
2122
2256
  "find_repository_hunt_targets",
2123
2257
  "run_program_name_action",
@@ -4353,21 +4487,50 @@ function latestAddedProgramMatches(change, input, platformFilters) {
4353
4487
  return true;
4354
4488
  }
4355
4489
  async function getProgramTargetsPayload(client, input, programId) {
4356
- const api = await client.getProgramTargets(programId, {
4357
- search: input.search,
4358
- target_type: input.target_type,
4359
- eligibility: input.include_ineligible ? "all" : "eligible",
4360
- minTargetScore: input.min_target_score,
4361
- targetOpportunityLabel: input.target_opportunity_label,
4362
- competitionRisk: input.competition_risk,
4363
- page: 1,
4364
- page_size: client.endpointNumber("program_targets", "max_page_size", MAX_TARGETS_PER_PROGRAM)
4365
- });
4366
- const data = readObject(api.data);
4367
- const upstreamMeta = readObject(data?.meta);
4368
- const rawTargets = readArray(data?.targets);
4369
- const sanitizedTargets = rawTargets.map(sanitizeTarget).filter((target) => targetHasAllowedScope(target, input));
4370
- const targets = sanitizedTargets.slice(input.offset, input.offset + input.limit);
4490
+ const pageSize = client.endpointNumber("program_targets", "max_page_size", MAX_TARGETS_PER_PROGRAM);
4491
+ // `eligibility=eligible` is an API-level in-scope bounty-eligibility filter,
4492
+ // so the common eligible-only path needs no second local scope pass.
4493
+ const directOffsetPaging = !input.include_ineligible || (!input.strict_scope_filter && input.include_out_of_scope);
4494
+ const collected = [];
4495
+ let matchedSeen = directOffsetPaging ? Math.floor(input.offset / pageSize) * pageSize : 0;
4496
+ let page = directOffsetPaging ? Math.floor(input.offset / pageSize) + 1 : 1;
4497
+ const firstPage = page;
4498
+ let api;
4499
+ let upstreamMeta;
4500
+ let upstreamHasMore = false;
4501
+ do {
4502
+ api = await client.getProgramTargets(programId, {
4503
+ search: input.search,
4504
+ target_type: input.target_type,
4505
+ eligibility: input.include_ineligible ? "all" : "eligible",
4506
+ minTargetScore: input.min_target_score,
4507
+ targetOpportunityLabel: input.target_opportunity_label,
4508
+ competitionRisk: input.competition_risk,
4509
+ page,
4510
+ page_size: pageSize
4511
+ });
4512
+ const data = readObject(api.data);
4513
+ upstreamMeta = readObject(data?.meta);
4514
+ for (const target of readArray(data?.targets).map(sanitizeTarget)) {
4515
+ if (!targetHasAllowedScope(target, input))
4516
+ continue;
4517
+ if (matchedSeen >= input.offset && collected.length < input.limit)
4518
+ collected.push(target);
4519
+ matchedSeen += 1;
4520
+ }
4521
+ upstreamHasMore = readBoolean(upstreamMeta?.has_more) === true;
4522
+ const totalPages = readNumber(upstreamMeta?.total_pages);
4523
+ page += 1;
4524
+ if (totalPages !== undefined && page > totalPages) {
4525
+ upstreamHasMore = false;
4526
+ }
4527
+ } while (upstreamHasMore &&
4528
+ page <= MAX_UPSTREAM_PAGE &&
4529
+ collected.length < input.limit);
4530
+ if (!api) {
4531
+ throw new Error("No program target response was collected.");
4532
+ }
4533
+ const targets = collected;
4371
4534
  return withApiMetadata(api, {
4372
4535
  program_id: programId,
4373
4536
  targets: formatTargetList(targets, input.output_mode),
@@ -4375,12 +4538,16 @@ async function getProgramTargetsPayload(client, input, programId) {
4375
4538
  offset: input.offset,
4376
4539
  limit: input.limit,
4377
4540
  output_mode: input.output_mode,
4378
- total_active_targets: readNumber(upstreamMeta?.total) ?? rawTargets.length,
4379
- total_after_filters: sanitizedTargets.length,
4541
+ total_active_targets: readNumber(upstreamMeta?.total) ?? matchedSeen,
4542
+ total_after_filters: upstreamHasMore ? undefined : matchedSeen,
4380
4543
  returned: targets.length,
4381
- has_more: input.offset + input.limit < sanitizedTargets.length || readBoolean(upstreamMeta?.has_more) === true,
4382
- upstream_has_more: readBoolean(upstreamMeta?.has_more),
4383
- possibly_incomplete: readBoolean(upstreamMeta?.has_more) === true,
4544
+ has_more: upstreamHasMore || matchedSeen > input.offset + targets.length,
4545
+ upstream_has_more: upstreamHasMore,
4546
+ possibly_incomplete: upstreamHasMore,
4547
+ upstream_pages_fetched: page - firstPage,
4548
+ upstream_start_page: firstPage,
4549
+ direct_offset_paging: directOffsetPaging,
4550
+ upstream_page_limit_reached: upstreamHasMore && page > MAX_UPSTREAM_PAGE,
4384
4551
  data_capabilities: readObject(upstreamMeta?.data_capabilities),
4385
4552
  target_source: "program_targets"
4386
4553
  }
@@ -5633,7 +5800,7 @@ function previewExportText(payload) {
5633
5800
  }
5634
5801
  function collectResourceLinks(payload) {
5635
5802
  const links = new Map();
5636
- for (const program of extractProgramsFromPayload(payload)) {
5803
+ for (const program of extractProgramsFromPayload(payload).slice(0, MAX_BULK_PROGRAM_RESOURCE_LINKS)) {
5637
5804
  const id = stringField(program, "id");
5638
5805
  const resourceLinks = readObject(program.resource_links);
5639
5806
  const programUri = stringField(resourceLinks, "program");
@@ -5735,6 +5902,8 @@ function compactProgram(program) {
5735
5902
  name: stringField(program, "name"),
5736
5903
  platform: stringField(program, "platform"),
5737
5904
  catalog: stringField(program, "catalog"),
5905
+ program_status: stringField(program, "program_status"),
5906
+ is_active: readBoolean(program.is_active),
5738
5907
  program_type: stringField(program, "program_type"),
5739
5908
  platform_url: stringField(program, "platform_url"),
5740
5909
  bbradar_url: stringField(program, "bbradar_url"),
@@ -5753,6 +5922,9 @@ function compactProgram(program) {
5753
5922
  last_updated: stringField(program, "last_updated"),
5754
5923
  targets_updated_at: stringField(program, "targets_updated_at"),
5755
5924
  scope_summary: readObject(program.scope_summary),
5925
+ match_qualities: Array.isArray(program.match_qualities)
5926
+ ? readStringArrayField(program, "match_qualities")
5927
+ : undefined,
5756
5928
  resource_links: readObject(program.resource_links)
5757
5929
  });
5758
5930
  }
@@ -5813,6 +5985,7 @@ function formatRepositoryChange(change, mode) {
5813
5985
  })
5814
5986
  : undefined,
5815
5987
  changes_url: stringField(change, "changes_url"),
5988
+ comparison: sanitizeJson(change.comparison),
5816
5989
  programs: readArray(change.programs).map((program) => formatProgram(readObject(program) ?? {}, "compact")),
5817
5990
  targets: Array.isArray(change.targets)
5818
5991
  ? change.targets.map((target) => compactTarget(readObject(target) ?? {}))
@@ -5981,15 +6154,15 @@ function candidateTradeoffs(candidate) {
5981
6154
  }
5982
6155
  function candidateNextAction(candidate) {
5983
6156
  if (candidate.wildcardCount > 0) {
5984
- return "Review in-scope wildcard targets with get_program_targets and keep follow-up analysis passive and BBRadar-data-backed.";
6157
+ return "Use included target samples for shortlisting; call get_program_targets only if the user explicitly requests the selected program's exhaustive wildcard scope.";
5985
6158
  }
5986
6159
  if (candidateHasApiSurface(candidate)) {
5987
- return "Review API targets with get_program_targets and summarize the allowed API scope from BBRadar data.";
6160
+ return "Use included target samples for shortlisting; call get_program_targets only if the user explicitly requests the selected program's exhaustive API scope.";
5988
6161
  }
5989
6162
  if (candidateHasWebSurface(candidate)) {
5990
- return "Review web/domain targets with get_program_targets and summarize freshness, reward, and report-count signals.";
6163
+ return "Use included target samples for shortlisting; call get_program_targets only if the user explicitly requests the selected program's exhaustive web scope.";
5991
6164
  }
5992
- return "Fetch exact scope with get_program_targets before making recommendations.";
6165
+ return "The program-level ranking is sufficient for a shortlist; fetch exact scope only if the user requests exhaustive details for this program.";
5993
6166
  }
5994
6167
  function targetSurfaceScore(candidate) {
5995
6168
  return candidate.targetSurfaceScore;
@@ -6294,13 +6467,595 @@ function clampLimit(name, value, max, warnings) {
6294
6467
  warnings.push(`${name} was clamped from ${value} to ${max}.`);
6295
6468
  return max;
6296
6469
  }
6470
+ async function findHuntTargets(client, config, input) {
6471
+ const warnings = [];
6472
+ const errors = [];
6473
+ const selfHostedOnly = isSelfHostedOnlyPlatformSelection(input.platforms);
6474
+ const targetChangesEndpointSupported = client.supportsEndpoint("target_changes");
6475
+ const recentChangesEnabled = input.include_recent_changes &&
6476
+ !selfHostedOnly &&
6477
+ targetChangesEndpointSupported;
6478
+ const desiredRecentChangeQueries = recentChangesEnabled ? huntRecentChangeQueries(input) : [];
6479
+ const recentChangeQueries = desiredRecentChangeQueries.slice(0, Math.max(0, input.upstream_request_budget - 2));
6480
+ const recentRequestReserve = recentChangeQueries.length;
6481
+ const candidatePoolSize = Math.min(MAX_FIND_PROGRAM_RESULTS, Math.max(input.max_results, input.max_results * 3));
6482
+ const programRequestBudget = Math.min(input.max_program_pages, Math.max(1, Math.ceil((input.upstream_request_budget - recentRequestReserve) / 2)));
6483
+ const programInput = {
6484
+ platforms: input.platforms,
6485
+ exclude_platforms: input.exclude_platforms,
6486
+ tags: input.tags,
6487
+ scope_tags: input.scope_tags,
6488
+ target_types: input.target_types,
6489
+ language_tags: input.language_tags,
6490
+ web3: input.surface_mode === "web3",
6491
+ exclude_web3: input.surface_mode === "web2",
6492
+ opportunity_levels: [],
6493
+ updated_since: input.updated_since,
6494
+ min_bounty_min: undefined,
6495
+ min_bounty_max: input.min_bounty_max,
6496
+ max_bounty_max: undefined,
6497
+ require_bounty: input.require_bounty,
6498
+ max_public_report_count: input.max_public_report_count,
6499
+ require_known_report_count: false,
6500
+ include_unknown_report_count: input.include_unknown_report_count,
6501
+ exclude_ctf: true,
6502
+ exclude_mobile_only: input.surface_mode === "web2",
6503
+ exclude_non_web: input.surface_mode === "web2",
6504
+ has_api_scope: false,
6505
+ contest_like: false,
6506
+ vdp_mode: "exclude",
6507
+ fresh_launch_days: input.fresh_launch_days,
6508
+ min_wildcards: input.min_wildcards,
6509
+ min_eligible_targets: input.min_eligible_targets,
6510
+ min_total_targets: input.min_total_targets,
6511
+ min_added_24h: undefined,
6512
+ min_added_7d: input.min_added_7d,
6513
+ max_pages: input.max_program_pages,
6514
+ max_results: candidatePoolSize,
6515
+ sort_by: huntProgramSort(input.ranking_mode),
6516
+ include_target_samples: false,
6517
+ target_sample_programs: 0,
6518
+ target_sample_size: 1,
6519
+ only_in_scope_targets: true,
6520
+ only_bounty_eligible_targets: true,
6521
+ target_samples_only_wildcards: false,
6522
+ output_mode: "compact",
6523
+ upstream_request_budget: programRequestBudget
6524
+ };
6525
+ if (input.include_recent_changes && selfHostedOnly) {
6526
+ warnings.push("Self-Hosted does not expose target-change history; recent target evidence was derived only from current target-inventory timestamps.");
6527
+ }
6528
+ else if (input.include_recent_changes && !recentChangesEnabled) {
6529
+ warnings.push("The negotiated API contract does not expose target changes; ranked current programs without recent-change enrichment.");
6530
+ }
6531
+ else if (recentChangeQueries.length < desiredRecentChangeQueries.length) {
6532
+ warnings.push(`Recent target-change queries were reduced from ${desiredRecentChangeQueries.length} to ${recentChangeQueries.length} to preserve the upstream request budget.`);
6533
+ }
6534
+ const [programPayload, recentResult] = await Promise.all([
6535
+ findPrograms(client, config, programInput),
6536
+ recentChangesEnabled
6537
+ ? fetchHuntRecentChanges(client, config, input, recentChangeQueries)
6538
+ : Promise.resolve({ changes: [], sourceRequests: [], requestsUsed: 0, errors: [] })
6539
+ ]);
6540
+ const programWarnings = readArray(programPayload.warnings).filter((warning) => typeof warning === "string");
6541
+ warnings.push(...programWarnings);
6542
+ errors.push(...recentResult.errors);
6543
+ const programBudget = readObject(programPayload.upstream_budget);
6544
+ const programRequestsUsed = readNumber(programBudget?.used) ?? 0;
6545
+ const targetRequestBudget = Math.max(0, input.upstream_request_budget - programRequestsUsed - recentResult.requestsUsed);
6546
+ const programEntries = readArray(programPayload.programs)
6547
+ .map(readObject)
6548
+ .filter((program) => program !== undefined);
6549
+ const recentByProgram = groupRecentHuntChanges(recentResult.changes, input);
6550
+ const preliminaryRankedEntries = programEntries
6551
+ .map((entry) => ({
6552
+ entry,
6553
+ ranking: buildHuntTargetMatch(entry, new Map(), recentByProgram, input)
6554
+ }))
6555
+ .sort((left, right) => compareHuntTargetMatches(left.ranking, right.ranking, input.ranking_mode));
6556
+ const sampledProgramCount = Math.min(input.max_results, preliminaryRankedEntries.length, targetRequestBudget);
6557
+ const finalistEntries = preliminaryRankedEntries
6558
+ .slice(0, sampledProgramCount)
6559
+ .map((entry) => entry.entry);
6560
+ const targetResult = await collectHuntTargetSamples(client, finalistEntries, input);
6561
+ errors.push(...targetResult.errors);
6562
+ if (sampledProgramCount < Math.min(input.max_results, programEntries.length)) {
6563
+ warnings.push(`The upstream request budget limited the final exact target shortlist to ${sampledProgramCount} of ${Math.min(input.max_results, programEntries.length)} requested results.`);
6564
+ }
6565
+ const ranked = finalistEntries
6566
+ .map((entry) => buildHuntTargetMatch(entry, targetResult.targetsByProgram, recentByProgram, input))
6567
+ .sort((left, right) => compareHuntTargetMatches(left, right, input.ranking_mode))
6568
+ .slice(0, input.max_results);
6569
+ const matches = ranked.map((entry, index) => ({
6570
+ rank: index + 1,
6571
+ ...entry.payload
6572
+ }));
6573
+ const targetRequestsUsed = targetResult.requestsUsed;
6574
+ const totalRequestsUsed = programRequestsUsed + recentResult.requestsUsed + targetRequestsUsed;
6575
+ return stripUndefined({
6576
+ request_id: randomUUID(),
6577
+ fetched_at: new Date().toISOString(),
6578
+ source_requests: [
6579
+ ...readArray(programPayload.source_requests),
6580
+ ...recentResult.sourceRequests,
6581
+ ...targetResult.sourceRequests
6582
+ ],
6583
+ warnings: uniqueStrings(warnings).length > 0 ? uniqueStrings(warnings) : undefined,
6584
+ errors: errors.length > 0 ? errors : undefined,
6585
+ filters: stripUndefined({
6586
+ platforms: input.platforms.length > 0 ? input.platforms : undefined,
6587
+ exclude_platforms: input.exclude_platforms.length > 0 ? input.exclude_platforms : undefined,
6588
+ tags: input.tags.length > 0 ? input.tags : undefined,
6589
+ scope_tags: input.scope_tags.length > 0 ? input.scope_tags : undefined,
6590
+ target_types: input.target_types.length > 0 ? input.target_types : undefined,
6591
+ language_tags: input.language_tags.length > 0 ? input.language_tags : undefined,
6592
+ surface_mode: input.surface_mode,
6593
+ min_bounty_max: input.min_bounty_max,
6594
+ require_bounty: input.require_bounty,
6595
+ max_public_report_count: input.max_public_report_count,
6596
+ min_eligible_targets: input.min_eligible_targets,
6597
+ min_total_targets: input.min_total_targets,
6598
+ min_wildcards: input.min_wildcards,
6599
+ min_added_7d: input.min_added_7d,
6600
+ fresh_launch_days: input.fresh_launch_days,
6601
+ updated_since: input.updated_since,
6602
+ min_target_score: input.min_target_score,
6603
+ target_opportunity_label: input.target_opportunity_label,
6604
+ competition_risk: input.competition_risk,
6605
+ recent_target_days: input.include_recent_changes ? input.recent_target_days : undefined,
6606
+ ranking_mode: input.ranking_mode
6607
+ }),
6608
+ upstream_budget: {
6609
+ initial: input.upstream_request_budget,
6610
+ used: totalRequestsUsed,
6611
+ remaining: Math.max(0, input.upstream_request_budget - totalRequestsUsed)
6612
+ },
6613
+ ranking_scope: stripUndefined({
6614
+ ...(readObject(programPayload.ranking_scope) ?? {}),
6615
+ single_program_catalog_pass: true,
6616
+ recent_change_window_days: input.include_recent_changes ? input.recent_target_days : undefined,
6617
+ recent_change_page_size: input.include_recent_changes ? input.recent_changes_page_size : undefined
6618
+ }),
6619
+ result_guidance: {
6620
+ final_shortlist: true,
6621
+ follow_up_calls_required: false,
6622
+ instruction: "Use these matches as the final shortlist. Exact bounded target samples and recent additions are already included; call get_program_targets only if the user explicitly requests an exhaustive scope dump for one selected program."
6623
+ },
6624
+ matches,
6625
+ meta: {
6626
+ returned: matches.length,
6627
+ program_candidates: programEntries.length,
6628
+ exact_target_programs_sampled: targetResult.targetsByProgram.size,
6629
+ recent_changes_scanned: recentResult.changes.length,
6630
+ recent_programs_matched: recentByProgram.size,
6631
+ recent_change_catalog_supported: !selfHostedOnly && targetChangesEndpointSupported,
6632
+ model_round_trips_expected: 1,
6633
+ parallel_recent_change_fetch: recentChangeQueries.length > 0,
6634
+ bulk_resource_links_suppressed: true,
6635
+ partial_success: errors.length > 0
6636
+ }
6637
+ });
6638
+ }
6639
+ function huntProgramSort(mode) {
6640
+ if (mode === "highest_reward") {
6641
+ return "highest_reward";
6642
+ }
6643
+ if (mode === "widest_scope") {
6644
+ return "most_eligible_targets";
6645
+ }
6646
+ if (mode === "freshest_targets") {
6647
+ return "most_new_targets";
6648
+ }
6649
+ if (mode === "lowest_competition") {
6650
+ return "lowest_reports";
6651
+ }
6652
+ return "best_hunt_value";
6653
+ }
6654
+ async function fetchHuntRecentChanges(client, config, input, targetTypes) {
6655
+ const results = await Promise.all(targetTypes.map(async (targetType) => {
6656
+ try {
6657
+ const api = await client.getRecentChanges(toQuery({
6658
+ change_type: "added",
6659
+ minTargetScore: input.min_target_score,
6660
+ targetOpportunityLabel: input.target_opportunity_label,
6661
+ competitionRisk: input.competition_risk,
6662
+ include_removed: false,
6663
+ include_ineligible: false,
6664
+ include_out_of_scope: false,
6665
+ platforms: input.platforms,
6666
+ tags: targetType ? [targetType] : undefined,
6667
+ page: 1,
6668
+ page_size: input.recent_changes_page_size
6669
+ }));
6670
+ const data = readObject(api.data);
6671
+ return {
6672
+ changes: readArray(data?.results).map((change) => sanitizeChange(change, config.webBaseUrl)),
6673
+ sourceRequest: {
6674
+ source: "recent_changes",
6675
+ target_type: targetType,
6676
+ request_id: api.requestId,
6677
+ upstream_request_id: api.upstreamRequestId,
6678
+ ...apiSourceMetadata(api),
6679
+ page: 1
6680
+ },
6681
+ requestsUsed: api.cached || api.coalesced ? 0 : 1,
6682
+ error: undefined
6683
+ };
6684
+ }
6685
+ catch (error) {
6686
+ return {
6687
+ changes: [],
6688
+ sourceRequest: undefined,
6689
+ requestsUsed: error instanceof BBRadarApiError && (error.cached || error.coalesced) ? 0 : 1,
6690
+ error: apiOperationError(targetType ? `recent ${targetType} target changes` : "recent target changes", error)
6691
+ };
6692
+ }
6693
+ }));
6694
+ const changes = new Map();
6695
+ for (const result of results) {
6696
+ for (const change of result.changes) {
6697
+ const id = readNumber(change.id) ?? stringField(change, "id");
6698
+ const target = readObject(change.target);
6699
+ const program = readObject(change.program);
6700
+ const fallbackKey = `${stringField(program, "id") ?? ""}:${targetIdentifier(target ?? {}) ?? ""}:${stringField(change, "changed_at") ?? ""}`;
6701
+ changes.set(id === undefined ? fallbackKey : String(id), change);
6702
+ }
6703
+ }
6704
+ return {
6705
+ changes: [...changes.values()],
6706
+ sourceRequests: results
6707
+ .map((result) => result.sourceRequest)
6708
+ .filter((request) => request !== undefined),
6709
+ requestsUsed: results.reduce((total, result) => total + result.requestsUsed, 0),
6710
+ errors: results
6711
+ .map((result) => result.error)
6712
+ .filter((error) => error !== undefined)
6713
+ };
6714
+ }
6715
+ function huntRecentChangeQueries(input) {
6716
+ const requested = uniqueStrings(input.target_types);
6717
+ if (requested.length > 0 && requested.length <= 3) {
6718
+ return requested;
6719
+ }
6720
+ if (requested.length > 3) {
6721
+ return [undefined];
6722
+ }
6723
+ if (input.surface_mode === "web2") {
6724
+ return ["domain", "api", "wildcard"];
6725
+ }
6726
+ if (input.surface_mode === "web3") {
6727
+ return ["source code", "smart contract", "blockchain-dlt"];
6728
+ }
6729
+ return [undefined];
6730
+ }
6731
+ async function collectHuntTargetSamples(client, programs, input) {
6732
+ const targetsByProgram = new Map();
6733
+ const sourceRequestsByIndex = new Array(programs.length);
6734
+ const errorsByIndex = new Array(programs.length);
6735
+ let requestsUsed = 0;
6736
+ await mapWithConcurrency(programs.map((program, index) => ({ program, index })), TARGET_SAMPLE_CONCURRENCY, async ({ program, index }) => {
6737
+ const programId = stringField(program, "id");
6738
+ if (!programId) {
6739
+ return;
6740
+ }
6741
+ try {
6742
+ const requestedPageSize = Math.max(input.targets_per_result, input.target_types.length > 1 ? input.targets_per_result * 10 : input.targets_per_result * 4, 20);
6743
+ const api = await client.getProgramTargets(programId, {
6744
+ target_type: input.target_types.length === 1 ? input.target_types[0] : undefined,
6745
+ eligibility: "eligible",
6746
+ minTargetScore: input.min_target_score,
6747
+ targetOpportunityLabel: input.target_opportunity_label,
6748
+ competitionRisk: input.competition_risk,
6749
+ page: 1,
6750
+ page_size: Math.min(client.endpointNumber("program_targets", "max_page_size", MAX_TARGETS_PER_PROGRAM), requestedPageSize)
6751
+ });
6752
+ const data = readObject(api.data);
6753
+ const matchingTargets = readArray(data?.targets)
6754
+ .map(sanitizeTarget)
6755
+ .filter((target) => huntTargetMatches(target, input));
6756
+ const targets = selectHuntTargetSamples(matchingTargets, input.targets_per_result, input.recent_target_days);
6757
+ targetsByProgram.set(programId, targets);
6758
+ sourceRequestsByIndex[index] = {
6759
+ source: "program_targets",
6760
+ program_id: programId,
6761
+ request_id: api.requestId,
6762
+ upstream_request_id: api.upstreamRequestId,
6763
+ ...apiSourceMetadata(api)
6764
+ };
6765
+ if (!api.cached && !api.coalesced) {
6766
+ requestsUsed += 1;
6767
+ }
6768
+ }
6769
+ catch (error) {
6770
+ errorsByIndex[index] = apiOperationError(programId, error);
6771
+ if (!(error instanceof BBRadarApiError) || (!error.cached && !error.coalesced)) {
6772
+ requestsUsed += 1;
6773
+ }
6774
+ }
6775
+ });
6776
+ return {
6777
+ targetsByProgram,
6778
+ sourceRequests: sourceRequestsByIndex.filter((request) => request !== undefined),
6779
+ requestsUsed,
6780
+ errors: errorsByIndex.filter((error) => error !== undefined)
6781
+ };
6782
+ }
6783
+ function huntTargetMatches(target, input) {
6784
+ if (target.in_scope !== true || target.eligible_for_bounty !== true) {
6785
+ return false;
6786
+ }
6787
+ if (input.target_types.length > 0 && !input.target_types.some((targetType) => targetMatchesRequestedType(target, targetType))) {
6788
+ return false;
6789
+ }
6790
+ if (input.scope_tags.length > 0 && !arraysIntersectNormalized(new Set(input.scope_tags.map(normalizeTag)), readStringArrayField(target, "scope_tags"))) {
6791
+ return false;
6792
+ }
6793
+ if (input.language_tags.length > 0 && !arraysIntersectNormalized(new Set(input.language_tags.map(normalizeTag)), readStringArrayField(target, "language_tags"))) {
6794
+ return false;
6795
+ }
6796
+ if (input.min_target_score !== undefined && (readNumber(target.target_opportunity_score) ?? -1) < input.min_target_score) {
6797
+ return false;
6798
+ }
6799
+ if (input.target_opportunity_label !== undefined && stringField(target, "target_opportunity_label") !== input.target_opportunity_label) {
6800
+ return false;
6801
+ }
6802
+ if (input.competition_risk !== undefined && stringField(target, "competition_risk") !== input.competition_risk) {
6803
+ return false;
6804
+ }
6805
+ return true;
6806
+ }
6807
+ function compareHuntTargets(left, right) {
6808
+ return (compareNumbersDesc(readNumber(left.target_opportunity_score), readNumber(right.target_opportunity_score)) ||
6809
+ Number(readBoolean(right.wildcard) === true) - Number(readBoolean(left.wildcard) === true) ||
6810
+ (targetIdentifier(left) ?? "").localeCompare(targetIdentifier(right) ?? ""));
6811
+ }
6812
+ function selectHuntTargetSamples(targets, limit, recentTargetDays) {
6813
+ const selected = new Map();
6814
+ const cutoff = Date.now() - recentTargetDays * 24 * 60 * 60 * 1000;
6815
+ const newest = [...targets]
6816
+ .filter((target) => huntTargetAddedTimestamp(target) >= cutoff)
6817
+ .sort((left, right) => huntTargetAddedTimestamp(right) - huntTargetAddedTimestamp(left));
6818
+ const byQuality = [...targets].sort(compareHuntTargets);
6819
+ for (const target of newest.slice(0, Math.min(2, limit))) {
6820
+ const identifier = targetIdentifier(target);
6821
+ if (identifier) {
6822
+ selected.set(identifier, target);
6823
+ }
6824
+ }
6825
+ for (const target of byQuality) {
6826
+ if (selected.size >= limit) {
6827
+ break;
6828
+ }
6829
+ const identifier = targetIdentifier(target);
6830
+ if (identifier && !selected.has(identifier)) {
6831
+ selected.set(identifier, target);
6832
+ }
6833
+ }
6834
+ return [...selected.values()];
6835
+ }
6836
+ function huntTargetAddedTimestamp(target) {
6837
+ const addedAt = stringField(target, "added_at") ?? stringField(target, "source_added_at");
6838
+ const timestamp = addedAt ? Date.parse(addedAt) : Number.NaN;
6839
+ return Number.isFinite(timestamp) ? timestamp : 0;
6840
+ }
6841
+ function groupRecentHuntChanges(changes, input) {
6842
+ const cutoff = Date.now() - input.recent_target_days * 24 * 60 * 60 * 1000;
6843
+ const byProgram = new Map();
6844
+ for (const change of changes) {
6845
+ if (stringField(change, "change_type") !== "added" || timestampField(change, "changed_at") < cutoff) {
6846
+ continue;
6847
+ }
6848
+ const program = readObject(change.program);
6849
+ const target = readObject(change.target);
6850
+ const programId = stringField(program, "id");
6851
+ if (!programId || !target || !huntTargetMatches(target, input)) {
6852
+ continue;
6853
+ }
6854
+ const existing = byProgram.get(programId) ?? [];
6855
+ const identifier = targetIdentifier(target);
6856
+ if (!identifier || !existing.some((entry) => targetIdentifier(readObject(entry.target) ?? {}) === identifier)) {
6857
+ existing.push(change);
6858
+ existing.sort((left, right) => timestampField(right, "changed_at") - timestampField(left, "changed_at"));
6859
+ byProgram.set(programId, existing);
6860
+ }
6861
+ }
6862
+ return byProgram;
6863
+ }
6864
+ function buildHuntTargetMatch(entry, targetsByProgram, recentByProgram, input) {
6865
+ const programId = stringField(entry, "id") ?? "";
6866
+ const targetSamples = targetsByProgram.get(programId) ?? [];
6867
+ const recentChanges = recentByProgram.get(programId) ?? [];
6868
+ const recentTargets = recentHuntTargetEntries(targetSamples, recentChanges, input.recent_target_days);
6869
+ const rankFactors = readObject(entry.rank_factors) ?? {};
6870
+ const latestRecentAt = recentTargets[0]?.addedAt;
6871
+ const rewardScore = normalizedRewardScore(readNumber(rankFactors.bounty_max));
6872
+ const scopeScore = Math.min(100, (readNumber(rankFactors.eligible_targets) ?? 0) * 2 +
6873
+ (readNumber(rankFactors.wildcard_targets) ?? 0) * 5 +
6874
+ (readNumber(rankFactors.target_surface_score) ?? 0) * 0.25);
6875
+ const freshnessScore = huntFreshnessScore(latestRecentAt, recentTargets.length, readNumber(rankFactors.added_7d));
6876
+ const publicReports = readNumber(rankFactors.public_report_count);
6877
+ // Missing report evidence is unknown, not low competition. Keep the
6878
+ // internal ranking conservative and do not expose a made-up numeric signal.
6879
+ const competitionScore = publicReports === undefined ? 20 : Math.max(0, 100 - Math.min(100, publicReports / 10));
6880
+ const bestTargetScore = Math.max(0, ...targetSamples.map((target) => readNumber(target.target_opportunity_score) ?? 0));
6881
+ const baseHuntScore = readNumber(rankFactors.hunt_value_score) ?? 0;
6882
+ const compositeScore = huntCompositeScore(input.ranking_mode, baseHuntScore, rewardScore, scopeScore, freshnessScore, competitionScore, bestTargetScore);
6883
+ const program = compactProgram(entry);
6884
+ delete program.resource_links;
6885
+ const existingReasons = readArray(entry.why_ranked_here).filter((reason) => typeof reason === "string");
6886
+ const reasons = [...existingReasons];
6887
+ if (recentTargets.length > 0) {
6888
+ reasons.push(`${recentTargets.length} eligible target additions in the last ${input.recent_target_days} days`);
6889
+ }
6890
+ if (bestTargetScore > 0) {
6891
+ reasons.push(`best sampled target score ${bestTargetScore}`);
6892
+ }
6893
+ return {
6894
+ compositeScore,
6895
+ rewardScore,
6896
+ scopeScore,
6897
+ freshnessScore,
6898
+ competitionScore,
6899
+ payload: stripUndefined({
6900
+ program,
6901
+ match_score: compositeScore,
6902
+ rank_factors: {
6903
+ ...rankFactors,
6904
+ reward_score: roundScore(rewardScore),
6905
+ scope_score: roundScore(scopeScore),
6906
+ freshness_score: roundScore(freshnessScore),
6907
+ competition_score: publicReports === undefined ? undefined : roundScore(competitionScore),
6908
+ competition_signal: publicReports === undefined ? "unknown" : "public_report_count",
6909
+ best_sample_target_score: bestTargetScore > 0 ? bestTargetScore : undefined,
6910
+ recent_added_targets_in_window: recentTargets.length,
6911
+ latest_added_target_at: latestRecentAt,
6912
+ composite_score: compositeScore
6913
+ },
6914
+ why_ranked_here: uniqueStrings(reasons),
6915
+ tradeoffs: readArray(entry.tradeoffs).filter((tradeoff) => typeof tradeoff === "string"),
6916
+ target_samples: targetSamples.map(compactHuntTarget),
6917
+ recent_added_targets: recentTargets.slice(0, input.targets_per_result).map((recent) => ({
6918
+ ...compactHuntTarget(recent.target),
6919
+ added_at: recent.addedAt,
6920
+ freshness_source: recent.source
6921
+ }))
6922
+ })
6923
+ };
6924
+ }
6925
+ function compactHuntTarget(target) {
6926
+ return stripUndefined({
6927
+ ...compactTarget(target),
6928
+ added_at: stringField(target, "added_at"),
6929
+ source_added_at: stringField(target, "source_added_at"),
6930
+ last_changed_at: stringField(target, "last_changed_at")
6931
+ });
6932
+ }
6933
+ function recentHuntTargetEntries(targetSamples, recentChanges, recentTargetDays) {
6934
+ const cutoff = Date.now() - recentTargetDays * 24 * 60 * 60 * 1000;
6935
+ const entries = new Map();
6936
+ const remember = (target, addedAt, source) => {
6937
+ const identifier = targetIdentifier(target);
6938
+ const timestamp = addedAt ? Date.parse(addedAt) : Number.NaN;
6939
+ if (!identifier || !addedAt || !Number.isFinite(timestamp) || timestamp < cutoff) {
6940
+ return;
6941
+ }
6942
+ const existing = entries.get(identifier);
6943
+ if (!existing || timestamp > existing.timestamp || (timestamp === existing.timestamp && source === "target_change")) {
6944
+ entries.set(identifier, { target, addedAt, source, timestamp });
6945
+ }
6946
+ };
6947
+ for (const change of recentChanges) {
6948
+ const target = readObject(change.target);
6949
+ if (target) {
6950
+ remember(target, stringField(change, "changed_at"), "target_change");
6951
+ }
6952
+ }
6953
+ for (const target of targetSamples) {
6954
+ remember(target, stringField(target, "added_at") ?? stringField(target, "source_added_at"), "target_inventory");
6955
+ }
6956
+ return [...entries.values()].sort((left, right) => right.timestamp - left.timestamp);
6957
+ }
6958
+ function normalizedRewardScore(reward) {
6959
+ if (reward === undefined || reward <= 0) {
6960
+ return 0;
6961
+ }
6962
+ return Math.min(100, Math.log10(reward + 1) / Math.log10(100_001) * 100);
6963
+ }
6964
+ function huntFreshnessScore(latestAt, recentCount, added7d) {
6965
+ if (latestAt) {
6966
+ const timestamp = Date.parse(latestAt);
6967
+ if (Number.isFinite(timestamp)) {
6968
+ const ageDays = Math.max(0, (Date.now() - timestamp) / (24 * 60 * 60 * 1000));
6969
+ return Math.min(100, Math.max(0, 100 - ageDays * 2) + Math.min(20, recentCount * 4));
6970
+ }
6971
+ }
6972
+ return Math.min(100, Math.max(0, added7d ?? 0) * 12);
6973
+ }
6974
+ function huntCompositeScore(mode, baseHuntScore, rewardScore, scopeScore, freshnessScore, competitionScore, bestTargetScore) {
6975
+ if (mode === "highest_reward") {
6976
+ return roundScore(rewardScore * 0.7 + baseHuntScore * 0.2 + scopeScore * 0.1);
6977
+ }
6978
+ if (mode === "widest_scope") {
6979
+ return roundScore(scopeScore * 0.7 + baseHuntScore * 0.2 + rewardScore * 0.1);
6980
+ }
6981
+ if (mode === "freshest_targets") {
6982
+ return roundScore(freshnessScore * 0.7 + bestTargetScore * 0.15 + baseHuntScore * 0.15);
6983
+ }
6984
+ if (mode === "lowest_competition") {
6985
+ return roundScore(competitionScore * 0.65 + baseHuntScore * 0.2 + rewardScore * 0.15);
6986
+ }
6987
+ return roundScore(baseHuntScore * 0.35 +
6988
+ rewardScore * 0.2 +
6989
+ scopeScore * 0.2 +
6990
+ freshnessScore * 0.15 +
6991
+ bestTargetScore * 0.1);
6992
+ }
6993
+ function roundScore(value) {
6994
+ return Math.round(value * 100) / 100;
6995
+ }
6996
+ function compareHuntTargetMatches(left, right, mode) {
6997
+ if (mode === "highest_reward") {
6998
+ return right.rewardScore - left.rewardScore || right.compositeScore - left.compositeScore;
6999
+ }
7000
+ if (mode === "widest_scope") {
7001
+ return right.scopeScore - left.scopeScore || right.compositeScore - left.compositeScore;
7002
+ }
7003
+ if (mode === "freshest_targets") {
7004
+ return right.freshnessScore - left.freshnessScore || right.compositeScore - left.compositeScore;
7005
+ }
7006
+ if (mode === "lowest_competition") {
7007
+ return right.competitionScore - left.competitionScore || right.compositeScore - left.compositeScore;
7008
+ }
7009
+ return right.compositeScore - left.compositeScore;
7010
+ }
6297
7011
  async function findRepositoryHuntTargets(client, config, input) {
6298
7012
  const warnings = [];
7013
+ const repositoryPlatforms = input.platforms.filter((platform) => !isSelfHostedPlatform(platform));
7014
+ const selfHostedRequested = repositoryPlatforms.length !== input.platforms.length;
7015
+ if (input.platforms.length > 0 && repositoryPlatforms.length === 0) {
7016
+ return {
7017
+ request_id: randomUUID(),
7018
+ fetched_at: new Date().toISOString(),
7019
+ source_requests: [],
7020
+ warnings: [
7021
+ "Self-Hosted does not expose repository-change events or target intelligence, so repository-aware hunting is unavailable for this catalog. Use find_hunt_targets for current Self-Hosted program and target inventory."
7022
+ ],
7023
+ filters: {
7024
+ platforms: input.platforms,
7025
+ surface_mode: input.surface_mode,
7026
+ ranking_mode: input.ranking_mode,
7027
+ output_mode: input.output_mode
7028
+ },
7029
+ upstream_budget: {
7030
+ initial: input.upstream_request_budget,
7031
+ used: 0,
7032
+ remaining: input.upstream_request_budget
7033
+ },
7034
+ ranking_scope: {
7035
+ mode: "unsupported_catalog",
7036
+ possibly_incomplete: false
7037
+ },
7038
+ programs: [],
7039
+ matches: [],
7040
+ meta: {
7041
+ returned: 0,
7042
+ matched_programs: 0,
7043
+ program_candidates: 0,
7044
+ repository_events_scanned: 0,
7045
+ qualifying_targets_before_result_limit: 0,
7046
+ repository_request_attempts: 0,
7047
+ parallel_source_fetch: false
7048
+ }
7049
+ };
7050
+ }
7051
+ if (selfHostedRequested) {
7052
+ warnings.push("Self-Hosted was excluded from repository-aware matching because that catalog has no repository-change stream; other requested platforms were processed normally.");
7053
+ }
6299
7054
  const repositoryRequestReserve = input.upstream_request_budget >= 3 ? 2 : 1;
6300
7055
  const programRequestBudget = Math.max(1, input.upstream_request_budget - repositoryRequestReserve);
6301
7056
  const programInput = {
6302
- platforms: input.platforms,
6303
- exclude_platforms: input.exclude_platforms,
7057
+ platforms: repositoryPlatforms,
7058
+ exclude_platforms: uniqueStrings([...input.exclude_platforms, "Self-Hosted"]),
6304
7059
  tags: input.tags,
6305
7060
  scope_tags: input.scope_tags,
6306
7061
  target_types: input.target_types,
@@ -6342,7 +7097,7 @@ async function findRepositoryHuntTargets(client, config, input) {
6342
7097
  };
6343
7098
  const [programPayload, repository] = await Promise.all([
6344
7099
  findPrograms(client, config, programInput),
6345
- fetchRepositoryHuntPage(client, input, warnings, repositoryRequestReserve)
7100
+ fetchRepositoryHuntPage(client, { ...input, platforms: repositoryPlatforms }, warnings, repositoryRequestReserve)
6346
7101
  ]);
6347
7102
  const candidatePrograms = readArray(programPayload.programs)
6348
7103
  .map(readObject)
@@ -7153,6 +7908,10 @@ function toProgramCandidate(rawProgram, webBaseUrl) {
7153
7908
  return candidate;
7154
7909
  }
7155
7910
  function programCandidateMatches(candidate, filters) {
7911
+ const programStatus = normalizeTag(stringField(candidate.program, "program_status") ?? "");
7912
+ if (readBoolean(candidate.program.is_active) === false || programStatus === "paused") {
7913
+ return false;
7914
+ }
7156
7915
  if (filters.platforms.length > 0 && !filters.platforms.map(normalizeTag).includes(normalizeTag(stringField(candidate.program, "platform") ?? ""))) {
7157
7916
  return false;
7158
7917
  }
@@ -7478,6 +8237,12 @@ function uniqueStrings(values) {
7478
8237
  function normalizeTag(value) {
7479
8238
  return value.trim().toLowerCase();
7480
8239
  }
8240
+ function isSelfHostedPlatform(value) {
8241
+ return normalizeTag(value).replace(/[\s_]+/g, "-") === "self-hosted";
8242
+ }
8243
+ function isSelfHostedOnlyPlatformSelection(platforms) {
8244
+ return platforms.length > 0 && platforms.every(isSelfHostedPlatform);
8245
+ }
7481
8246
  function tokenSet(value) {
7482
8247
  return new Set(value.split(/[^a-z0-9]+/).filter((token) => token.length >= 2));
7483
8248
  }
@@ -7490,9 +8255,11 @@ function readStringArrayField(value, field) {
7490
8255
  }
7491
8256
  function registerPrompts(server, profile) {
7492
8257
  const discoveryToolInstructions = profile === "core"
7493
- ? `- Start with find_programs and pass opportunity_levels from the requested value below unless the user asked for a broader search.
7494
- - Express language, target type, reward, Web3, wildcard, VDP, and platform needs through find_programs filters.`
7495
- : `- Start with find_program_candidates unless the user needs custom filters; pass opportunity_levels from the requested value below.
8258
+ ? `- Start with find_hunt_targets for ranked target/program discovery. Make one call and use its shortlist as final; do not repeat find_programs with alternate sorts or fetch each candidate's targets.
8259
+ - Express language, target type, reward, Web2/Web3, wildcard, competition, freshness, and platform needs through find_hunt_targets filters.
8260
+ - Use find_repository_hunt_targets instead when the user explicitly requires Git or repository activity.`
8261
+ : `- Start with find_hunt_targets for ranked target/program discovery; use its included target samples and recent additions as the final shortlist.
8262
+ - Use find_program_candidates only for lower-level program-only discovery; pass opportunity_levels from the requested value below.
7496
8263
  - Use find_language_programs for language asks, find_target_type_programs for API/mobile/source-code/domain asks, find_reward_programs or find_paid_programs for reward asks, find_vdp_programs only for explicit VDP/no-bounty asks, find_web3_contests for Web3 contest/audit asks, find_web3_programs for general Web3 requests, and find_wildcard_programs for wildcard-specific requests.`;
7497
8264
  server.registerPrompt("find_best_programs_to_hunt", {
7498
8265
  title: "Find Best Programs To Hunt",
@@ -7507,7 +8274,7 @@ function registerPrompts(server, profile) {
7507
8274
 
7508
8275
  Find the best BBRadar program candidates.
7509
8276
  ${discoveryToolInstructions}
7510
- - Use this opportunity level JSON array: ${promptJson([args.opportunity_level ?? "elite"])}.
8277
+ - Treat this opportunity level JSON array as a ranking preference, not a strict first-pass filter: ${promptJson([args.opportunity_level ?? "elite"])}.
7511
8278
  - Apply platform filters from this JSON string if provided: ${promptJson(args.platforms ?? "")}.
7512
8279
  - Apply tag filters from this JSON string if provided: ${promptJson(args.tags ?? "")}.
7513
8280
  - Keep the shortlist to this JSON value: ${promptJson(args.max_programs ?? "10")}.
@@ -7586,7 +8353,7 @@ function advanceFeedCursor(store, cursor, rawMeta) {
7586
8353
  stored_in_memory: true
7587
8354
  };
7588
8355
  }
7589
- async function collectCursorFeedPages(store, initialCursor, maxPages, timeBudgetMs, fetchPage) {
8356
+ async function collectCursorFeedPages(store, initialCursor, maxPages, timeBudgetMs, initialSnapshot, fetchPage, refreshInventory) {
7590
8357
  const startedAt = Date.now();
7591
8358
  const results = [];
7592
8359
  const sourceRequests = [];
@@ -7598,9 +8365,110 @@ async function collectCursorFeedPages(store, initialCursor, maxPages, timeBudget
7598
8365
  let hasMore = false;
7599
8366
  let pageBudgetExhausted = false;
7600
8367
  let timeBudgetExhausted = false;
8368
+ let gapRecovery;
8369
+ let activeSnapshot = initialSnapshot;
7601
8370
  do {
7602
- const api = await fetchPage(currentCursor.afterId);
8371
+ let api;
8372
+ try {
8373
+ api = await fetchPage(currentCursor.afterId, undefined, activeSnapshot);
8374
+ feedRecoveryProgress.get(store)?.delete(currentCursor.key);
8375
+ }
8376
+ catch (error) {
8377
+ if (!(error instanceof BBRadarApiError) ||
8378
+ error.status !== 410 ||
8379
+ currentCursor.afterId === undefined ||
8380
+ activeSnapshot !== undefined ||
8381
+ !refreshInventory) {
8382
+ throw error;
8383
+ }
8384
+ const recoveryNonce = `${Date.now()}-${currentCursor.afterId}`;
8385
+ const markerApi = await fetchPage(undefined, `${recoveryNonce}-marker`);
8386
+ const markerData = readObject(markerApi.data);
8387
+ const markerAfterId = feedBaselineAfterId(markerData);
8388
+ let progress = feedRecoveryProgress.get(store)?.get(currentCursor.key);
8389
+ let restartedForDrift = false;
8390
+ if (progress && (progress.previousAfterId !== currentCursor.afterId ||
8391
+ progress.baselineAfterId !== markerAfterId)) {
8392
+ feedRecoveryProgress.get(store)?.delete(currentCursor.key);
8393
+ progress = undefined;
8394
+ restartedForDrift = true;
8395
+ }
8396
+ const beforeAfterId = progress?.baselineAfterId ?? markerAfterId;
8397
+ const startPage = progress?.nextPage ?? 1;
8398
+ const inventoryPart = await refreshInventory(startPage, `${recoveryNonce}-inventory`);
8399
+ const inventory = {
8400
+ ...inventoryPart,
8401
+ rows_observed: (progress?.rowsObserved ?? 0) + (readNumber(inventoryPart.rows_observed) ?? 0),
8402
+ pages_fetched: (progress?.pagesFetched ?? 0) + (readNumber(inventoryPart.pages_fetched) ?? 0),
8403
+ resumed_from_page: startPage,
8404
+ restarted_for_feed_drift: restartedForDrift
8405
+ };
8406
+ const inventoryComplete = readBoolean(inventory.complete) === true;
8407
+ const endpointPageLimitReached = readBoolean(inventory.endpoint_page_limit_reached) === true;
8408
+ const afterApi = inventoryComplete ? await fetchPage(undefined, `${recoveryNonce}-after`) : markerApi;
8409
+ const afterData = readObject(afterApi.data);
8410
+ const afterAfterId = feedBaselineAfterId(afterData);
8411
+ const baselineStable = inventoryComplete && beforeAfterId !== undefined && beforeAfterId === afterAfterId;
8412
+ const nextAfterId = baselineStable ? beforeAfterId : currentCursor.afterId;
8413
+ if (baselineStable) {
8414
+ store.set(currentCursor.key, nextAfterId);
8415
+ feedRecoveryProgress.get(store)?.delete(currentCursor.key);
8416
+ }
8417
+ else if (!inventoryComplete && !endpointPageLimitReached && beforeAfterId !== undefined) {
8418
+ // Persist only the unchanged expired bookmark so resume_cursor can
8419
+ // continue recovery without treating partial inventory as a baseline.
8420
+ store.set(currentCursor.key, currentCursor.afterId);
8421
+ setRecoveryCheckpoint(store, currentCursor.key, {
8422
+ previousAfterId: currentCursor.afterId,
8423
+ baselineAfterId: beforeAfterId,
8424
+ nextPage: readNumber(inventoryPart.next_page) ?? startPage,
8425
+ rowsObserved: readNumber(inventory.rows_observed) ?? 0,
8426
+ pagesFetched: readNumber(inventory.pages_fetched) ?? 0
8427
+ });
8428
+ }
8429
+ else {
8430
+ // A failed first explicit recovery must remain resumable even when no
8431
+ // safe inventory checkpoint can be retained.
8432
+ store.set(currentCursor.key, currentCursor.afterId);
8433
+ feedRecoveryProgress.get(store)?.delete(currentCursor.key);
8434
+ }
8435
+ gapRecovery = {
8436
+ historical_gap: true,
8437
+ current_inventory_refreshed: inventoryComplete,
8438
+ recovery_complete: baselineStable,
8439
+ retained_previous_cursor: !baselineStable,
8440
+ incomplete_reason: !inventoryComplete
8441
+ ? endpointPageLimitReached ? "inventory_endpoint_page_limit_reached" : "inventory_page_or_time_budget_exhausted"
8442
+ : beforeAfterId === undefined
8443
+ ? "feed_baseline_unavailable"
8444
+ : beforeAfterId !== afterAfterId
8445
+ ? "feed_changed_during_inventory_refresh"
8446
+ : undefined,
8447
+ inventory,
8448
+ next_inventory_page: baselineStable ? undefined : readNumber(inventoryPart.next_page),
8449
+ previous_after_id: currentCursor.afterId,
8450
+ baseline_after_id: baselineStable ? nextAfterId : undefined,
8451
+ baseline_events_emitted: 0,
8452
+ limitation: "Changes retired before recovery cannot be reconstructed."
8453
+ };
8454
+ lastApi = afterApi;
8455
+ lastData = afterData;
8456
+ cursorPayload = {
8457
+ endpoint: currentCursor.endpoint,
8458
+ mode: baselineStable ? "recovered_baseline" : "recovery_incomplete",
8459
+ used_after_id: currentCursor.afterId,
8460
+ next_after_id: nextAfterId,
8461
+ has_more: false,
8462
+ stored_in_memory: true
8463
+ };
8464
+ pagesFetched += 1;
8465
+ break;
8466
+ }
7603
8467
  const data = readObject(api.data);
8468
+ const returnedSnapshot = readString(readObject(data?.meta)?.snapshot);
8469
+ if (activeSnapshot === "start" && returnedSnapshot) {
8470
+ activeSnapshot = returnedSnapshot;
8471
+ }
7604
8472
  const pageResults = readArray(data?.results);
7605
8473
  lastApi = api;
7606
8474
  lastData = data;
@@ -7643,12 +8511,61 @@ async function collectCursorFeedPages(store, initialCursor, maxPages, timeBudget
7643
8511
  pagesFetched,
7644
8512
  pageBudgetExhausted,
7645
8513
  timeBudgetExhausted,
7646
- sourceRequests
8514
+ sourceRequests,
8515
+ gapRecovery
8516
+ };
8517
+ }
8518
+ const feedRecoveryProgress = new WeakMap();
8519
+ function recoveryProgressFor(store) {
8520
+ let progress = feedRecoveryProgress.get(store);
8521
+ if (!progress) {
8522
+ progress = new Map();
8523
+ feedRecoveryProgress.set(store, progress);
8524
+ }
8525
+ return progress;
8526
+ }
8527
+ function setRecoveryCheckpoint(store, key, checkpoint) {
8528
+ const progress = recoveryProgressFor(store);
8529
+ if (!progress.has(key) && progress.size >= 500) {
8530
+ const oldest = progress.keys().next().value;
8531
+ if (oldest !== undefined)
8532
+ progress.delete(oldest);
8533
+ }
8534
+ progress.delete(key);
8535
+ progress.set(key, checkpoint);
8536
+ }
8537
+ function feedBaselineAfterId(data) {
8538
+ const metaAfterId = readNumber(readObject(data?.meta)?.next_after_id);
8539
+ const ids = readArray(data?.results)
8540
+ .map((entry) => readNumber(readObject(entry)?.id))
8541
+ .filter((value) => value !== undefined);
8542
+ return ids.length > 0 ? Math.max(metaAfterId ?? 0, ...ids) : metaAfterId;
8543
+ }
8544
+ async function refreshInventoryBaseline(fetchPage, collection, maxPages, timeBudgetMs, startPage = 1, recoveryProbe = "inventory") {
8545
+ const startedAt = Date.now();
8546
+ let page = startPage;
8547
+ let rows = 0;
8548
+ let hasMore = false;
8549
+ do {
8550
+ const api = await fetchPage(page, `${recoveryProbe}-${page}`);
8551
+ const data = readObject(api.data);
8552
+ rows += readArray(data?.[collection]).length;
8553
+ hasMore = readBoolean(readObject(data?.meta)?.has_more) === true;
8554
+ page += 1;
8555
+ } while (hasMore && page < startPage + maxPages && Date.now() - startedAt < timeBudgetMs);
8556
+ return {
8557
+ collection,
8558
+ rows_observed: rows,
8559
+ pages_fetched: page - startPage,
8560
+ next_page: hasMore && page <= MAX_UPSTREAM_PAGE ? page : undefined,
8561
+ endpoint_page_limit_reached: hasMore && page > MAX_UPSTREAM_PAGE,
8562
+ complete: !hasMore,
8563
+ bounded: true
7647
8564
  };
7648
8565
  }
7649
8566
  function normalizeFeedCursorFilters(filters) {
7650
8567
  return Object.fromEntries(Object.entries(filters)
7651
- .filter(([key, value]) => key !== "after_id" && key !== "page" && value !== undefined)
8568
+ .filter(([key, value]) => key !== "after_id" && key !== "page" && key !== "snapshot" && value !== undefined)
7652
8569
  .map(([key, value]) => [key, normalizeFeedCursorFilterValue(value)]));
7653
8570
  }
7654
8571
  function normalizeFeedCursorFilterValue(value) {
@@ -7855,6 +8772,11 @@ function splitFilterList(value) {
7855
8772
  function toQuery(values) {
7856
8773
  return compactRecord(values);
7857
8774
  }
8775
+ function assertStableSnapshotSupported(client, endpoint, snapshot) {
8776
+ if (snapshot !== undefined && !client.supportsStableSnapshot(endpoint)) {
8777
+ throw new Error(`The negotiated BBRadar API contract does not advertise stable snapshots for ${endpoint}. Omit snapshot to use live pagination.`);
8778
+ }
8779
+ }
7858
8780
  function compactRecord(values) {
7859
8781
  return Object.fromEntries(Object.entries(values).filter(([, value]) => {
7860
8782
  if (value === undefined || value === null) {
@@ -7903,6 +8825,10 @@ function compactTarget(target) {
7903
8825
  in_scope: readBoolean(target.in_scope),
7904
8826
  eligible_for_bounty: readBoolean(target.eligible_for_bounty),
7905
8827
  wildcard: readBoolean(target.wildcard),
8828
+ path_kind: stringField(target, "path_kind"),
8829
+ path_prefix: stringField(target, "path_prefix"),
8830
+ match_quality: stringField(target, "match_quality"),
8831
+ match_reason: stringField(target, "match_reason"),
7906
8832
  target_opportunity_score: readNumber(target.target_opportunity_score),
7907
8833
  target_opportunity_label: stringField(target, "target_opportunity_label"),
7908
8834
  competition_risk: stringField(target, "competition_risk"),