@bbradar/mcp 0.2.6 → 0.3.1
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/CHANGELOG.md +25 -0
- package/README.md +230 -3
- package/benchmark/live-api-check.mjs +126 -0
- package/benchmark/live-hunt.mjs +105 -0
- package/benchmark/live-stdio.mjs +80 -0
- package/benchmark/pro-benchmark.mjs +26 -4
- package/dist/activityContract.d.ts +4 -0
- package/dist/activityContract.js +47 -0
- package/dist/activityContract.js.map +1 -0
- package/dist/bbradarClient.d.ts +10 -1
- package/dist/bbradarClient.js +78 -16
- package/dist/bbradarClient.js.map +1 -1
- package/dist/sanitize.js +3 -0
- package/dist/sanitize.js.map +1 -1
- package/dist/server.js +814 -137
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { requiresActivityQuery, validateActivityCatalogInput } from "./activityContract.js";
|
|
2
3
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { BBRadarApiError } from "./bbradarClient.js";
|
|
@@ -6,7 +7,7 @@ import { NumericCursorStore } from "./cursorStore.js";
|
|
|
6
7
|
import { stableStringify, stripUndefined } from "./json.js";
|
|
7
8
|
import { FixedWindowRateLimiter } from "./rateLimit.js";
|
|
8
9
|
import { readArray, readBoolean, readNumber, readObject, readString, sanitizeJson, sanitizeProPayload, sanitizeChange, sanitizeProgram, sanitizeRepositoryChange, sanitizeTarget, sanitizeTargetsForExport } from "./sanitize.js";
|
|
9
|
-
const SERVER_VERSION = "0.
|
|
10
|
+
const SERVER_VERSION = "0.3.1";
|
|
10
11
|
const MAX_PROGRAMS_PER_SEARCH = 100;
|
|
11
12
|
const MAX_TARGET_EXPORT = 500;
|
|
12
13
|
const MAX_RECENT_CHANGES = 100;
|
|
@@ -45,7 +46,7 @@ const MAX_IN_MEMORY_FEED_CURSORS = 500;
|
|
|
45
46
|
const MAX_RESOLVE_SEARCH_QUERIES = 4;
|
|
46
47
|
const FAST_RESOLVE_SEARCH_QUERIES = 2;
|
|
47
48
|
const MAX_DIRECT_HANDLE_RESOLUTION_REQUESTS = 96;
|
|
48
|
-
const SDK_VERSION = "1.
|
|
49
|
+
const SDK_VERSION = "1.31.0";
|
|
49
50
|
const CORE_TOOL_NAMES = new Set([
|
|
50
51
|
"search_programs",
|
|
51
52
|
"get_program",
|
|
@@ -71,7 +72,8 @@ const CORE_TOOL_NAMES = new Set([
|
|
|
71
72
|
"get_program_activity",
|
|
72
73
|
"get_analytics_platform_series",
|
|
73
74
|
"get_analytics_program_activity",
|
|
74
|
-
"get_analytics_program_detail"
|
|
75
|
+
"get_analytics_program_detail",
|
|
76
|
+
"get_activity_capabilities", "get_activity_snapshot", "get_activity_rankings", "get_program_payouts", "get_activity_change_detail"
|
|
75
77
|
]);
|
|
76
78
|
const TOOL_ENDPOINT_REQUIREMENTS = {
|
|
77
79
|
search_targets: "target_search",
|
|
@@ -187,7 +189,7 @@ const changeListModeSchema = z.enum(["compact", "full"]);
|
|
|
187
189
|
const repositoryChangeListModeSchema = z.enum(["compact", "full"]);
|
|
188
190
|
const programListModeSchema = z.enum(["compact", "full"]);
|
|
189
191
|
const rewardThresholdModeSchema = z.enum(["max_at_least", "min_at_least"]);
|
|
190
|
-
const vdpModeSchema = z.enum(["include", "exclude", "only_likely", "only_known_no_bounty"]);
|
|
192
|
+
const vdpModeSchema = z.enum(["include", "exclude", "only", "only_likely", "only_known_no_bounty"]);
|
|
191
193
|
const targetMatchModeSchema = z
|
|
192
194
|
.enum(["smart", "contains", "exact", "suffix", "wildcard"])
|
|
193
195
|
.describe("How to match target_query. smart is forgiving for URLs, wildcards, target names, and partial words.");
|
|
@@ -255,11 +257,11 @@ const stableSnapshotSchema = z
|
|
|
255
257
|
.string()
|
|
256
258
|
.trim()
|
|
257
259
|
.min(1)
|
|
258
|
-
.max(
|
|
260
|
+
.max(32, "Catalog snapshot tokens have at most 32 characters. Use 'start', then meta.snapshot; Activity snapshot IDs belong only to Activity and payout tools.")
|
|
259
261
|
.refine((value) => !/[\u0000-\u001F\u007F]/.test(value), {
|
|
260
262
|
message: "Snapshot tokens cannot contain control characters."
|
|
261
263
|
})
|
|
262
|
-
.describe("Optional
|
|
264
|
+
.describe("Optional catalog/feed snapshot token, at most 32 characters. Send 'start' on page 1, then reuse meta.snapshot with identical filters. Do not pass get_activity_snapshot's snapshot_id here. Snapshot creation can be slower; omit it for the fastest live result.");
|
|
263
265
|
const opaqueCursorSchema = z.string().min(1).max(4096).describe("Opaque five-minute cursor. Reuse with identical filters and page size; restart the query on expiry.");
|
|
264
266
|
const analyticsDateSchema = () => z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use an inclusive UTC YYYY-MM-DD date.");
|
|
265
267
|
const proLimitSchema = z.number().int().min(1).max(100);
|
|
@@ -283,6 +285,59 @@ const programSortSchema = z
|
|
|
283
285
|
"best_hunt_value"
|
|
284
286
|
])
|
|
285
287
|
.default("best_match");
|
|
288
|
+
const ACTIVITY_TABS = ["payouts", "value", "scope", "rewards", "updates", "reports", "response", "status", "all_changes"];
|
|
289
|
+
const ACTIVITY_EVENT_PERIODS = ["7d", "30d", "90d"];
|
|
290
|
+
const ACTIVITY_COUNT_TABS = new Set(["payouts", "reports", "response"]);
|
|
291
|
+
const ACTIVITY_SHARED_PARAMS = ["tab", "sort", "direction", "platforms", "tags", "program_kind", "program_type", "search",
|
|
292
|
+
"current_status", "labels", "opportunity_tiers", "cursor", "limit", "snapshot"];
|
|
293
|
+
// Inputs the API accepts only for these views. Anything else is rejected before
|
|
294
|
+
// a request so it cannot be silently ignored or reinterpreted.
|
|
295
|
+
const ACTIVITY_TAB_PARAMS = {
|
|
296
|
+
payouts: ["period", "basis", "precision"],
|
|
297
|
+
value: ["include_map"],
|
|
298
|
+
scope: ["period"],
|
|
299
|
+
rewards: ["period"],
|
|
300
|
+
updates: ["period", "category"],
|
|
301
|
+
reports: ["period", "launched_within", "report_change_min", "report_change_max"],
|
|
302
|
+
response: ["period", "launched_within"],
|
|
303
|
+
status: ["period", "change_type"],
|
|
304
|
+
all_changes: ["period", "category"]
|
|
305
|
+
};
|
|
306
|
+
const activityCatalogSortSchema = z.enum(["updated", "payout_amount", "payout_lower_bound", "payout_upper_bound", "scope_added", "scope_net", "reward_change", "active_days", "reports", "reports_change", "resolved", "last_resolved", "first_response", "time_to_bounty", "last_change"]);
|
|
307
|
+
const activityCountPeriodSchema = () => z.enum(["24h", "7d", "30d", "90d", "all_time"]);
|
|
308
|
+
const activityEventPeriodSchema = () => z.enum(["7d", "30d", "90d"]);
|
|
309
|
+
const activityCatalogNumberSchema = () => z.number().finite().min(0).optional();
|
|
310
|
+
const activityCatalogInput = {
|
|
311
|
+
program_type: z.enum(["paid", "vdp", "unknown"]).optional(),
|
|
312
|
+
payout_metric: z.enum(["payout_total", "payout_increase", "payout_native_90d"]).optional().describe("payout_total requires all_time; payout_increase is a tracked counter increase in 24h/7d/30d/90d; payout_native_90d requires 90d and is provider-reported. Omit to select the period's default metric."),
|
|
313
|
+
payout_period: activityCountPeriodSchema().optional(),
|
|
314
|
+
payout_precision: z.enum(["exact", "proven_bounds"]).optional(),
|
|
315
|
+
payout_min: activityCatalogNumberSchema(), payout_max: activityCatalogNumberSchema(),
|
|
316
|
+
payout_positive: z.boolean().optional().describe("true requires a proved positive payout; false requires an exact observed zero. Omit to include either."),
|
|
317
|
+
payout_activity: z.enum(["increase", "no_increase", "lower"]).optional(),
|
|
318
|
+
scope_period: activityEventPeriodSchema().optional(),
|
|
319
|
+
scope_added_min: activityCatalogNumberSchema(), scope_removed_min: activityCatalogNumberSchema(),
|
|
320
|
+
scope_net_min: z.number().finite().optional(), scope_net_max: z.number().finite().optional(),
|
|
321
|
+
reward_period: activityEventPeriodSchema().optional(),
|
|
322
|
+
reward_change: z.enum(["increased", "decreased"]).optional(), reward_change_min_pct: activityCatalogNumberSchema(),
|
|
323
|
+
updates_period: activityEventPeriodSchema().optional(),
|
|
324
|
+
active_days_min: activityCatalogNumberSchema(), changes_min: activityCatalogNumberSchema(),
|
|
325
|
+
change_category: z.enum(["scope", "rewards", "rules", "program"]).optional().describe("Update-event category; requires its own updates_period. scope_period with scope_added_min already filters scope additions and does not need this field."),
|
|
326
|
+
reports_period: activityCountPeriodSchema().optional(),
|
|
327
|
+
reports_min: activityCatalogNumberSchema(), reports_max: activityCatalogNumberSchema(),
|
|
328
|
+
reports_change: z.enum(["rising", "falling"]).optional(), reports_change_min_pct: activityCatalogNumberSchema(),
|
|
329
|
+
launched_within: z.enum(["7d", "30d", "90d"]).optional(),
|
|
330
|
+
resolved_period: activityCountPeriodSchema().optional(), resolved_min: activityCatalogNumberSchema(),
|
|
331
|
+
last_resolved_within: z.enum(["7d", "30d", "90d"]).optional(),
|
|
332
|
+
first_response_max_hours: activityCatalogNumberSchema(), time_to_bounty_max_hours: activityCatalogNumberSchema(),
|
|
333
|
+
status_period: activityEventPeriodSchema().optional(),
|
|
334
|
+
status_change: z.enum(["first_seen", "paused", "resumed", "left_listing", "returned"]).optional(),
|
|
335
|
+
current_status: z.enum(["active", "paused", "suspended", "disabled", "closed", "unavailable", "unknown"]).optional(),
|
|
336
|
+
activity_labels: z.array(z.string().min(1).max(64)).max(10).optional(),
|
|
337
|
+
sort_by: activityCatalogSortSchema.optional(), sort_direction: z.enum(["asc", "desc"]).optional()
|
|
338
|
+
};
|
|
339
|
+
const activityCatalogGuidance = "Give each Activity signal its own period. payout_total requires all_time; payout_increase uses 24h/7d/30d/90d; payout_native_90d requires 90d. change_category requires updates_period even when scope_period is present; scope_period with scope_added_min needs no change_category. Use enabled sort_by/sort_direction combinations; default is updated descending. Tracked no_increase/lower require 30d. Catalog snapshot is 'start' or meta.snapshot, never an Activity snapshot_id. An empty result does not authorize relaxing the user's thresholds or restricting platforms to a recent-feed sample.";
|
|
340
|
+
const freeSubmissionOnlySchema = z.boolean().default(false).describe("Only programs with no detected submission fee or deposit.");
|
|
286
341
|
const programIdSchema = z
|
|
287
342
|
.string()
|
|
288
343
|
.trim()
|
|
@@ -305,51 +360,24 @@ const programIdentifierSchema = z
|
|
|
305
360
|
function jsonRecordSchema() {
|
|
306
361
|
return z.record(z.unknown());
|
|
307
362
|
}
|
|
308
|
-
function rateLimitOutputSchema() {
|
|
309
|
-
return z.object({
|
|
310
|
-
remaining: z.number().int().min(0),
|
|
311
|
-
reset_at: z.string()
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
function toolTimingOutputSchema() {
|
|
315
|
-
return z.object({
|
|
316
|
-
tool_name: z.string(),
|
|
317
|
-
duration_ms: z.number().int().min(0)
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
function cacheOutputSchema() {
|
|
321
|
-
return z.object({
|
|
322
|
-
hit: z.boolean(),
|
|
323
|
-
stale_while_revalidate: z.boolean().optional(),
|
|
324
|
-
coalesced_live_request: z.boolean().optional(),
|
|
325
|
-
no_store: z.boolean().optional(),
|
|
326
|
-
expires_at: z.string().optional()
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
function errorOutputSchema() {
|
|
330
|
-
return z.object({
|
|
331
|
-
message: z.string(),
|
|
332
|
-
status: z.number().int().optional(),
|
|
333
|
-
detail: z.unknown().optional(),
|
|
334
|
-
errors: z.unknown().optional()
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
363
|
function programOutputSchema() {
|
|
338
364
|
return jsonRecordSchema();
|
|
339
365
|
}
|
|
366
|
+
// Every tool repeats this envelope in discovery. Diagnostic objects (cache,
|
|
367
|
+
// rate limit, timing, error) stay open records so each tool's schema stays small.
|
|
340
368
|
function apiEnvelopeOutputShape() {
|
|
341
369
|
return {
|
|
342
370
|
request_id: z.string(),
|
|
343
371
|
upstream_request_id: z.string().optional(),
|
|
344
372
|
fetched_at: z.string().optional(),
|
|
345
|
-
cache:
|
|
373
|
+
cache: jsonRecordSchema().optional(),
|
|
346
374
|
source_requests: z.array(jsonRecordSchema()).optional(),
|
|
347
|
-
mcp_rate_limit:
|
|
348
|
-
mcp_timing:
|
|
375
|
+
mcp_rate_limit: jsonRecordSchema().optional(),
|
|
376
|
+
mcp_timing: jsonRecordSchema().optional(),
|
|
349
377
|
warnings: z.array(z.string()).optional(),
|
|
350
378
|
program_id_resolution: jsonRecordSchema().optional(),
|
|
351
379
|
recovery: jsonRecordSchema().optional(),
|
|
352
|
-
error:
|
|
380
|
+
error: jsonRecordSchema().optional()
|
|
353
381
|
};
|
|
354
382
|
}
|
|
355
383
|
function programListOutputShape() {
|
|
@@ -367,7 +395,8 @@ function findProgramsOutputShape() {
|
|
|
367
395
|
warnings: z.array(z.string()).optional(),
|
|
368
396
|
errors: z.array(jsonRecordSchema()).optional(),
|
|
369
397
|
upstream_budget: jsonRecordSchema().optional(),
|
|
370
|
-
ranking_scope: jsonRecordSchema().optional()
|
|
398
|
+
ranking_scope: jsonRecordSchema().optional(),
|
|
399
|
+
scan: jsonRecordSchema().optional()
|
|
371
400
|
};
|
|
372
401
|
}
|
|
373
402
|
function decisionOutputShape() {
|
|
@@ -415,7 +444,13 @@ function createProfiledToolRegistrar(server, profile, client) {
|
|
|
415
444
|
const extra = args[args.length - 1];
|
|
416
445
|
return client.withRequestContext(extra.signal, () => callback(...args), readObject(args[0])?.snapshot === "start");
|
|
417
446
|
});
|
|
418
|
-
|
|
447
|
+
// Raw SDK shapes otherwise strip unknown arguments. Every advertised
|
|
448
|
+
// filter must be applied or rejected, including on the older shortcuts.
|
|
449
|
+
const inputSchema = toolConfig.inputSchema;
|
|
450
|
+
const strictConfig = inputSchema && !(inputSchema instanceof z.ZodType)
|
|
451
|
+
? { ...toolConfig, inputSchema: z.object(inputSchema).strict() }
|
|
452
|
+
: toolConfig;
|
|
453
|
+
const registeredTool = server.registerTool(name, strictConfig, scopedCallback);
|
|
419
454
|
const requiredEndpoint = TOOL_ENDPOINT_REQUIREMENTS[name];
|
|
420
455
|
if ((profile === "core" && !CORE_TOOL_NAMES.has(name)) ||
|
|
421
456
|
(requiredEndpoint !== undefined && !client.supportsEndpoint(requiredEndpoint))) {
|
|
@@ -432,6 +467,9 @@ export function createBbradarServer(client, config) {
|
|
|
432
467
|
"Use resolve_program or run_program_name_action for a named program.",
|
|
433
468
|
"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.",
|
|
434
469
|
"Use find_programs for lower-level program-only discovery and find_programs_by_target for reverse target lookup.",
|
|
470
|
+
"For payouts, reports, response measures or Activity comparisons, first read get_activity_capabilities, then get_activity_rankings with the matching tab for one ranking. For several conditions at once (e.g. recent payouts plus new scope, Web2-only, low competition, no submission fee) use one find_hunt_targets or find_programs call with Activity inputs and local filters together. Keep periods explicit; never intersect separately paginated rankings.",
|
|
471
|
+
"Activity data is published in versions. get_activity_snapshot's snapshot_id binds only Activity capabilities, Activity rankings and program payouts. Catalog/feed tools use snapshot=start then meta.snapshot instead; do not send an Activity snapshot_id to find_programs. Never mix rows from different versions.",
|
|
472
|
+
"Preserve the user's thresholds, periods and platform scope when discovery times out or returns no matches. A sampled recent-change feed does not enumerate all supported platforms. Inspect partial_success, errors, ranking_scope and scan before claiming complete coverage.",
|
|
435
473
|
"For semantic changes use get_history_changes or get_program_history, then get_history_change_detail only when before/after items are needed. For exact activity use get_program_activity; for ranked activity use get_analytics_program_activity. Use daily analytics tools directly for date series.",
|
|
436
474
|
"Default to bounty-eligible in-scope data unless the user explicitly requests broader results.",
|
|
437
475
|
"Prefer compact/identifier outputs and disclose sampled ranking scope when present."
|
|
@@ -442,6 +480,9 @@ export function createBbradarServer(client, config) {
|
|
|
442
480
|
"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.",
|
|
443
481
|
"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.",
|
|
444
482
|
"Use advanced tools only after the user's goal is clear or the guide points to them.",
|
|
483
|
+
"For payouts, reports, response measures or Activity comparisons, first read get_activity_capabilities. Use get_activity_rankings (or its per-view alias) for one ranking, or one find_hunt_targets/find_programs call combining Activity inputs with Web2, scope, competition and fee filters. Keep server order and explicit periods; never re-rank a sampled catalog or intersect separate pages.",
|
|
484
|
+
"Activity data is published in versions. get_activity_snapshot's snapshot_id binds only Activity capabilities, Activity rankings and program payouts. Catalog/feed tools use snapshot=start then meta.snapshot instead; do not send an Activity snapshot_id to find_programs. Never mix rows from different versions.",
|
|
485
|
+
"Preserve the user's thresholds, periods and platform scope when discovery times out or returns no matches. A sampled recent-change feed does not enumerate all supported platforms. Inspect partial_success, errors, ranking_scope and scan before claiming complete coverage.",
|
|
445
486
|
"Partial program name + action: run_program_name_action. Partial program name only: resolve_program.",
|
|
446
487
|
"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.",
|
|
447
488
|
"Recently updated programs across platforms: find_recently_updated_programs. Do not infer a platform such as Immunefi unless the user explicitly names it.",
|
|
@@ -478,6 +519,135 @@ export function createBbradarServer(client, config) {
|
|
|
478
519
|
const targetSearchPageSizeSchema = z.number().int().min(1).max(maxTargetSearchPageSize).default(Math.min(50, maxTargetSearchPageSize));
|
|
479
520
|
const historyOutput = { ...apiEnvelopeOutputShape(), changes: z.array(jsonRecordSchema()).optional(), coverage: jsonRecordSchema().optional(), meta: jsonRecordSchema().optional() };
|
|
480
521
|
const analyticsOutput = { ...apiEnvelopeOutputShape(), platform: z.string().optional(), program: jsonRecordSchema().optional(), programs: z.array(jsonRecordSchema()).optional(), series: z.array(jsonRecordSchema()).optional(), observations: jsonRecordSchema().optional(), category: z.string().optional(), population: z.string().optional(), ranking_definition: z.string().optional(), inventory_definition: z.string().optional(), meta: jsonRecordSchema().optional() };
|
|
522
|
+
// Published Activity versions are 64 lowercase hex characters. "start" and
|
|
523
|
+
// catalog snapshot tokens are different contracts and are rejected here.
|
|
524
|
+
const activitySnapshotSchema = z.string().regex(/^[0-9a-f]{64}$/, "Use a snapshot_id returned by Activity.")
|
|
525
|
+
.describe("Published Activity version (get_activity_snapshot or meta.snapshot_id). Binds reads to one version; expired or withdrawn versions return 410. Never mix with a cursor from another version.");
|
|
526
|
+
const includeMapSchema = z.boolean().default(false)
|
|
527
|
+
.describe("Return Value map points (up to thousands). Off by default: map_points_omitted reports the count.");
|
|
528
|
+
const activityParams = {
|
|
529
|
+
tab: z.enum(ACTIVITY_TABS),
|
|
530
|
+
period: z.enum(["24h", "7d", "30d", "90d", "all_time"]).optional(),
|
|
531
|
+
basis: z.enum(["reported", "tracked"]).optional(),
|
|
532
|
+
precision: z.enum(["exact", "proven_bounds"]).optional(),
|
|
533
|
+
sort: z.string().min(1).max(64).optional(),
|
|
534
|
+
direction: z.enum(["asc", "desc"]).optional(),
|
|
535
|
+
platforms: platformListSchema(), tags: tagListSchema(),
|
|
536
|
+
program_kind: z.enum(["bounty", "contest", "audit", "challenge"]).optional(),
|
|
537
|
+
program_type: z.enum(["paid", "vdp", "unknown"]).optional(),
|
|
538
|
+
search: z.string().min(1).max(200).optional(),
|
|
539
|
+
current_status: z.enum(["active", "paused", "suspended", "disabled", "closed", "unavailable", "unknown"]).optional(),
|
|
540
|
+
labels: z.array(z.string().min(1).max(64)).max(10).optional().describe("Only labels listed by get_activity_capabilities; others return 422."),
|
|
541
|
+
opportunity_tiers: z.array(opportunityLevelSchema).min(1).max(4).optional().describe("Any of these catalog opportunity tiers, as frozen into the Activity version. get_activity_capabilities lists the tiers present in facets.opportunity_tiers; an API without the filter is rejected before any request."),
|
|
542
|
+
launched_within: z.enum(["7d", "30d", "90d"]).optional(),
|
|
543
|
+
category: z.enum(["scope", "rewards", "rules", "program"]).optional(),
|
|
544
|
+
change_type: z.enum(["first_seen", "paused", "resumed", "left_listing", "returned"]).optional(),
|
|
545
|
+
report_change_min: z.number().finite().optional(), report_change_max: z.number().finite().optional(),
|
|
546
|
+
include_map: includeMapSchema,
|
|
547
|
+
cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(25),
|
|
548
|
+
snapshot: activitySnapshotSchema.optional()
|
|
549
|
+
};
|
|
550
|
+
const activityOutput = {
|
|
551
|
+
...apiEnvelopeOutputShape(), rows: z.array(jsonRecordSchema()).optional(), points: z.array(jsonRecordSchema()).optional(),
|
|
552
|
+
tab: z.string().optional(), period: z.string().nullable().optional(),
|
|
553
|
+
basis: z.string().nullable().optional(), precision: z.string().nullable().optional(),
|
|
554
|
+
sort: z.string().nullable().optional(), direction: z.string().optional(), meta: jsonRecordSchema().optional(),
|
|
555
|
+
cohort_median: jsonRecordSchema().nullable().optional(), available_views: z.array(jsonRecordSchema()).optional(),
|
|
556
|
+
map_points_omitted: z.number().int().optional()
|
|
557
|
+
};
|
|
558
|
+
registerTool("get_activity_capabilities", {
|
|
559
|
+
title: "Get Available Activity Views",
|
|
560
|
+
description: "Read the exact enabled period, sort, direction, basis and precision combinations before choosing an Activity ranking. Availability is evaluated before user filters; do not infer combinations from independent enums. Honor each view's sort_directions and default_direction and the enabled label list. The snapshot block identifies the published version these capabilities belong to.",
|
|
561
|
+
inputSchema: z.object({ snapshot: activitySnapshotSchema.optional() }).strict(),
|
|
562
|
+
outputSchema: { ...apiEnvelopeOutputShape(), tabs: z.array(jsonRecordSchema()).optional(), version: z.union([z.string(), z.number()]).optional(), facets: jsonRecordSchema().optional(), labels: z.array(z.string()).optional(), listing: jsonRecordSchema().optional(), listing_payouts_enabled: z.boolean().optional(), derived_signals_enabled: z.boolean().optional(), snapshot: jsonRecordSchema().optional() },
|
|
563
|
+
annotations: readOnlyAnnotations
|
|
564
|
+
}, (args) => runTool("get_activity_capabilities", config, rateLimiter, async () => {
|
|
565
|
+
assertProFeature(client, "activity_capabilities", "enabled");
|
|
566
|
+
assertActivitySnapshotSelector(client, "activity_capabilities", args.snapshot);
|
|
567
|
+
return proPage(await client.getActivityCapabilities(toQuery({ snapshot: args.snapshot })), "tabs");
|
|
568
|
+
}));
|
|
569
|
+
registerTool("get_activity_snapshot", {
|
|
570
|
+
title: "Get Current Activity Version",
|
|
571
|
+
description: "Read only the identity of the currently published Activity version: snapshot_id, frozen as_of cutoff, expires_at, policy_version and suggested poll_seconds. Pass snapshot_id as snapshot only to get_activity_capabilities, get_activity_rankings and get_program_payouts. Catalog/feed tools such as find_programs use snapshot=start then meta.snapshot instead. as_of is when BBRadar froze its calculation, not a provider payment or update time.",
|
|
572
|
+
inputSchema: z.object({}).strict(),
|
|
573
|
+
outputSchema: { ...apiEnvelopeOutputShape(), snapshot_id: z.string().optional(), as_of: z.string().optional(), expires_at: z.string().optional(), policy_version: z.string().optional(), poll_seconds: z.number().optional() },
|
|
574
|
+
annotations: readOnlyAnnotations
|
|
575
|
+
}, () => runTool("get_activity_snapshot", config, rateLimiter, async () => {
|
|
576
|
+
assertProFeature(client, "activity_snapshot", "enabled");
|
|
577
|
+
const api = await client.getActivitySnapshot();
|
|
578
|
+
const data = readObject(api.data);
|
|
579
|
+
if (!data || typeof data.snapshot_id !== "string" || !/^[0-9a-f]{64}$/.test(data.snapshot_id)) {
|
|
580
|
+
throw new Error("Malformed Activity version response.");
|
|
581
|
+
}
|
|
582
|
+
return withApiMetadata(api, sanitizeProPayload(api.data));
|
|
583
|
+
}));
|
|
584
|
+
registerTool("get_program_payouts", {
|
|
585
|
+
title: "Get Program Payouts and Response Facts",
|
|
586
|
+
description: "Read reported totals, measured counter increases, source dates, precision, report and response facts, derived signals and label explanations for one exact platform:handle. Keep null separate from zero, bounds separate from exact amounts and unknown payment dates null. Payout timestamps are when increases were seen, never payment dates. No provider requests are made.",
|
|
587
|
+
inputSchema: z.object({ program_id: programIdSchema, snapshot: activitySnapshotSchema.optional(), include_map: includeMapSchema }).strict(),
|
|
588
|
+
// Mirrors the API response, including its version identity: a client that
|
|
589
|
+
// discovered this schema validates every structured result against it.
|
|
590
|
+
outputSchema: { ...apiEnvelopeOutputShape(), program_id: z.string().optional(), program: jsonRecordSchema().optional(), summary: jsonRecordSchema().optional(), as_of: z.string().optional(), snapshot_id: z.string().optional(), expires_at: z.string().nullable().optional(), facts: z.array(jsonRecordSchema()).optional(), windows: jsonRecordSchema().optional(), series: z.array(jsonRecordSchema()).optional(), payout_summary: jsonRecordSchema().nullable().optional(), views: z.array(jsonRecordSchema()).optional(), reports: jsonRecordSchema().optional(), response: jsonRecordSchema().optional(), last_increase_at: z.string().nullable().optional(), signals: jsonRecordSchema().optional(), label_details: z.array(jsonRecordSchema()).optional(), labels: z.array(z.string()).optional(), value_points: z.array(jsonRecordSchema()).optional(), map_points_omitted: z.number().int().optional(), meta: jsonRecordSchema().optional() },
|
|
591
|
+
annotations: readOnlyAnnotations
|
|
592
|
+
}, (args) => runTool("get_program_payouts", config, rateLimiter, async () => {
|
|
593
|
+
assertProFeature(client, "program_payouts", "enabled");
|
|
594
|
+
assertActivitySnapshotSelector(client, "program_payouts", args.snapshot);
|
|
595
|
+
const api = await client.getProgramPayouts(args.program_id, toQuery({ snapshot: args.snapshot }));
|
|
596
|
+
if (!readObject(api.data))
|
|
597
|
+
throw new Error("Malformed program payout response.");
|
|
598
|
+
return omitMapPoints(withApiMetadata(api, sanitizeProPayload(api.data)), "value_points", args.include_map);
|
|
599
|
+
}));
|
|
600
|
+
const readActivityRanking = async (tab, args) => {
|
|
601
|
+
assertProFeature(client, "activity_rankings", "enabled");
|
|
602
|
+
assertActivitySnapshotSelector(client, "activity_rankings", args.snapshot);
|
|
603
|
+
assertActivityTierFilter(client, args.opportunity_tiers);
|
|
604
|
+
const { include_map, ...query } = args;
|
|
605
|
+
return activityRankingPage(await client.getActivityRankings(toQuery({ ...query, tab })), include_map === true);
|
|
606
|
+
};
|
|
607
|
+
registerTool("get_activity_rankings", {
|
|
608
|
+
title: "Get Activity Ranking",
|
|
609
|
+
description: "One globally ranked server page of an Activity view, frozen at meta.as_of. Read get_activity_capabilities first: explicit period/basis/precision/sort/direction never fall back, and each tab only accepts its own inputs. tab: payouts (reported and tracked bases stay separate; proven_bounds desc = highest reported minimum, asc = lowest reported maximum, never an exact ranking; source totals can lag); value (fixed lifetime generosity and 90-day volume percentiles, no period; one label gives its shortlist); scope (recorded additions/removals/net, not inventory differences); rewards (net reward-range change, unknown % stays null); updates (active UTC days, change groups); reports (received counts; volume is not competition; all_time is the lifetime counter); response (resolved counts, last resolution, response time; time to bounty is award timing); status (first_seen/paused/resumed/left_listing/returned events); all_changes (chronological events; get_activity_change_detail for before/after). Event tabs use rolling 7d/30d/90d, not calendar months. launched_within applies to reports/response. Payout increases are observed counters, not payment dates. Reuse cursors only with identical inputs. For several signals at once, or with Web2/scope/competition/fee filters, use find_programs or find_hunt_targets.",
|
|
610
|
+
inputSchema: z.object(activityParams).strict(),
|
|
611
|
+
outputSchema: activityOutput,
|
|
612
|
+
annotations: readOnlyAnnotations
|
|
613
|
+
}, (args) => runTool("get_activity_rankings", config, rateLimiter, async () => {
|
|
614
|
+
assertActivityTabInputs(args.tab, args);
|
|
615
|
+
return readActivityRanking(args.tab, args);
|
|
616
|
+
}));
|
|
617
|
+
// Single-view aliases for the full profile; each schema exposes only the
|
|
618
|
+
// inputs its view accepts and shares the ranking tool's request path.
|
|
619
|
+
const activityDescriptions = {
|
|
620
|
+
value: "Discover programs with frozen within-platform value and report-volume percentiles. Generosity is lifetime; volume is 90 days. No period or launch-cohort input applies. Use a label-specific shortlist: Pays near top orders by realization, No payouts seen by received_90d (raw count with its basis; small cohorts remain eligible). Comparative ratio sorts use server percentiles; never recompute after filtering. Preserve label_details explanations and destinations. A counter increase is observed activity, not an individual payment. Map points are returned only with include_map.",
|
|
621
|
+
payouts: "Rank payouts using the same server calculation as BBRadar. Last 7 days and last 30 days are rolling windows. Explicit periods never fall back. For high or low payouts lately, use the same enabled tracked-30-day cohort, or separately label a native-90-day cohort. Reported and tracked bases stay separate. With proven_bounds, descending is Highest reported minimum using lower bounds; ascending is Lowest reported maximum using upper bounds. Neither claims an exact ranking. Source totals can be delayed, including Immunefi.",
|
|
622
|
+
scope: "Rank recorded scope additions, removals, net movement or largest update; not differences between inventory totals. Periods are rolling 7, 30 or 90 days, not calendar months.",
|
|
623
|
+
rewards: "Rank comparable net reward-range changes over rolling 7, 30 or 90 days. Unknown change percentages remain null.",
|
|
624
|
+
updates: "Rank active UTC days and recorded change groups over rolling periods. Baselines and internal corrections are excluded.",
|
|
625
|
+
reports: "Rank received report counts. Each program uses measured lifetime-counter growth where available, otherwise its supported native period. All-time is the current lifetime counter. Previous and change compare like-for-like. Report volume is not competition. Launch cohorts use the canonical launch date; amounts are not reports since launch.",
|
|
626
|
+
response: "Rank resolved counter growth or current lifetime counters, last resolution, response duration or time to bounty. Keep the provider's definition: time to bounty is award timing, not payment settlement. Period applies only to resolved counts.",
|
|
627
|
+
status: "Read distinct First seen, Paused, Resumed, Left listing and Returned events. Event type and current status are separate filters; never merge events from one program.",
|
|
628
|
+
all_changes: "Read chronological semantic program changes with event IDs. Fetch get_activity_change_detail lazily for before/after items, including eligible events for programs that left the catalog. Separate events stay separate."
|
|
629
|
+
};
|
|
630
|
+
for (const tab of ACTIVITY_TABS) {
|
|
631
|
+
const name = `get_activity_${tab === "all_changes" ? "changes" : tab}`;
|
|
632
|
+
const allowed = new Set([...ACTIVITY_SHARED_PARAMS, ...ACTIVITY_TAB_PARAMS[tab]]);
|
|
633
|
+
const shape = Object.fromEntries(Object.entries(activityParams).filter(([key]) => key !== "tab" && allowed.has(key)));
|
|
634
|
+
if (!ACTIVITY_COUNT_TABS.has(tab) && shape.period)
|
|
635
|
+
shape.period = z.enum(ACTIVITY_EVENT_PERIODS).optional();
|
|
636
|
+
registerTool(name, {
|
|
637
|
+
title: `Get Activity ${tab === "all_changes" ? "Changes" : tab[0].toUpperCase() + tab.slice(1)}`,
|
|
638
|
+
description: `${activityDescriptions[tab]} One globally ranked server page, with a frozen as-of cutoff. Reuse cursors only with unchanged filters. Use get_activity_capabilities for supported combinations.`,
|
|
639
|
+
inputSchema: z.object(shape).strict(), outputSchema: activityOutput, annotations: readOnlyAnnotations
|
|
640
|
+
}, (args) => runTool(name, config, rateLimiter, () => readActivityRanking(tab, args)));
|
|
641
|
+
}
|
|
642
|
+
registerTool("get_activity_change_detail", {
|
|
643
|
+
title: "Get Activity Change Detail",
|
|
644
|
+
description: "Read one bounded page of recorded before/after items for an exact Activity event ID in its source snapshot. Pass meta.snapshot_id from the Activity page. Retained eligible events may describe programs that have left the catalog. Reuse an opaque cursor only with the same event, snapshot and limit.",
|
|
645
|
+
inputSchema: z.object({ event_id: z.string().min(1).max(128), snapshot: activitySnapshotSchema.describe("meta.snapshot_id from the Activity page that supplied this event."), cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(50) }).strict(),
|
|
646
|
+
outputSchema: { ...apiEnvelopeOutputShape(), change: jsonRecordSchema().optional(), items: z.array(jsonRecordSchema()).optional(), meta: jsonRecordSchema().optional() }, annotations: readOnlyAnnotations
|
|
647
|
+
}, (args) => runTool("get_activity_change_detail", config, rateLimiter, async () => {
|
|
648
|
+
assertProFeature(client, "activity_event_detail", "enabled");
|
|
649
|
+
return proPage(await client.getActivityChangeDetail(args.event_id, toQuery({ snapshot: args.snapshot, cursor: args.cursor, limit: args.limit })), "items");
|
|
650
|
+
}));
|
|
481
651
|
registerTool("get_history_changes", {
|
|
482
652
|
title: "Get Program History Changes",
|
|
483
653
|
description: "One page of semantic program History. Use get_program_history for an exact program, especially Self-Hosted. History may be partial or unavailable; inspect coverage.",
|
|
@@ -554,8 +724,8 @@ export function createBbradarServer(client, config) {
|
|
|
554
724
|
}));
|
|
555
725
|
registerTool("search_programs", {
|
|
556
726
|
title: "Search BBRadar Programs",
|
|
557
|
-
description: `Search or list active and confirmed paused platform programs plus active Self-Hosted programs. page_size max ${maxProgramsPageSize}. Stable snapshots are opt-in
|
|
558
|
-
inputSchema: {
|
|
727
|
+
description: `Search or list active and confirmed paused platform programs plus active Self-Hosted programs. page_size max ${maxProgramsPageSize}. Stable snapshots are opt-in. For payout, report, scope-change or other Activity filters use find_programs.`,
|
|
728
|
+
inputSchema: z.object({
|
|
559
729
|
search: searchTextSchema.optional().describe("Server-side program name, handle, platform, policy-host, or target search."),
|
|
560
730
|
program_kind: z.enum(["bounty", "contest", "challenge"]).optional(),
|
|
561
731
|
platforms: platformListSchema(),
|
|
@@ -565,7 +735,7 @@ export function createBbradarServer(client, config) {
|
|
|
565
735
|
vdp_mode: vdpModeSchema.default("exclude").describe("VDP/no-bounty filter mode."),
|
|
566
736
|
page: pageSchema,
|
|
567
737
|
page_size: programsPageSizeSchema
|
|
568
|
-
},
|
|
738
|
+
}).strict(),
|
|
569
739
|
outputSchema: programListOutputShape(),
|
|
570
740
|
annotations: readOnlyAnnotations
|
|
571
741
|
}, (args) => runTool("search_programs", config, rateLimiter, async () => {
|
|
@@ -756,6 +926,7 @@ export function createBbradarServer(client, config) {
|
|
|
756
926
|
program_id: programIdentifierSchema,
|
|
757
927
|
search: searchTextSchema.optional(),
|
|
758
928
|
target_type: filterValueSchema().optional(),
|
|
929
|
+
language_tags: languageTagListSchema().describe("Keep targets with any requested language tag. Applied while paging; repeat with the returned next_offset."),
|
|
759
930
|
min_target_score: z.number().int().min(0).max(100).optional(),
|
|
760
931
|
target_opportunity_label: targetOpportunityLabelSchema.optional(),
|
|
761
932
|
competition_risk: competitionRiskSchema.optional(),
|
|
@@ -924,8 +1095,9 @@ export function createBbradarServer(client, config) {
|
|
|
924
1095
|
}));
|
|
925
1096
|
registerTool("find_hunt_targets", {
|
|
926
1097
|
title: "Find Best Hunt Targets",
|
|
927
|
-
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.",
|
|
928
|
-
inputSchema: {
|
|
1098
|
+
description: "Preferred one-call path for requests combining good rewards, broad Web2/Web3 scope, target quality, competition, recent payouts or scope changes, and recently added targets. Activity inputs (e.g. payout_period=30d with payout_positive=true, scope_period=30d with scope_added_min=1) first narrow candidates on the server in global order; the other filters and ranking_mode then apply locally. 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.",
|
|
1099
|
+
inputSchema: z.object({
|
|
1100
|
+
...activityCatalogInput,
|
|
929
1101
|
platforms: platformListSchema(),
|
|
930
1102
|
exclude_platforms: stringListSchema("Platforms to exclude from the final shortlist."),
|
|
931
1103
|
tags: tagListSchema(),
|
|
@@ -952,9 +1124,10 @@ export function createBbradarServer(client, config) {
|
|
|
952
1124
|
max_program_pages: z.number().int().min(1).max(MAX_FIND_PROGRAM_PAGES).default(DEFAULT_HUNT_PROGRAM_PAGES),
|
|
953
1125
|
max_results: z.number().int().min(1).max(MAX_HUNT_RESULTS).default(10),
|
|
954
1126
|
targets_per_result: z.number().int().min(1).max(MAX_HUNT_TARGETS_PER_RESULT).default(5),
|
|
1127
|
+
free_submission_only: freeSubmissionOnlySchema,
|
|
955
1128
|
ranking_mode: z.enum(["balanced", "highest_reward", "widest_scope", "freshest_targets", "lowest_competition"]).default("balanced"),
|
|
956
1129
|
upstream_request_budget: z.number().int().min(2).max(MAX_FIND_UPSTREAM_REQUEST_BUDGET).default(DEFAULT_FIND_UPSTREAM_REQUEST_BUDGET)
|
|
957
|
-
},
|
|
1130
|
+
}).strict(),
|
|
958
1131
|
outputSchema: {
|
|
959
1132
|
...apiEnvelopeOutputShape(),
|
|
960
1133
|
matches: z.array(jsonRecordSchema()).optional(),
|
|
@@ -962,11 +1135,15 @@ export function createBbradarServer(client, config) {
|
|
|
962
1135
|
errors: z.array(jsonRecordSchema()).optional(),
|
|
963
1136
|
upstream_budget: jsonRecordSchema().optional(),
|
|
964
1137
|
ranking_scope: jsonRecordSchema().optional(),
|
|
1138
|
+
scan: jsonRecordSchema().optional(),
|
|
965
1139
|
result_guidance: jsonRecordSchema().optional(),
|
|
966
1140
|
meta: jsonRecordSchema().optional()
|
|
967
1141
|
},
|
|
968
1142
|
annotations: readOnlyAnnotations
|
|
969
|
-
}, (args) => runTool("find_hunt_targets", config, rateLimiter, async () =>
|
|
1143
|
+
}, (args) => runTool("find_hunt_targets", config, rateLimiter, async () => {
|
|
1144
|
+
const activity = Object.fromEntries(Object.keys(activityCatalogInput).map((key) => [key, args[key]]));
|
|
1145
|
+
return findHuntTargets(client, config, args, activity);
|
|
1146
|
+
}));
|
|
970
1147
|
registerTool("find_repository_hunt_targets", {
|
|
971
1148
|
title: "Find Repository-Aware Hunt Targets",
|
|
972
1149
|
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.",
|
|
@@ -1027,7 +1204,7 @@ export function createBbradarServer(client, config) {
|
|
|
1027
1204
|
include_ineligible: z.boolean().default(false).describe("Include ineligible."),
|
|
1028
1205
|
recent_changes_page_size: recentChangesPageSizeSchema.default(Math.min(MAX_RECENT_CHANGES, maxTargetChangesPageSize)),
|
|
1029
1206
|
max_results: z.number().int().min(1).max(MAX_RECENT_CHANGES).default(10),
|
|
1030
|
-
include_full_target_list: z.boolean().default(true).describe("Fetch the newest matching program
|
|
1207
|
+
include_full_target_list: z.boolean().default(true).describe("Fetch up to full_target_limit targets for the newest matching program. Check full_targets_has_more and continue with get_program_targets at full_targets_next_offset. Set false for the fastest cross-program discovery."),
|
|
1031
1208
|
full_target_list_mode: targetListModeSchema.default("identifiers"),
|
|
1032
1209
|
full_target_limit: z.number().int().min(1).max(MAX_TARGETS_PER_PROGRAM).default(MAX_TARGETS_PER_PROGRAM),
|
|
1033
1210
|
full_list_include_out_of_scope: z.boolean().default(false),
|
|
@@ -1184,16 +1361,19 @@ export function createBbradarServer(client, config) {
|
|
|
1184
1361
|
}, (args) => runTool("check_watchlist_new_targets", config, rateLimiter, async () => checkWatchlistNewTargets(client, config, args)));
|
|
1185
1362
|
registerTool("get_opportunities", {
|
|
1186
1363
|
title: "Get BBRadar Opportunities",
|
|
1187
|
-
description: `List programs in one opportunity tier. page_size max ${MAX_PROGRAMS_PER_SEARCH}.`,
|
|
1188
|
-
inputSchema: {
|
|
1364
|
+
description: `List programs in one opportunity tier. page_size max ${MAX_PROGRAMS_PER_SEARCH}. For Activity filters within a tier use find_programs with one opportunity_levels entry.`,
|
|
1365
|
+
inputSchema: z.object({
|
|
1189
1366
|
level: opportunityLevelSchema.default("elite"),
|
|
1367
|
+
program_kind: z.enum(["bounty", "contest", "challenge"]).optional(),
|
|
1368
|
+
search: searchTextSchema.optional(),
|
|
1190
1369
|
platforms: platformListSchema(),
|
|
1191
1370
|
tags: tagListSchema(),
|
|
1192
1371
|
updated_since: isoDateTimeSchema.optional(),
|
|
1372
|
+
snapshot: stableSnapshotSchema.optional(),
|
|
1193
1373
|
vdp_mode: vdpModeSchema.default("exclude").describe("VDP/no-bounty filter mode."),
|
|
1194
1374
|
page: pageSchema,
|
|
1195
1375
|
page_size: programsPageSizeSchema
|
|
1196
|
-
},
|
|
1376
|
+
}).strict(),
|
|
1197
1377
|
outputSchema: {
|
|
1198
1378
|
...programListOutputShape(),
|
|
1199
1379
|
level: opportunityLevelSchema.optional()
|
|
@@ -1201,11 +1381,15 @@ export function createBbradarServer(client, config) {
|
|
|
1201
1381
|
annotations: readOnlyAnnotations
|
|
1202
1382
|
}, (args) => runTool("get_opportunities", config, rateLimiter, async () => {
|
|
1203
1383
|
const { level, vdp_mode, ...filters } = args;
|
|
1204
|
-
|
|
1384
|
+
assertStableSnapshotSupported(client, "opportunities", filters.snapshot);
|
|
1385
|
+
const combined = args.program_kind !== undefined;
|
|
1386
|
+
if (combined)
|
|
1387
|
+
assertCatalogQuerySupported(client, "opportunities", args);
|
|
1388
|
+
const api = await client.getOpportunities(level, toQuery({ ...filters, vdp_mode: combined ? vdp_mode : undefined }));
|
|
1205
1389
|
const data = readObject(api.data);
|
|
1206
1390
|
const programs = readArray(data?.programs)
|
|
1207
1391
|
.map((program) => toProgramCandidate(program, config.webBaseUrl))
|
|
1208
|
-
.filter((candidate) => candidateMatchesVdpMode(candidate, vdp_mode))
|
|
1392
|
+
.filter((candidate) => combined || candidateMatchesVdpMode(candidate, vdp_mode))
|
|
1209
1393
|
.map((candidate) => candidate.program);
|
|
1210
1394
|
return withApiMetadata(api, {
|
|
1211
1395
|
level,
|
|
@@ -1215,8 +1399,12 @@ export function createBbradarServer(client, config) {
|
|
|
1215
1399
|
}));
|
|
1216
1400
|
registerTool("find_programs", {
|
|
1217
1401
|
title: "Find BBRadar Programs",
|
|
1218
|
-
description:
|
|
1219
|
-
inputSchema: {
|
|
1402
|
+
description: `Program discovery. Activity inputs (payouts, reports, scope/reward changes, response, status, labels) run on the server over the complete catalog in one global order, within at most one opportunity tier. They combine freely with local filters (Web2/Web3, wildcards, target counts, bounty, competition_risks, report counts, free_submission_only, freshness): pages are read in server order and local filters only remove programs; scan.complete and scan.next_cursor report coverage. Sampled sorts need no Activity inputs. ${activityCatalogGuidance}`,
|
|
1403
|
+
inputSchema: z.object({
|
|
1404
|
+
...activityCatalogInput,
|
|
1405
|
+
search: searchTextSchema.optional(),
|
|
1406
|
+
snapshot: stableSnapshotSchema.optional(),
|
|
1407
|
+
page: z.number().int().min(1).optional(),
|
|
1220
1408
|
platforms: platformListSchema(),
|
|
1221
1409
|
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."),
|
|
1222
1410
|
tags: tagListSchema(),
|
|
@@ -1226,6 +1414,9 @@ export function createBbradarServer(client, config) {
|
|
|
1226
1414
|
web3: z.boolean().default(false).describe("Require Web3 signals."),
|
|
1227
1415
|
program_kind: z.enum(["bounty", "contest", "challenge"]).optional().describe("Source-classified program kind; sent to the Pro program list."),
|
|
1228
1416
|
exclude_web3: z.boolean().default(false).describe("Exclude programs with Web3, blockchain, smart-contract, crypto, DeFi, NFT, Solidity, Ethereum, or Solana signals. Use this for Web2-only discovery."),
|
|
1417
|
+
free_submission_only: freeSubmissionOnlySchema,
|
|
1418
|
+
competition_risks: z.array(competitionRiskSchema).max(4).default([]).describe("Program competition labels to keep, e.g. [\"Low\"]."),
|
|
1419
|
+
cursor: z.string().min(1).max(512).optional().describe("scan.next_cursor from the previous result; repeat the same filters."),
|
|
1229
1420
|
opportunity_levels: z.array(opportunityLevelSchema).max(4).default([]),
|
|
1230
1421
|
updated_since: isoDateTimeSchema.optional(),
|
|
1231
1422
|
min_bounty_min: z.number().int().min(0).optional(),
|
|
@@ -1249,7 +1440,7 @@ export function createBbradarServer(client, config) {
|
|
|
1249
1440
|
min_added_7d: z.number().int().min(0).optional(),
|
|
1250
1441
|
max_pages: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(3),
|
|
1251
1442
|
max_results: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(10),
|
|
1252
|
-
sort_by: programSortSchema,
|
|
1443
|
+
sort_by: z.union([programSortSchema.removeDefault(), activityCatalogSortSchema]).optional(),
|
|
1253
1444
|
include_target_samples: z.boolean().default(false),
|
|
1254
1445
|
target_sample_programs: z.number().int().min(0).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(3),
|
|
1255
1446
|
target_sample_size: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(5),
|
|
@@ -1257,13 +1448,40 @@ export function createBbradarServer(client, config) {
|
|
|
1257
1448
|
only_bounty_eligible_targets: z.boolean().default(true),
|
|
1258
1449
|
output_mode: programListModeSchema.default("compact"),
|
|
1259
1450
|
upstream_request_budget: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(DEFAULT_FIND_UPSTREAM_REQUEST_BUDGET)
|
|
1260
|
-
},
|
|
1451
|
+
}).strict(),
|
|
1261
1452
|
outputSchema: findProgramsOutputShape(),
|
|
1262
1453
|
annotations: readOnlyAnnotations
|
|
1263
|
-
}, (args) => runTool("find_programs", config, rateLimiter, async () =>
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1454
|
+
}, (args) => runTool("find_programs", config, rateLimiter, async () => {
|
|
1455
|
+
const serverCatalog = hasActivityCatalogInput(args) || args.snapshot !== undefined || args.page !== undefined || args.cursor !== undefined
|
|
1456
|
+
|| (args.search !== undefined && supportsCatalogFilters(client, args.opportunity_levels.length ? "opportunities" : "programs")
|
|
1457
|
+
&& !legacyCatalogFilters(args).length && ["include", "exclude", "only"].includes(args.vdp_mode));
|
|
1458
|
+
if (serverCatalog && args.cursor === undefined && !legacyCatalogFilters(args).length)
|
|
1459
|
+
return findServerCatalogPrograms(client, config, args);
|
|
1460
|
+
if (serverCatalog) {
|
|
1461
|
+
// Platform, tag, kind, VDP, update and status predicates were applied
|
|
1462
|
+
// by the server; only the remaining filters run locally.
|
|
1463
|
+
return findFilteredServerPrograms(client, config, args, {
|
|
1464
|
+
...args, sort_by: "best_match", platforms: [], tags: [], program_kind: undefined, contest_like: false,
|
|
1465
|
+
vdp_mode: "include", opportunity_levels: [], updated_since: undefined, skip_status_check: true,
|
|
1466
|
+
min_bounty_min: args.min_bounty_min, min_bounty_max: args.min_bounty_max, max_bounty_max: args.max_bounty_max,
|
|
1467
|
+
max_public_report_count: args.max_public_report_count, fresh_launch_days: args.fresh_launch_days,
|
|
1468
|
+
min_eligible_targets: args.min_eligible_targets, min_total_targets: args.min_total_targets,
|
|
1469
|
+
min_added_24h: args.min_added_24h, min_added_7d: args.min_added_7d,
|
|
1470
|
+
target_samples_only_wildcards: args.min_wildcards > 0
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
return findPrograms(client, config, {
|
|
1474
|
+
...args,
|
|
1475
|
+
updated_since: args.updated_since,
|
|
1476
|
+
min_bounty_min: args.min_bounty_min, min_bounty_max: args.min_bounty_max,
|
|
1477
|
+
max_bounty_max: args.max_bounty_max, max_public_report_count: args.max_public_report_count,
|
|
1478
|
+
fresh_launch_days: args.fresh_launch_days,
|
|
1479
|
+
min_eligible_targets: args.min_eligible_targets, min_total_targets: args.min_total_targets,
|
|
1480
|
+
min_added_24h: args.min_added_24h, min_added_7d: args.min_added_7d,
|
|
1481
|
+
sort_by: programSortSchema.parse(args.sort_by),
|
|
1482
|
+
target_samples_only_wildcards: args.min_wildcards > 0
|
|
1483
|
+
});
|
|
1484
|
+
}));
|
|
1267
1485
|
registerTool("find_wildcard_programs", {
|
|
1268
1486
|
title: "Find Wildcard Programs",
|
|
1269
1487
|
description: "Wildcard program shortlist with platform, minimum reward, freshness, and report-count filters.",
|
|
@@ -2177,6 +2395,9 @@ function bbradarGuide(topic, profile, client) {
|
|
|
2177
2395
|
arguments: { include_targets: false, max_cursor_pages: 2 },
|
|
2178
2396
|
then: "For incremental polling, seed once with after_id and later set resume_cursor=true with the same filters."
|
|
2179
2397
|
},
|
|
2398
|
+
{ user_goal: "program payouts, recent reports, response times or Activity rankings", first_tool: "get_activity_capabilities", arguments: {}, then: "Choose an enabled period, sort and direction, then call get_activity_rankings with that tab for one server-ranked page. Do not approximate by paging or re-ranking the catalog." },
|
|
2399
|
+
{ user_goal: "programs with recent payouts and new scope that also match Web2, competition or fee criteria", first_tool: "find_hunt_targets", arguments: { payout_period: "30d", payout_positive: true, scope_period: "30d", scope_added_min: 1, surface_mode: "web2", free_submission_only: true, ranking_mode: "lowest_competition" }, then: "Use find_programs with the same Activity inputs and competition_risks for program-only results; continue with scan.next_cursor." },
|
|
2400
|
+
{ user_goal: "payout and report facts for one exact program", first_tool: "get_program_payouts", arguments: { program_id: "Platform:handle" } },
|
|
2180
2401
|
{ user_goal: "semantic program change history", first_tool: "get_program_history", arguments: { program_id: "Platform:handle" }, then: "Fetch get_history_change_detail only for a selected change." },
|
|
2181
2402
|
{ user_goal: "most active programs in a date range", first_tool: "get_analytics_program_activity", arguments: {}, then: "Use this server-side ranking rather than sampling the program catalog." },
|
|
2182
2403
|
{ user_goal: "exact recorded activity for one program", first_tool: "get_program_activity", arguments: { program_id: "Platform:handle" } },
|
|
@@ -2290,6 +2511,7 @@ function bbradarGuide(topic, profile, client) {
|
|
|
2290
2511
|
decision_tree: decisionTree.filter((entry) => toolAvailableForContract(client, String(entry.first_tool)) &&
|
|
2291
2512
|
(topic === "start" || guideEntryMatchesTopic(topic, entry))),
|
|
2292
2513
|
filters,
|
|
2514
|
+
activity_catalog: activityCatalogGuidance,
|
|
2293
2515
|
advanced_tools: advancedTools,
|
|
2294
2516
|
examples
|
|
2295
2517
|
};
|
|
@@ -2322,6 +2544,9 @@ function coreBbradarGuide(topic, client) {
|
|
|
2322
2544
|
first_tool: "get_repository_changes",
|
|
2323
2545
|
arguments: { include_targets: false, max_cursor_pages: 2 }
|
|
2324
2546
|
},
|
|
2547
|
+
{ user_goal: "program payouts, recent reports, response times or Activity rankings", first_tool: "get_activity_capabilities", arguments: {}, then: "Read supported view combinations and call get_activity_rankings with that tab. Use get_activity_change_detail lazily for a selected Activity event." },
|
|
2548
|
+
{ user_goal: "programs with recent payouts and new scope that also match Web2, competition or fee criteria", first_tool: "find_hunt_targets", arguments: { payout_period: "30d", payout_positive: true, scope_period: "30d", scope_added_min: 1, surface_mode: "web2", free_submission_only: true, ranking_mode: "lowest_competition" } },
|
|
2549
|
+
{ user_goal: "payout and report facts for one exact program", first_tool: "get_program_payouts", arguments: { program_id: "Platform:handle" } },
|
|
2325
2550
|
{ user_goal: "semantic program change history", first_tool: "get_program_history", arguments: { program_id: "Platform:handle" }, then: "Fetch change detail only when needed." },
|
|
2326
2551
|
{ user_goal: "most active programs", first_tool: "get_analytics_program_activity", arguments: {} },
|
|
2327
2552
|
{ user_goal: "exact recorded activity", first_tool: "get_program_activity", arguments: { program_id: "Platform:handle" } },
|
|
@@ -2365,7 +2590,8 @@ function coreBbradarGuide(topic, client) {
|
|
|
2365
2590
|
],
|
|
2366
2591
|
decision_tree: decisionTree.filter((entry) => toolAvailableForContract(client, String(entry.first_tool)) &&
|
|
2367
2592
|
(topic === "start" || guideEntryMatchesTopic(topic, entry))),
|
|
2368
|
-
available_tools: [...CORE_TOOL_NAMES].filter((name) => toolAvailableForContract(client, name))
|
|
2593
|
+
available_tools: [...CORE_TOOL_NAMES].filter((name) => toolAvailableForContract(client, name)),
|
|
2594
|
+
activity_catalog: activityCatalogGuidance
|
|
2369
2595
|
};
|
|
2370
2596
|
}
|
|
2371
2597
|
function toolAvailableForContract(client, toolName) {
|
|
@@ -3375,29 +3601,14 @@ async function runResolvedProgramAction(client, config, programId, input) {
|
|
|
3375
3601
|
});
|
|
3376
3602
|
}
|
|
3377
3603
|
if (input.action === "targets") {
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
const sanitizedTargets = rawTargets
|
|
3382
|
-
.map(sanitizeTarget)
|
|
3383
|
-
.filter((target) => targetHasAllowedScope(target, {
|
|
3604
|
+
return getProgramTargetsPayload(client, {
|
|
3605
|
+
target_type: input.target_type,
|
|
3606
|
+
language_tags: input.language_tags,
|
|
3384
3607
|
include_out_of_scope: input.include_out_of_scope,
|
|
3385
3608
|
include_ineligible: input.include_ineligible,
|
|
3386
|
-
strict_scope_filter: true
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
return withApiMetadata(api, {
|
|
3390
|
-
program_id: programId,
|
|
3391
|
-
targets: formatTargetList(targets, input.target_list_mode),
|
|
3392
|
-
meta: {
|
|
3393
|
-
limit: input.max_targets,
|
|
3394
|
-
output_mode: input.target_list_mode,
|
|
3395
|
-
total_active_targets: rawTargets.length,
|
|
3396
|
-
total_after_filters: sanitizedTargets.length,
|
|
3397
|
-
returned: targets.length,
|
|
3398
|
-
has_more: sanitizedTargets.length > targets.length
|
|
3399
|
-
}
|
|
3400
|
-
});
|
|
3609
|
+
strict_scope_filter: true,
|
|
3610
|
+
offset: 0, limit: input.max_targets, output_mode: input.target_list_mode
|
|
3611
|
+
}, programId);
|
|
3401
3612
|
}
|
|
3402
3613
|
return checkProgramNewTargets(client, config, {
|
|
3403
3614
|
program_id: programId,
|
|
@@ -4523,30 +4734,18 @@ async function getLatestAddedTargets(client, config, input) {
|
|
|
4523
4734
|
.map((change) => readObject(change.target))
|
|
4524
4735
|
.filter((target) => target !== undefined);
|
|
4525
4736
|
let fullTargets = [];
|
|
4526
|
-
let
|
|
4527
|
-
let totalAfterFilters;
|
|
4737
|
+
let fullTargetsMeta;
|
|
4528
4738
|
if (input.include_full_target_list && programId) {
|
|
4529
|
-
const
|
|
4530
|
-
const targetsData = readObject(targetsApi.data);
|
|
4531
|
-
const rawTargets = readArray(targetsData?.targets);
|
|
4532
|
-
fullTargets = rawTargets
|
|
4533
|
-
.map(sanitizeTarget)
|
|
4534
|
-
.filter((target) => targetHasAllowedScope(target, {
|
|
4739
|
+
const payload = await getProgramTargetsPayload(client, {
|
|
4535
4740
|
include_out_of_scope: input.full_list_include_out_of_scope,
|
|
4536
4741
|
include_ineligible: input.full_list_include_ineligible,
|
|
4537
|
-
strict_scope_filter: false
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
program_id: programId,
|
|
4544
|
-
request_id: targetsApi.requestId,
|
|
4545
|
-
upstream_request_id: targetsApi.upstreamRequestId,
|
|
4546
|
-
...apiSourceMetadata(targetsApi)
|
|
4547
|
-
});
|
|
4742
|
+
strict_scope_filter: false,
|
|
4743
|
+
offset: 0, limit: input.full_target_limit, output_mode: input.full_target_list_mode
|
|
4744
|
+
}, programId);
|
|
4745
|
+
fullTargets = readArray(payload.targets);
|
|
4746
|
+
fullTargetsMeta = readObject(payload.meta);
|
|
4747
|
+
sourceRequests.push(...readArray(payload.source_requests).map(readObject).filter((request) => request !== undefined));
|
|
4548
4748
|
}
|
|
4549
|
-
const limitedFullTargets = fullTargets.slice(0, input.full_target_limit);
|
|
4550
4749
|
return stripUndefined({
|
|
4551
4750
|
request_id: randomUUID(),
|
|
4552
4751
|
source_requests: sourceRequests,
|
|
@@ -4555,17 +4754,19 @@ async function getLatestAddedTargets(client, config, input) {
|
|
|
4555
4754
|
program,
|
|
4556
4755
|
matches,
|
|
4557
4756
|
new_targets: formatTargetList(latestNewTargets, input.full_target_list_mode),
|
|
4558
|
-
full_targets: input.include_full_target_list ?
|
|
4757
|
+
full_targets: input.include_full_target_list ? fullTargets : undefined,
|
|
4559
4758
|
meta: stripUndefined({
|
|
4560
4759
|
recent_changes_scanned: changes.length,
|
|
4561
4760
|
matching_changes_scanned: matchingChanges.length,
|
|
4562
4761
|
matches_returned: matches.length,
|
|
4563
4762
|
matches_has_more: matches.length < matchingChanges.length,
|
|
4564
4763
|
new_targets_returned: formatTargetList(latestNewTargets, input.full_target_list_mode).length,
|
|
4565
|
-
full_targets_total_active:
|
|
4566
|
-
full_targets_total_after_filters:
|
|
4567
|
-
full_targets_returned: input.include_full_target_list ?
|
|
4568
|
-
full_targets_has_more:
|
|
4764
|
+
full_targets_total_active: fullTargetsMeta?.total_active_targets,
|
|
4765
|
+
full_targets_total_after_filters: fullTargetsMeta?.total_after_filters,
|
|
4766
|
+
full_targets_returned: input.include_full_target_list ? fullTargets.length : undefined,
|
|
4767
|
+
full_targets_has_more: fullTargetsMeta?.has_more,
|
|
4768
|
+
full_targets_next_offset: fullTargetsMeta?.next_offset,
|
|
4769
|
+
full_targets_possibly_incomplete: fullTargetsMeta?.possibly_incomplete
|
|
4569
4770
|
})
|
|
4570
4771
|
});
|
|
4571
4772
|
}
|
|
@@ -4603,15 +4804,21 @@ function latestAddedProgramMatches(change, input, platformFilters) {
|
|
|
4603
4804
|
return true;
|
|
4604
4805
|
}
|
|
4605
4806
|
async function getProgramTargetsPayload(client, input, programId) {
|
|
4606
|
-
const
|
|
4807
|
+
const maxPageSize = client.endpointNumber("program_targets", "max_page_size", MAX_TARGETS_PER_PROGRAM);
|
|
4607
4808
|
// `eligibility=eligible` is an API-level in-scope bounty-eligibility filter,
|
|
4608
4809
|
// so the common eligible-only path needs no second local scope pass.
|
|
4609
|
-
const
|
|
4810
|
+
const languageTags = new Set((input.language_tags ?? []).map(normalizeTag));
|
|
4811
|
+
const directOffsetPaging = languageTags.size === 0 && (!input.include_ineligible || (!input.strict_scope_filter && input.include_out_of_scope));
|
|
4812
|
+
// Bound direct reads to the requested prefix. Local post-filters still need
|
|
4813
|
+
// full pages to find sparse matches. Offsets remain filtered row offsets,
|
|
4814
|
+
// independent of the page size used by an earlier call.
|
|
4815
|
+
const pageSize = directOffsetPaging ? Math.min(maxPageSize, input.offset + input.limit) : maxPageSize;
|
|
4610
4816
|
const collected = [];
|
|
4611
4817
|
let matchedSeen = directOffsetPaging ? Math.floor(input.offset / pageSize) * pageSize : 0;
|
|
4612
4818
|
let page = directOffsetPaging ? Math.floor(input.offset / pageSize) + 1 : 1;
|
|
4613
4819
|
const firstPage = page;
|
|
4614
4820
|
let api;
|
|
4821
|
+
const sourceRequests = [];
|
|
4615
4822
|
let upstreamMeta;
|
|
4616
4823
|
let upstreamHasMore = false;
|
|
4617
4824
|
do {
|
|
@@ -4625,11 +4832,15 @@ async function getProgramTargetsPayload(client, input, programId) {
|
|
|
4625
4832
|
page,
|
|
4626
4833
|
page_size: pageSize
|
|
4627
4834
|
});
|
|
4835
|
+
sourceRequests.push({ source: "program_targets", program_id: programId, page,
|
|
4836
|
+
request_id: api.requestId, upstream_request_id: api.upstreamRequestId, ...apiSourceMetadata(api) });
|
|
4628
4837
|
const data = readObject(api.data);
|
|
4629
4838
|
upstreamMeta = readObject(data?.meta);
|
|
4630
4839
|
for (const target of readArray(data?.targets).map(sanitizeTarget)) {
|
|
4631
4840
|
if (!targetHasAllowedScope(target, input))
|
|
4632
4841
|
continue;
|
|
4842
|
+
if (languageTags.size > 0 && !arraysIntersectNormalized(languageTags, readStringArrayField(target, "language_tags")))
|
|
4843
|
+
continue;
|
|
4633
4844
|
if (matchedSeen >= input.offset && collected.length < input.limit)
|
|
4634
4845
|
collected.push(target);
|
|
4635
4846
|
matchedSeen += 1;
|
|
@@ -4647,8 +4858,10 @@ async function getProgramTargetsPayload(client, input, programId) {
|
|
|
4647
4858
|
throw new Error("No program target response was collected.");
|
|
4648
4859
|
}
|
|
4649
4860
|
const targets = collected;
|
|
4861
|
+
const hasMore = upstreamHasMore || matchedSeen > input.offset + targets.length;
|
|
4650
4862
|
return withApiMetadata(api, {
|
|
4651
4863
|
program_id: programId,
|
|
4864
|
+
source_requests: sourceRequests,
|
|
4652
4865
|
targets: formatTargetList(targets, input.output_mode),
|
|
4653
4866
|
meta: {
|
|
4654
4867
|
offset: input.offset,
|
|
@@ -4657,7 +4870,8 @@ async function getProgramTargetsPayload(client, input, programId) {
|
|
|
4657
4870
|
total_active_targets: readNumber(upstreamMeta?.total) ?? matchedSeen,
|
|
4658
4871
|
total_after_filters: upstreamHasMore ? undefined : matchedSeen,
|
|
4659
4872
|
returned: targets.length,
|
|
4660
|
-
has_more:
|
|
4873
|
+
has_more: hasMore,
|
|
4874
|
+
next_offset: hasMore ? input.offset + targets.length : undefined,
|
|
4661
4875
|
upstream_has_more: upstreamHasMore,
|
|
4662
4876
|
possibly_incomplete: upstreamHasMore,
|
|
4663
4877
|
upstream_pages_fetched: page - firstPage,
|
|
@@ -4670,9 +4884,16 @@ async function getProgramTargetsPayload(client, input, programId) {
|
|
|
4670
4884
|
});
|
|
4671
4885
|
}
|
|
4672
4886
|
async function getProgramTargetsExportFallbackPayload(client, input, programId) {
|
|
4887
|
+
if (input.search || input.target_type || input.min_target_score !== undefined
|
|
4888
|
+
|| input.target_opportunity_label || input.competition_risk) {
|
|
4889
|
+
throw new Error("The per-program targets endpoint is unavailable. Its search, type and intelligence filters cannot be preserved by the export fallback; the requested filters were not discarded.");
|
|
4890
|
+
}
|
|
4673
4891
|
const exportLookup = await fetchProgramTargetsWithEndpointFallback(client, programId);
|
|
4674
|
-
const
|
|
4892
|
+
const languageTags = new Set((input.language_tags ?? []).map(normalizeTag));
|
|
4893
|
+
const sanitizedTargets = exportLookup.rawTargets.filter((target) => targetHasAllowedScope(target, input)
|
|
4894
|
+
&& (languageTags.size === 0 || arraysIntersectNormalized(languageTags, readStringArrayField(target, "language_tags"))));
|
|
4675
4895
|
const targets = sanitizedTargets.slice(input.offset, input.offset + input.limit);
|
|
4896
|
+
const hasMore = input.offset + targets.length < sanitizedTargets.length;
|
|
4676
4897
|
return stripUndefined({
|
|
4677
4898
|
request_id: exportLookup.requestId,
|
|
4678
4899
|
upstream_request_id: exportLookup.upstreamRequestId,
|
|
@@ -4686,10 +4907,10 @@ async function getProgramTargetsExportFallbackPayload(client, input, programId)
|
|
|
4686
4907
|
offset: input.offset,
|
|
4687
4908
|
limit: input.limit,
|
|
4688
4909
|
output_mode: input.output_mode,
|
|
4689
|
-
|
|
4690
|
-
total_after_filters: sanitizedTargets.length,
|
|
4910
|
+
...fallbackTargetCoverage(exportLookup, sanitizedTargets.length),
|
|
4691
4911
|
returned: targets.length,
|
|
4692
|
-
has_more:
|
|
4912
|
+
has_more: hasMore || exportLookup.coverage?.possibly_incomplete === true,
|
|
4913
|
+
next_offset: hasMore ? input.offset + targets.length : undefined,
|
|
4693
4914
|
target_source: exportLookup.source,
|
|
4694
4915
|
target_list_unavailable: exportLookup.unavailable,
|
|
4695
4916
|
fallback_error: exportLookup.error
|
|
@@ -4718,6 +4939,7 @@ async function fetchProgramTargetsWithEndpointFallback(client, programId) {
|
|
|
4718
4939
|
})),
|
|
4719
4940
|
source: "program_targets",
|
|
4720
4941
|
rawTargets,
|
|
4942
|
+
coverage: targetPageCoverage(data, rawTargets.length, rawTargets.length),
|
|
4721
4943
|
sourceRequests: [
|
|
4722
4944
|
{
|
|
4723
4945
|
source: "program_targets",
|
|
@@ -4817,14 +5039,21 @@ function cacheTtlMsFromDiagnostics(cache) {
|
|
|
4817
5039
|
}
|
|
4818
5040
|
async function fetchProgramTargetsFromExportFallback(client, programId) {
|
|
4819
5041
|
try {
|
|
5042
|
+
const limit = Math.min(MAX_TARGET_EXPORT, client.endpointNumber("target_export", "max_targets", MAX_TARGET_EXPORT));
|
|
4820
5043
|
const api = await client.exportTargets({
|
|
4821
5044
|
program_ids: [programId],
|
|
4822
5045
|
include_out_of_scope: true,
|
|
4823
5046
|
include_ineligible: true,
|
|
4824
5047
|
format: "json",
|
|
4825
|
-
limit
|
|
5048
|
+
limit
|
|
4826
5049
|
});
|
|
4827
5050
|
const rawTargets = readExportTargetRows(api.data).map((row) => sanitizeTarget(readObject(row)?.target ?? row));
|
|
5051
|
+
const data = readObject(api.data);
|
|
5052
|
+
const coverage = targetPageCoverage({ meta: {
|
|
5053
|
+
...readObject(data?.meta),
|
|
5054
|
+
// A full capped export is not proof that the inventory ends here.
|
|
5055
|
+
has_more: readObject(data?.meta)?.has_more === true || rawTargets.length >= limit
|
|
5056
|
+
} }, rawTargets.length, rawTargets.length);
|
|
4828
5057
|
return {
|
|
4829
5058
|
requestId: api.requestId,
|
|
4830
5059
|
upstreamRequestId: api.upstreamRequestId,
|
|
@@ -4838,6 +5067,7 @@ async function fetchProgramTargetsFromExportFallback(client, programId) {
|
|
|
4838
5067
|
})),
|
|
4839
5068
|
source: "target_export",
|
|
4840
5069
|
rawTargets,
|
|
5070
|
+
coverage,
|
|
4841
5071
|
sourceRequests: [
|
|
4842
5072
|
{
|
|
4843
5073
|
source: "target_export",
|
|
@@ -5098,8 +5328,7 @@ async function getProgramScopeSummary(client, config, input) {
|
|
|
5098
5328
|
program: formatProgram(lookup.program, "compact"),
|
|
5099
5329
|
scope: buildTargetScopeSummary(targets, input.target_list_mode, input.group_limit, input.include_out_of_scope, input.include_ineligible),
|
|
5100
5330
|
meta: {
|
|
5101
|
-
|
|
5102
|
-
total_after_filters: targets.length,
|
|
5331
|
+
...targetPageCoverage(targetsData, rawTargets.length, targets.length),
|
|
5103
5332
|
target_list_mode: input.target_list_mode,
|
|
5104
5333
|
group_limit: input.group_limit,
|
|
5105
5334
|
target_source: "program_targets",
|
|
@@ -5124,8 +5353,7 @@ async function getProgramScopeSummaryFromIndexFallback(client, indexFallback, in
|
|
|
5124
5353
|
program: formatProgram(indexFallback.program, "compact"),
|
|
5125
5354
|
scope: buildTargetScopeSummary(targets, input.target_list_mode, input.group_limit, input.include_out_of_scope, input.include_ineligible),
|
|
5126
5355
|
meta: stripUndefined({
|
|
5127
|
-
|
|
5128
|
-
total_after_filters: targets.length,
|
|
5356
|
+
...fallbackTargetCoverage(exportLookup, targets.length),
|
|
5129
5357
|
target_list_mode: input.target_list_mode,
|
|
5130
5358
|
group_limit: input.group_limit,
|
|
5131
5359
|
target_source: exportLookup.source,
|
|
@@ -5161,8 +5389,7 @@ async function getProgramTargetBreakdown(client, config, input) {
|
|
|
5161
5389
|
groups: readObject(scope.groups)
|
|
5162
5390
|
},
|
|
5163
5391
|
meta: {
|
|
5164
|
-
|
|
5165
|
-
total_after_filters: targets.length,
|
|
5392
|
+
...targetPageCoverage(targetsData, rawTargets.length, targets.length),
|
|
5166
5393
|
target_list_mode: input.target_list_mode,
|
|
5167
5394
|
group_limit: input.group_limit,
|
|
5168
5395
|
target_source: "program_targets",
|
|
@@ -5170,6 +5397,32 @@ async function getProgramTargetBreakdown(client, config, input) {
|
|
|
5170
5397
|
}
|
|
5171
5398
|
});
|
|
5172
5399
|
}
|
|
5400
|
+
function targetPageCoverage(data, rows, filteredRows) {
|
|
5401
|
+
const meta = readObject(data?.meta);
|
|
5402
|
+
const total = numberField(meta, "total");
|
|
5403
|
+
const incomplete = meta?.has_more === true || (numberField(meta, "total_pages") ?? 1) > 1
|
|
5404
|
+
|| (total !== undefined && total > rows);
|
|
5405
|
+
return stripUndefined({
|
|
5406
|
+
total_active_targets: total ?? (incomplete ? undefined : rows),
|
|
5407
|
+
total_after_filters: incomplete ? undefined : filteredRows,
|
|
5408
|
+
targets_scanned: rows,
|
|
5409
|
+
matching_targets_scanned: filteredRows,
|
|
5410
|
+
possibly_incomplete: incomplete,
|
|
5411
|
+
count_scope: incomplete ? "sampled_target_page" : "complete_target_inventory"
|
|
5412
|
+
});
|
|
5413
|
+
}
|
|
5414
|
+
function fallbackTargetCoverage(lookup, filteredRows) {
|
|
5415
|
+
const incomplete = lookup.unavailable || lookup.coverage?.possibly_incomplete === true;
|
|
5416
|
+
return stripUndefined({
|
|
5417
|
+
...lookup.coverage,
|
|
5418
|
+
total_active_targets: lookup.unavailable ? undefined : lookup.coverage?.total_active_targets ?? (incomplete ? undefined : lookup.rawTargets.length),
|
|
5419
|
+
total_after_filters: incomplete ? undefined : filteredRows,
|
|
5420
|
+
targets_scanned: lookup.rawTargets.length,
|
|
5421
|
+
matching_targets_scanned: filteredRows,
|
|
5422
|
+
possibly_incomplete: incomplete,
|
|
5423
|
+
count_scope: lookup.unavailable ? "unavailable" : incomplete ? "sampled_target_page" : "complete_target_inventory"
|
|
5424
|
+
});
|
|
5425
|
+
}
|
|
5173
5426
|
async function getProgramTargetBreakdownFromIndexFallback(client, indexFallback, input) {
|
|
5174
5427
|
const exportLookup = await fetchProgramTargetsWithEndpointFallback(client, input.program_id);
|
|
5175
5428
|
const targets = exportLookup.rawTargets.filter((target) => targetHasAllowedScope(target, {
|
|
@@ -5196,8 +5449,7 @@ async function getProgramTargetBreakdownFromIndexFallback(client, indexFallback,
|
|
|
5196
5449
|
groups: readObject(scope.groups)
|
|
5197
5450
|
},
|
|
5198
5451
|
meta: stripUndefined({
|
|
5199
|
-
|
|
5200
|
-
total_after_filters: targets.length,
|
|
5452
|
+
...fallbackTargetCoverage(exportLookup, targets.length),
|
|
5201
5453
|
target_list_mode: input.target_list_mode,
|
|
5202
5454
|
group_limit: input.group_limit,
|
|
5203
5455
|
target_source: exportLookup.source,
|
|
@@ -6040,6 +6292,9 @@ function compactProgram(program) {
|
|
|
6040
6292
|
competition_risk: stringField(program, "competition_risk"),
|
|
6041
6293
|
competition_risk_reason: stringField(program, "competition_risk_reason"),
|
|
6042
6294
|
submission_cost: program.submission_cost,
|
|
6295
|
+
payout_summary: program.payout_summary,
|
|
6296
|
+
activity: program.activity,
|
|
6297
|
+
activity_summary: program.activity_summary,
|
|
6043
6298
|
reward_range: program.reward_range,
|
|
6044
6299
|
reward_range_label: stringField(program, "reward_range_label"),
|
|
6045
6300
|
public_report_count: program.public_report_count,
|
|
@@ -6598,7 +6853,22 @@ function clampLimit(name, value, max, warnings) {
|
|
|
6598
6853
|
warnings.push(`${name} was clamped from ${value} to ${max}.`);
|
|
6599
6854
|
return max;
|
|
6600
6855
|
}
|
|
6601
|
-
|
|
6856
|
+
// Candidate pool for hunts narrowed by Activity on the server. Matches arrive in
|
|
6857
|
+
// server order, so a larger pool lets ranking_mode choose among more of them.
|
|
6858
|
+
const HUNT_SERVER_POOL = 100;
|
|
6859
|
+
async function findHuntTargets(client, config, input, activity = {}) {
|
|
6860
|
+
const huntStartedAt = Date.now();
|
|
6861
|
+
const phaseTiming = {};
|
|
6862
|
+
const runStage = async (phase, timeoutMs, callback) => {
|
|
6863
|
+
const startedAt = Date.now();
|
|
6864
|
+
try {
|
|
6865
|
+
return await client.withRequestTimeout(timeoutMs, callback);
|
|
6866
|
+
}
|
|
6867
|
+
finally {
|
|
6868
|
+
phaseTiming[phase] = Date.now() - startedAt;
|
|
6869
|
+
}
|
|
6870
|
+
};
|
|
6871
|
+
validateActivityCatalogInput(activity);
|
|
6602
6872
|
const warnings = [];
|
|
6603
6873
|
const errors = [];
|
|
6604
6874
|
const selfHostedOnly = isSelfHostedOnlyPlatformSelection(input.platforms);
|
|
@@ -6641,6 +6911,7 @@ async function findHuntTargets(client, config, input) {
|
|
|
6641
6911
|
min_total_targets: input.min_total_targets,
|
|
6642
6912
|
min_added_24h: undefined,
|
|
6643
6913
|
min_added_7d: input.min_added_7d,
|
|
6914
|
+
free_submission_only: input.free_submission_only,
|
|
6644
6915
|
max_pages: input.max_program_pages,
|
|
6645
6916
|
max_results: candidatePoolSize,
|
|
6646
6917
|
sort_by: huntProgramSort(input.ranking_mode),
|
|
@@ -6662,14 +6933,29 @@ async function findHuntTargets(client, config, input) {
|
|
|
6662
6933
|
else if (recentChangeQueries.length < desiredRecentChangeQueries.length) {
|
|
6663
6934
|
warnings.push(`Recent target-change queries were reduced from ${desiredRecentChangeQueries.length} to ${recentChangeQueries.length} to preserve the upstream request budget.`);
|
|
6664
6935
|
}
|
|
6936
|
+
const serverFiltered = hasActivityCatalogInput(activity);
|
|
6937
|
+
// Catalog discovery and optional changes share at most 60% of the tool
|
|
6938
|
+
// deadline. Keep at least 30% for exact targets and 10% for the response.
|
|
6665
6939
|
const [programPayload, recentResult] = await Promise.all([
|
|
6666
|
-
|
|
6940
|
+
runStage("program_discovery_ms", Math.floor(config.requestTimeoutMs * 0.6), () => serverFiltered
|
|
6941
|
+
? findFilteredServerPrograms(client, config, {
|
|
6942
|
+
...programInput, ...activity, opportunity_levels: [], sort_by: activity.sort_by, sort_direction: activity.sort_direction,
|
|
6943
|
+
platforms: input.platforms, tags: input.tags, updated_since: input.updated_since, vdp_mode: "exclude", include_target_samples: false
|
|
6944
|
+
}, {
|
|
6945
|
+
...programInput, platforms: [], tags: [], vdp_mode: "include", updated_since: undefined,
|
|
6946
|
+
max_pages: programRequestBudget, upstream_request_budget: programRequestBudget
|
|
6947
|
+
}, HUNT_SERVER_POOL, true)
|
|
6948
|
+
: findPrograms(client, config, programInput, {
|
|
6949
|
+
allowPartialTimeout: true, excludeVdpUpstream: true
|
|
6950
|
+
})),
|
|
6667
6951
|
recentChangesEnabled
|
|
6668
|
-
? fetchHuntRecentChanges(client, config, input, recentChangeQueries)
|
|
6952
|
+
? runStage("recent_changes_ms", Math.floor(config.requestTimeoutMs * 0.3), () => fetchHuntRecentChanges(client, config, input, recentChangeQueries))
|
|
6669
6953
|
: Promise.resolve({ changes: [], sourceRequests: [], requestsUsed: 0, errors: [] })
|
|
6670
6954
|
]);
|
|
6955
|
+
client.throwIfRequestAborted();
|
|
6671
6956
|
const programWarnings = readArray(programPayload.warnings).filter((warning) => typeof warning === "string");
|
|
6672
6957
|
warnings.push(...programWarnings);
|
|
6958
|
+
errors.push(...readArray(programPayload.errors).map(readObject).filter((error) => error !== undefined));
|
|
6673
6959
|
errors.push(...recentResult.errors);
|
|
6674
6960
|
const programBudget = readObject(programPayload.upstream_budget);
|
|
6675
6961
|
const programRequestsUsed = readNumber(programBudget?.used) ?? 0;
|
|
@@ -6688,8 +6974,12 @@ async function findHuntTargets(client, config, input) {
|
|
|
6688
6974
|
const finalistEntries = preliminaryRankedEntries
|
|
6689
6975
|
.slice(0, sampledProgramCount)
|
|
6690
6976
|
.map((entry) => entry.entry);
|
|
6691
|
-
const targetResult = await collectHuntTargetSamples(client, finalistEntries, input);
|
|
6977
|
+
const targetResult = await runStage("target_sampling_ms", Math.floor(config.requestTimeoutMs * 0.9) - (Date.now() - huntStartedAt), () => collectHuntTargetSamples(client, finalistEntries, input));
|
|
6978
|
+
client.throwIfRequestAborted();
|
|
6692
6979
|
errors.push(...targetResult.errors);
|
|
6980
|
+
if (errors.length > 0) {
|
|
6981
|
+
warnings.push("Some discovery or enrichment reads failed or reached their time budget. Returned matches preserve the requested filters; missing target samples or change evidence are unavailable, not confirmed absent. Report the incomplete coverage without narrowing platforms or relaxing thresholds.");
|
|
6982
|
+
}
|
|
6693
6983
|
if (sampledProgramCount < Math.min(input.max_results, programEntries.length)) {
|
|
6694
6984
|
warnings.push(`The upstream request budget limited the final exact target shortlist to ${sampledProgramCount} of ${Math.min(input.max_results, programEntries.length)} requested results.`);
|
|
6695
6985
|
}
|
|
@@ -6703,6 +6993,10 @@ async function findHuntTargets(client, config, input) {
|
|
|
6703
6993
|
}));
|
|
6704
6994
|
const targetRequestsUsed = targetResult.requestsUsed;
|
|
6705
6995
|
const totalRequestsUsed = programRequestsUsed + recentResult.requestsUsed + targetRequestsUsed;
|
|
6996
|
+
const coverageIncomplete = errors.length > 0
|
|
6997
|
+
|| readObject(programPayload.ranking_scope)?.possibly_incomplete === true
|
|
6998
|
+
|| readObject(programPayload.scan)?.complete === false
|
|
6999
|
+
|| sampledProgramCount < Math.min(input.max_results, programEntries.length);
|
|
6706
7000
|
return stripUndefined({
|
|
6707
7001
|
request_id: randomUUID(),
|
|
6708
7002
|
fetched_at: new Date().toISOString(),
|
|
@@ -6733,6 +7027,8 @@ async function findHuntTargets(client, config, input) {
|
|
|
6733
7027
|
min_target_score: input.min_target_score,
|
|
6734
7028
|
target_opportunity_label: input.target_opportunity_label,
|
|
6735
7029
|
competition_risk: input.competition_risk,
|
|
7030
|
+
free_submission_only: input.free_submission_only || undefined,
|
|
7031
|
+
activity: serverFiltered ? stripUndefined(activity) : undefined,
|
|
6736
7032
|
recent_target_days: input.include_recent_changes ? input.recent_target_days : undefined,
|
|
6737
7033
|
ranking_mode: input.ranking_mode
|
|
6738
7034
|
}),
|
|
@@ -6743,14 +7039,19 @@ async function findHuntTargets(client, config, input) {
|
|
|
6743
7039
|
},
|
|
6744
7040
|
ranking_scope: stripUndefined({
|
|
6745
7041
|
...(readObject(programPayload.ranking_scope) ?? {}),
|
|
7042
|
+
local_ranking: serverFiltered ? input.ranking_mode : undefined,
|
|
6746
7043
|
single_program_catalog_pass: true,
|
|
6747
7044
|
recent_change_window_days: input.include_recent_changes ? input.recent_target_days : undefined,
|
|
6748
7045
|
recent_change_page_size: input.include_recent_changes ? input.recent_changes_page_size : undefined
|
|
6749
7046
|
}),
|
|
7047
|
+
scan: readObject(programPayload.scan),
|
|
6750
7048
|
result_guidance: {
|
|
6751
7049
|
final_shortlist: true,
|
|
6752
7050
|
follow_up_calls_required: false,
|
|
6753
|
-
|
|
7051
|
+
coverage_incomplete: coverageIncomplete,
|
|
7052
|
+
instruction: coverageIncomplete
|
|
7053
|
+
? "Use the bounded matches and report warnings and incomplete coverage. Preserve the user's periods, thresholds and platform scope; do not infer supported platforms from a sampled recent-change feed."
|
|
7054
|
+
: "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."
|
|
6754
7055
|
},
|
|
6755
7056
|
matches,
|
|
6756
7057
|
meta: {
|
|
@@ -6763,7 +7064,8 @@ async function findHuntTargets(client, config, input) {
|
|
|
6763
7064
|
model_round_trips_expected: 1,
|
|
6764
7065
|
parallel_recent_change_fetch: recentChangeQueries.length > 0,
|
|
6765
7066
|
bulk_resource_links_suppressed: true,
|
|
6766
|
-
partial_success: errors.length > 0
|
|
7067
|
+
partial_success: errors.length > 0,
|
|
7068
|
+
phase_timing: phaseTiming
|
|
6767
7069
|
}
|
|
6768
7070
|
});
|
|
6769
7071
|
}
|
|
@@ -7459,10 +7761,288 @@ function uniqueProgramsFromRepositoryMatches(matches) {
|
|
|
7459
7761
|
}
|
|
7460
7762
|
return [...programs.values()];
|
|
7461
7763
|
}
|
|
7462
|
-
|
|
7764
|
+
/** Filters the MCP applies to program records after retrieval. */
|
|
7765
|
+
function localCatalogFilters(input) {
|
|
7766
|
+
const keys = ["exclude_platforms", "scope_tags", "target_types", "language_tags", "web3", "exclude_web3",
|
|
7767
|
+
"min_bounty_min", "min_bounty_max", "max_bounty_max", "require_bounty", "max_public_report_count",
|
|
7768
|
+
"require_known_report_count", "include_unknown_report_count", "exclude_ctf", "exclude_mobile_only",
|
|
7769
|
+
"exclude_non_web", "has_api_scope", "fresh_launch_days", "min_eligible_targets", "min_total_targets",
|
|
7770
|
+
"min_added_24h", "min_added_7d", "free_submission_only", "competition_risks"].filter(key => {
|
|
7771
|
+
const value = input[key];
|
|
7772
|
+
return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== false;
|
|
7773
|
+
});
|
|
7774
|
+
if (Number(input.min_wildcards) > 0)
|
|
7775
|
+
keys.push("min_wildcards");
|
|
7776
|
+
return keys;
|
|
7777
|
+
}
|
|
7778
|
+
function legacyCatalogFilters(input) {
|
|
7779
|
+
return input.include_target_samples === true ? [...localCatalogFilters(input), "include_target_samples"] : localCatalogFilters(input);
|
|
7780
|
+
}
|
|
7781
|
+
function supportsCatalogFilters(client, endpoint) {
|
|
7782
|
+
return client.capabilitySnapshot().status === "negotiated"
|
|
7783
|
+
&& readObject(client.endpointCapability(endpoint)?.catalog_filters)?.enabled === true;
|
|
7784
|
+
}
|
|
7785
|
+
function assertCatalogQuerySupported(client, endpoint, input) {
|
|
7786
|
+
if (!["include", "exclude", "only"].includes(String(input.vdp_mode ?? "exclude"))) {
|
|
7787
|
+
throw new Error("Server-ranked queries accept vdp_mode include, exclude or only. The heuristic only_likely and only_known_no_bounty modes are available only in legacy sampled discovery.");
|
|
7788
|
+
}
|
|
7789
|
+
const capability = client.endpointCapability(endpoint);
|
|
7790
|
+
if (!supportsCatalogFilters(client, endpoint)) {
|
|
7791
|
+
throw new Error("This API deployment does not advertise the required catalog filters. Upgrade the API, or use legacy discovery without server paging or Activity filters.");
|
|
7792
|
+
}
|
|
7793
|
+
if (requiresActivityQuery(input) && readObject(capability?.activity_filters)?.enabled !== true) {
|
|
7794
|
+
throw new Error("Activity catalog filters are unavailable on this API deployment. Ordinary program discovery remains available.");
|
|
7795
|
+
}
|
|
7796
|
+
const kind = input.program_kind ?? (input.contest_like ? "contest" : undefined);
|
|
7797
|
+
if (kind && !readArray(capability?.program_kind_filter).includes(kind)) {
|
|
7798
|
+
throw new Error(`This API deployment does not support program_kind=${String(kind)} on ${endpoint}; the filter was not sent or discarded.`);
|
|
7799
|
+
}
|
|
7800
|
+
}
|
|
7801
|
+
function hasActivityCatalogInput(input) {
|
|
7802
|
+
return Object.keys(activityCatalogInput).some((key) => key === "sort_by"
|
|
7803
|
+
? activityCatalogSortSchema.safeParse(input[key]).success
|
|
7804
|
+
: input[key] !== undefined);
|
|
7805
|
+
}
|
|
7806
|
+
/** Validates and builds one server-ordered catalog query; nothing is sent. */
|
|
7807
|
+
function serverCatalogQuery(client, input) {
|
|
7808
|
+
validateActivityCatalogInput(input);
|
|
7809
|
+
const levels = Array.isArray(input.opportunity_levels) ? input.opportunity_levels : [];
|
|
7810
|
+
if (levels.length > 1)
|
|
7811
|
+
throw new Error("Combined Activity queries accept at most one opportunity tier; separate tier pages cannot form a global ranking.");
|
|
7812
|
+
if (input.sort_by !== undefined && !activityCatalogSortSchema.safeParse(input.sort_by).success) {
|
|
7813
|
+
throw new Error("Combined Activity queries require an enabled server sort_by. Omit it for updated descending; legacy sampled sorts cannot rank these results.");
|
|
7814
|
+
}
|
|
7815
|
+
if (input.contest_like && input.program_kind && input.program_kind !== "contest")
|
|
7816
|
+
throw new Error("contest_like conflicts with program_kind.");
|
|
7817
|
+
const endpoint = levels.length ? "opportunities" : "programs";
|
|
7818
|
+
assertCatalogQuerySupported(client, endpoint, input);
|
|
7819
|
+
assertStableSnapshotSupported(client, endpoint, typeof input.snapshot === "string" ? input.snapshot : undefined);
|
|
7820
|
+
const signals = Object.fromEntries(Object.keys(activityCatalogInput).filter((key) => input[key] !== undefined).map((key) => [key, input[key]]));
|
|
7821
|
+
return {
|
|
7822
|
+
endpoint, level: levels[0], maxPageSize: client.endpointNumber(endpoint, "max_page_size", MAX_PROGRAMS_PER_SEARCH),
|
|
7823
|
+
query: { ...signals, search: input.search, platforms: input.platforms, tags: input.tags,
|
|
7824
|
+
program_kind: input.program_kind ?? (input.contest_like ? "contest" : undefined), updated_since: input.updated_since,
|
|
7825
|
+
vdp_mode: input.vdp_mode, snapshot: input.snapshot }
|
|
7826
|
+
};
|
|
7827
|
+
}
|
|
7828
|
+
function readServerCatalogPage(client, catalog, page, pageSize, snapshot) {
|
|
7829
|
+
const query = toQuery({ ...catalog.query, snapshot, page, page_size: pageSize });
|
|
7830
|
+
return catalog.level ? client.getOpportunities(catalog.level, query) : client.listPrograms(query);
|
|
7831
|
+
}
|
|
7832
|
+
async function findServerCatalogPrograms(client, config, input) {
|
|
7833
|
+
// One exact server page: page and snapshot continue it. Local filters use the
|
|
7834
|
+
// ordered scan below instead.
|
|
7835
|
+
const catalog = serverCatalogQuery(client, input);
|
|
7836
|
+
const pageSize = Number(input.max_results) || 10;
|
|
7837
|
+
if (pageSize > catalog.maxPageSize)
|
|
7838
|
+
throw new Error(`Combined Activity pages support max_results up to ${catalog.maxPageSize}; use page and the returned stable snapshot to continue.`);
|
|
7839
|
+
const api = await readServerCatalogPage(client, catalog, Number(input.page ?? 1), pageSize, input.snapshot);
|
|
7840
|
+
const data = readObject(api.data);
|
|
7841
|
+
const mode = input.output_mode === "full" ? "full" : "compact";
|
|
7842
|
+
return withApiMetadata(api, {
|
|
7843
|
+
programs: readArray(data?.programs).map((program) => formatProgram(sanitizeProgram(program, config.webBaseUrl), mode)),
|
|
7844
|
+
ranking_scope: { mode: "server", sort_by: input.sort_by ?? "updated", sort_direction: input.sort_direction ?? "desc" },
|
|
7845
|
+
meta: sanitizeJson(data?.meta)
|
|
7846
|
+
});
|
|
7847
|
+
}
|
|
7848
|
+
// Pages requested together while scanning. Activity catalog reads share the
|
|
7849
|
+
// two-request changes pool, so a wider window would only queue.
|
|
7850
|
+
const CATALOG_SCAN_PREFETCH = 2;
|
|
7851
|
+
function encodeScanCursor(cursor) {
|
|
7852
|
+
return Buffer.from(JSON.stringify(cursor)).toString("base64url");
|
|
7853
|
+
}
|
|
7854
|
+
function decodeScanCursor(value, fingerprint, pageSize) {
|
|
7855
|
+
let cursor;
|
|
7856
|
+
try {
|
|
7857
|
+
cursor = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
7858
|
+
}
|
|
7859
|
+
catch {
|
|
7860
|
+
cursor = undefined;
|
|
7861
|
+
}
|
|
7862
|
+
if (!cursor || cursor.v !== 1 || !Number.isInteger(cursor.p) || cursor.p < 1 || !Number.isInteger(cursor.k) || cursor.k < 0) {
|
|
7863
|
+
throw new Error("Invalid scan cursor. Use scan.next_cursor from the previous find_programs result.");
|
|
7864
|
+
}
|
|
7865
|
+
if (cursor.f !== fingerprint || cursor.s !== pageSize) {
|
|
7866
|
+
throw new Error("This scan cursor belongs to different filters. Repeat the previous filters exactly, or start again without cursor.");
|
|
7867
|
+
}
|
|
7868
|
+
return cursor;
|
|
7869
|
+
}
|
|
7870
|
+
/**
|
|
7871
|
+
* Reads server-ordered pages and keeps the programs that also pass the local
|
|
7872
|
+
* filters, until enough match or the page/request budget is spent. Filtering
|
|
7873
|
+
* never reorders, so the result is a prefix of the server's global order.
|
|
7874
|
+
*/
|
|
7875
|
+
async function scanServerCatalog(client, config, input, local, limits) {
|
|
7876
|
+
const catalog = serverCatalogQuery(client, input);
|
|
7877
|
+
const pageSize = catalog.maxPageSize;
|
|
7878
|
+
const localFilters = localCatalogFilters(input);
|
|
7879
|
+
const fingerprint = createHash("sha256")
|
|
7880
|
+
.update(JSON.stringify([catalog.endpoint, catalog.level, toQuery({ ...catalog.query, snapshot: undefined }), localFilters.map((key) => [key, input[key]])]))
|
|
7881
|
+
.digest("base64url").slice(0, 16);
|
|
7882
|
+
const cursorValue = typeof input.cursor === "string" ? input.cursor : undefined;
|
|
7883
|
+
if (cursorValue && input.page !== undefined)
|
|
7884
|
+
throw new Error("Use either cursor or page, not both.");
|
|
7885
|
+
const start = cursorValue ? decodeScanCursor(cursorValue, fingerprint, pageSize)
|
|
7886
|
+
: { v: 1, p: Number(input.page ?? 1), k: 0, s: pageSize, f: fingerprint, a: undefined, t: undefined };
|
|
7887
|
+
const sourceRequests = [];
|
|
7888
|
+
const errors = [];
|
|
7889
|
+
let matches = [];
|
|
7890
|
+
let seen = new Set();
|
|
7891
|
+
let nextPage = start.p;
|
|
7892
|
+
let skip = start.k;
|
|
7893
|
+
let version = start.a;
|
|
7894
|
+
let token = start.t ?? input.snapshot;
|
|
7895
|
+
let totalPages;
|
|
7896
|
+
let serverTotal;
|
|
7897
|
+
let rowsScanned = 0;
|
|
7898
|
+
let pagesRead = 0;
|
|
7899
|
+
let restarted = false;
|
|
7900
|
+
let exhausted = false;
|
|
7901
|
+
let resume;
|
|
7902
|
+
scan: while (matches.length < limits.maxResults) {
|
|
7903
|
+
const pagesLeft = totalPages === undefined ? Number.POSITIVE_INFINITY : totalPages - nextPage + 1;
|
|
7904
|
+
const allowance = Math.min(limits.maxPages - pagesRead, limits.budget.remaining, pagesLeft);
|
|
7905
|
+
if (allowance <= 0)
|
|
7906
|
+
break;
|
|
7907
|
+
// Speculate only from an observed match rate, never before the first page.
|
|
7908
|
+
const rate = rowsScanned > 0 ? Math.max(matches.length / rowsScanned, 0.05) : 1;
|
|
7909
|
+
const needed = pagesRead === 0 ? 1 : Math.ceil((limits.maxResults - matches.length) / (rate * pageSize));
|
|
7910
|
+
const batch = Math.max(1, Math.min(allowance, needed, CATALOG_SCAN_PREFETCH));
|
|
7911
|
+
const pageToken = token;
|
|
7912
|
+
const batchStart = nextPage;
|
|
7913
|
+
const pages = await Promise.allSettled(Array.from({ length: batch }, (_, index) => batchStart + index)
|
|
7914
|
+
.map(async (page) => ({ page, api: await readServerCatalogPage(client, catalog, page, pageSize, pageToken) })));
|
|
7915
|
+
// Count all admitted reads, including a failed page or a prefetched page
|
|
7916
|
+
// beyond a gap; only the contiguous successful prefix enters the ranking.
|
|
7917
|
+
for (const result of pages) {
|
|
7918
|
+
const noRequest = result.status === "fulfilled" ? result.value.api.cached || result.value.api.coalesced
|
|
7919
|
+
: result.reason instanceof BBRadarApiError && (result.reason.cached || result.reason.coalesced);
|
|
7920
|
+
limits.budget.remaining -= noRequest ? 0 : 1;
|
|
7921
|
+
}
|
|
7922
|
+
for (const [index, result] of pages.entries()) {
|
|
7923
|
+
if (result.status === "rejected") {
|
|
7924
|
+
if (!limits.allowPartialTimeout || pagesRead === 0 || !(result.reason instanceof BBRadarApiError) || result.reason.status !== 408)
|
|
7925
|
+
throw result.reason;
|
|
7926
|
+
errors.push(apiOperationError(`program catalog page ${batchStart + index}`, result.reason));
|
|
7927
|
+
break scan;
|
|
7928
|
+
}
|
|
7929
|
+
const { page, api } = result.value;
|
|
7930
|
+
pagesRead += 1;
|
|
7931
|
+
sourceRequests.push({ source: catalog.level ? `opportunities:${catalog.level}` : "programs", page, request_id: api.requestId,
|
|
7932
|
+
upstream_request_id: api.upstreamRequestId, ...apiSourceMetadata(api) });
|
|
7933
|
+
const data = readObject(api.data);
|
|
7934
|
+
const meta = readObject(data?.meta);
|
|
7935
|
+
const pageVersion = stringField(meta ?? {}, "activity_snapshot_id");
|
|
7936
|
+
if (version && pageVersion && pageVersion !== version) {
|
|
7937
|
+
// Never mix rows from two Activity versions. Within one call, restart
|
|
7938
|
+
// once on the new version; a continued scan must be restarted by the caller.
|
|
7939
|
+
if (cursorValue || restarted) {
|
|
7940
|
+
throw new Error("The Activity version changed while scanning. Rerun the same query without cursor.");
|
|
7941
|
+
}
|
|
7942
|
+
restarted = true;
|
|
7943
|
+
matches = [];
|
|
7944
|
+
seen = new Set();
|
|
7945
|
+
rowsScanned = 0;
|
|
7946
|
+
nextPage = start.p;
|
|
7947
|
+
skip = start.k;
|
|
7948
|
+
totalPages = undefined;
|
|
7949
|
+
version = pageVersion;
|
|
7950
|
+
continue scan;
|
|
7951
|
+
}
|
|
7952
|
+
version ??= pageVersion;
|
|
7953
|
+
totalPages = readNumber(meta?.total_pages) ?? totalPages;
|
|
7954
|
+
serverTotal = readNumber(meta?.total) ?? serverTotal;
|
|
7955
|
+
if (token !== undefined && typeof meta?.snapshot === "string")
|
|
7956
|
+
token = meta.snapshot;
|
|
7957
|
+
const rows = readArray(data?.programs);
|
|
7958
|
+
const lastPage = rows.length < pageSize || (totalPages !== undefined && page >= totalPages);
|
|
7959
|
+
for (let index = skip; index < rows.length; index += 1) {
|
|
7960
|
+
rowsScanned += 1;
|
|
7961
|
+
const candidate = toProgramCandidate(rows[index], config.webBaseUrl);
|
|
7962
|
+
const id = stringField(candidate.program, "id") ?? `${page}:${index}`;
|
|
7963
|
+
if (seen.has(id) || !programCandidateMatches(candidate, local))
|
|
7964
|
+
continue;
|
|
7965
|
+
seen.add(id);
|
|
7966
|
+
matches.push(candidate);
|
|
7967
|
+
if (matches.length >= limits.maxResults) {
|
|
7968
|
+
resume = index + 1 < rows.length ? { p: page, k: index + 1 } : lastPage ? undefined : { p: page + 1, k: 0 };
|
|
7969
|
+
exhausted = resume === undefined;
|
|
7970
|
+
break scan;
|
|
7971
|
+
}
|
|
7972
|
+
}
|
|
7973
|
+
skip = 0;
|
|
7974
|
+
if (lastPage) {
|
|
7975
|
+
exhausted = true;
|
|
7976
|
+
break scan;
|
|
7977
|
+
}
|
|
7978
|
+
nextPage = page + 1;
|
|
7979
|
+
}
|
|
7980
|
+
}
|
|
7981
|
+
if (!exhausted && !resume)
|
|
7982
|
+
resume = { p: nextPage, k: skip };
|
|
7983
|
+
return {
|
|
7984
|
+
candidates: matches,
|
|
7985
|
+
sourceRequests,
|
|
7986
|
+
errors,
|
|
7987
|
+
scan: stripUndefined({
|
|
7988
|
+
order: "Server order; local filters only remove programs.",
|
|
7989
|
+
sort_by: input.sort_by ?? "updated",
|
|
7990
|
+
sort_direction: input.sort_direction ?? "desc",
|
|
7991
|
+
local_filters: localFilters.length > 0 ? localFilters : undefined,
|
|
7992
|
+
server_matches: serverTotal,
|
|
7993
|
+
rows_scanned: rowsScanned,
|
|
7994
|
+
pages_read: pagesRead,
|
|
7995
|
+
complete: exhausted,
|
|
7996
|
+
next_cursor: resume
|
|
7997
|
+
? encodeScanCursor({ v: 1, p: resume.p, k: resume.k, s: pageSize, f: fingerprint, a: version, t: typeof token === "string" ? token : undefined })
|
|
7998
|
+
: undefined,
|
|
7999
|
+
activity_snapshot_id: version,
|
|
8000
|
+
restarted_on_new_activity_version: restarted || undefined,
|
|
8001
|
+
deadline_reached: errors.length > 0 || undefined
|
|
8002
|
+
})
|
|
8003
|
+
};
|
|
8004
|
+
}
|
|
8005
|
+
/** find_programs with server Activity filters plus local filters or target samples. */
|
|
8006
|
+
async function findFilteredServerPrograms(client, config, input, local, maxResults = local.max_results, allowPartialTimeout = false) {
|
|
8007
|
+
const normalized = normalizeFindProgramsInput(local);
|
|
8008
|
+
const warnings = normalized.warnings;
|
|
8009
|
+
local = normalized.input;
|
|
8010
|
+
const budget = { initial: local.upstream_request_budget, remaining: local.upstream_request_budget };
|
|
8011
|
+
const result = await scanServerCatalog(client, config, input, local, { maxResults, maxPages: local.max_pages, budget, allowPartialTimeout });
|
|
8012
|
+
if (result.errors.length > 0)
|
|
8013
|
+
warnings.push("Program discovery reached its time budget; ranking covers only the completed server-ordered prefix.");
|
|
8014
|
+
if (result.scan.complete !== true) {
|
|
8015
|
+
warnings.push("More server pages remain; scan.next_cursor continues in the same order.");
|
|
8016
|
+
}
|
|
8017
|
+
const samplePrograms = Math.min(local.target_sample_programs, result.candidates.length, Math.max(0, budget.remaining));
|
|
8018
|
+
const targetSamples = local.include_target_samples && samplePrograms > 0
|
|
8019
|
+
? await collectTargetSamples(client, result.candidates, { ...local, target_sample_programs: samplePrograms }, budget)
|
|
8020
|
+
: {};
|
|
8021
|
+
const outputMode = local.output_mode ?? "compact";
|
|
8022
|
+
return stripUndefined({
|
|
8023
|
+
request_id: randomUUID(),
|
|
8024
|
+
fetched_at: new Date().toISOString(),
|
|
8025
|
+
source_requests: result.sourceRequests,
|
|
8026
|
+
errors: result.errors.length > 0 ? result.errors : undefined,
|
|
8027
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
8028
|
+
upstream_budget: { used: budget.initial - budget.remaining, remaining: budget.remaining },
|
|
8029
|
+
ranking_scope: { mode: "server_filtered", sort_by: result.scan.sort_by, sort_direction: result.scan.sort_direction, complete: result.scan.complete },
|
|
8030
|
+
scan: result.scan,
|
|
8031
|
+
programs: result.candidates.map((candidate, index) => candidateOutput(candidate, index, targetSamples, outputMode)),
|
|
8032
|
+
meta: { returned: result.candidates.length, server_matches: result.scan.server_matches }
|
|
8033
|
+
});
|
|
8034
|
+
}
|
|
8035
|
+
function programHasNoSubmissionFee(program) {
|
|
8036
|
+
// Mirrors the API: no detected cost, or a detected zero with no maximum.
|
|
8037
|
+
const cost = readObject(program.submission_cost);
|
|
8038
|
+
const amount = numberField(cost, "amount");
|
|
8039
|
+
return amount === undefined || (amount === 0 && (numberField(cost, "maximum_amount") ?? 0) === 0);
|
|
8040
|
+
}
|
|
8041
|
+
async function findPrograms(client, config, input, execution = {}) {
|
|
7463
8042
|
const normalized = normalizeFindProgramsInput(input);
|
|
7464
8043
|
const warnings = normalized.warnings;
|
|
7465
8044
|
input = normalized.input;
|
|
8045
|
+
const errors = [];
|
|
7466
8046
|
const budget = {
|
|
7467
8047
|
initial: input.upstream_request_budget,
|
|
7468
8048
|
remaining: input.upstream_request_budget
|
|
@@ -7475,14 +8055,17 @@ async function findPrograms(client, config, input) {
|
|
|
7475
8055
|
...(input.min_wildcards > 0 ? ["wildcard"] : [])
|
|
7476
8056
|
]);
|
|
7477
8057
|
const collected = await collectPrograms(client, {
|
|
8058
|
+
search: input.search,
|
|
7478
8059
|
platforms: input.platforms,
|
|
7479
8060
|
tags: upstreamTags,
|
|
7480
8061
|
program_kind: input.program_kind ?? (input.contest_like ? "contest" : undefined),
|
|
8062
|
+
vdp_mode: execution.excludeVdpUpstream && input.vdp_mode === "exclude" ? "exclude" : undefined,
|
|
7481
8063
|
updated_since: input.updated_since,
|
|
7482
8064
|
opportunity_levels: input.opportunity_levels,
|
|
7483
8065
|
max_pages: input.max_pages,
|
|
7484
8066
|
budget,
|
|
7485
|
-
warnings
|
|
8067
|
+
warnings,
|
|
8068
|
+
partialTimeoutErrors: execution.allowPartialTimeout ? errors : undefined
|
|
7486
8069
|
});
|
|
7487
8070
|
const filteredCandidates = collected.programs
|
|
7488
8071
|
.map((program) => toProgramCandidate(program, config.webBaseUrl))
|
|
@@ -7506,6 +8089,7 @@ async function findPrograms(client, config, input) {
|
|
|
7506
8089
|
source_requests: collected.sourceRequests,
|
|
7507
8090
|
fetched_at: new Date().toISOString(),
|
|
7508
8091
|
warnings: warnings.length > 0 ? warnings : undefined,
|
|
8092
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
7509
8093
|
filters: stripUndefined({
|
|
7510
8094
|
platforms: input.platforms.length > 0 ? input.platforms : undefined,
|
|
7511
8095
|
exclude_platforms: input.exclude_platforms && input.exclude_platforms.length > 0 ? input.exclude_platforms : undefined,
|
|
@@ -7534,6 +8118,8 @@ async function findPrograms(client, config, input) {
|
|
|
7534
8118
|
min_total_targets: input.min_total_targets,
|
|
7535
8119
|
min_added_24h: input.min_added_24h,
|
|
7536
8120
|
min_added_7d: input.min_added_7d,
|
|
8121
|
+
free_submission_only: input.free_submission_only || undefined,
|
|
8122
|
+
competition_risks: input.competition_risks?.length ? input.competition_risks : undefined,
|
|
7537
8123
|
sort_by: input.sort_by,
|
|
7538
8124
|
output_mode: outputMode
|
|
7539
8125
|
}),
|
|
@@ -7546,7 +8132,7 @@ async function findPrograms(client, config, input) {
|
|
|
7546
8132
|
platform_filter: input.platforms.length > 0 ? input.platforms : "all_platforms",
|
|
7547
8133
|
max_pages_scanned_per_source: input.max_pages,
|
|
7548
8134
|
upstream_total_pages: collected.totalPagesBySource,
|
|
7549
|
-
possibly_incomplete: isRankingPossiblyIncomplete(collected.totalPagesBySource, input.max_pages)
|
|
8135
|
+
possibly_incomplete: errors.length > 0 || isRankingPossiblyIncomplete(collected.totalPagesBySource, input.max_pages)
|
|
7550
8136
|
},
|
|
7551
8137
|
programs,
|
|
7552
8138
|
meta: {
|
|
@@ -7951,20 +8537,39 @@ async function collectProgramSource(client, source, sourceIndex, options) {
|
|
|
7951
8537
|
async function fetchProgramPage(client, source, sourceIndex, page, options) {
|
|
7952
8538
|
const runWithSlot = options.runWithProgramPageSlot ?? runImmediately;
|
|
7953
8539
|
return runWithSlot(async () => {
|
|
8540
|
+
const endpoint = source.kind === "programs" ? "programs" : "opportunities";
|
|
8541
|
+
const vdpModes = readArray(readObject(client.endpointCapability(endpoint)?.catalog_filters)?.vdp_mode);
|
|
7954
8542
|
const query = toQuery({
|
|
7955
8543
|
search: options.search,
|
|
7956
8544
|
platforms: options.platforms,
|
|
7957
8545
|
tags: options.tags,
|
|
7958
8546
|
updated_since: options.updated_since,
|
|
7959
8547
|
program_kind: source.kind === "programs" ? options.program_kind : undefined,
|
|
8548
|
+
// This removes only known VDPs; unknown classifications with positive
|
|
8549
|
+
// rewards still reach the local filters. Older APIs keep local exclusion.
|
|
8550
|
+
vdp_mode: supportsCatalogFilters(client, endpoint) && vdpModes.includes("exclude") ? options.vdp_mode : undefined,
|
|
7960
8551
|
page,
|
|
7961
|
-
page_size: MAX_PROGRAMS_PER_SEARCH
|
|
8552
|
+
page_size: Math.min(MAX_PROGRAMS_PER_SEARCH, client.endpointNumber(endpoint, "max_page_size", MAX_PROGRAMS_PER_SEARCH))
|
|
7962
8553
|
});
|
|
7963
8554
|
if (!tryConsumeBudget(options.budget)) {
|
|
7964
8555
|
addUniqueWarning(options.warnings, "Upstream request budget was exhausted before all requested program pages could be fetched.");
|
|
7965
8556
|
return undefined;
|
|
7966
8557
|
}
|
|
7967
|
-
|
|
8558
|
+
let api;
|
|
8559
|
+
try {
|
|
8560
|
+
api = source.kind === "opportunity" ? await client.getOpportunities(source.level, query) : await client.listPrograms(query);
|
|
8561
|
+
}
|
|
8562
|
+
catch (error) {
|
|
8563
|
+
// Hunts can retain completed catalog pages. Ordinary discovery keeps its
|
|
8564
|
+
// existing failure contract, and a failed first page is never an empty hit.
|
|
8565
|
+
if (page === 1 || !options.partialTimeoutErrors || !(error instanceof BBRadarApiError) || error.status !== 408)
|
|
8566
|
+
throw error;
|
|
8567
|
+
if (error.cached || error.coalesced)
|
|
8568
|
+
options.budget.remaining += 1;
|
|
8569
|
+
options.partialTimeoutErrors.push(apiOperationError(`program catalog page ${page}`, error));
|
|
8570
|
+
addUniqueWarning(options.warnings, "Program discovery reached its time budget; ranking covers only completed catalog pages.");
|
|
8571
|
+
return undefined;
|
|
8572
|
+
}
|
|
7968
8573
|
refundBudgetIfNoUpstreamRequest(options.budget, api);
|
|
7969
8574
|
const data = readObject(api.data);
|
|
7970
8575
|
const meta = readObject(data?.meta);
|
|
@@ -8046,7 +8651,7 @@ function programCandidateMatches(candidate, filters) {
|
|
|
8046
8651
|
return false;
|
|
8047
8652
|
}
|
|
8048
8653
|
const programStatus = normalizeTag(stringField(candidate.program, "program_status") ?? "");
|
|
8049
|
-
if (readBoolean(candidate.program.is_active) === false || programStatus === "paused") {
|
|
8654
|
+
if (!filters.skip_status_check && (readBoolean(candidate.program.is_active) === false || programStatus === "paused")) {
|
|
8050
8655
|
return false;
|
|
8051
8656
|
}
|
|
8052
8657
|
if (filters.platforms.length > 0 && !filters.platforms.map(normalizeTag).includes(normalizeTag(stringField(candidate.program, "platform") ?? ""))) {
|
|
@@ -8139,6 +8744,15 @@ function programCandidateMatches(candidate, filters) {
|
|
|
8139
8744
|
if (filters.min_added_7d !== undefined && (candidate.added7d ?? 0) < filters.min_added_7d) {
|
|
8140
8745
|
return false;
|
|
8141
8746
|
}
|
|
8747
|
+
if (filters.free_submission_only && !programHasNoSubmissionFee(candidate.program)) {
|
|
8748
|
+
return false;
|
|
8749
|
+
}
|
|
8750
|
+
if ((filters.competition_risks?.length ?? 0) > 0) {
|
|
8751
|
+
const risk = normalizeTag(stringField(candidate.program, "competition_risk") ?? "unknown");
|
|
8752
|
+
if (!filters.competition_risks.some((value) => normalizeTag(value) === risk)) {
|
|
8753
|
+
return false;
|
|
8754
|
+
}
|
|
8755
|
+
}
|
|
8142
8756
|
return true;
|
|
8143
8757
|
}
|
|
8144
8758
|
function compareProgramCandidates(left, right, sortBy) {
|
|
@@ -8179,6 +8793,8 @@ function compareProgramCandidates(left, right, sortBy) {
|
|
|
8179
8793
|
right.lastUpdatedTime - left.lastUpdatedTime);
|
|
8180
8794
|
}
|
|
8181
8795
|
function candidateMatchesVdpMode(candidate, vdpMode) {
|
|
8796
|
+
if (vdpMode === "only")
|
|
8797
|
+
return stringField(candidate.program, "program_type") === "vdp";
|
|
8182
8798
|
if (vdpMode === "exclude") {
|
|
8183
8799
|
return !candidateLooksLikeVdp(candidate);
|
|
8184
8800
|
}
|
|
@@ -8775,6 +9391,7 @@ function compactToolText(payload, isError) {
|
|
|
8775
9391
|
}));
|
|
8776
9392
|
}
|
|
8777
9393
|
const meta = readObject(payload.meta);
|
|
9394
|
+
const coverageIncomplete = readObject(payload.result_guidance)?.coverage_incomplete === true;
|
|
8778
9395
|
const returned = numberField(meta, "returned") ??
|
|
8779
9396
|
[
|
|
8780
9397
|
readArray(payload.programs).length,
|
|
@@ -8786,6 +9403,12 @@ function compactToolText(payload, isError) {
|
|
|
8786
9403
|
request_id: payload.request_id,
|
|
8787
9404
|
summary: "Full tool result is available in structuredContent.",
|
|
8788
9405
|
returned,
|
|
9406
|
+
partial_success: meta?.partial_success === true ? true : undefined,
|
|
9407
|
+
coverage_incomplete: coverageIncomplete ? true : undefined,
|
|
9408
|
+
errors: meta?.partial_success === true ? readArray(payload.errors).map((error) => {
|
|
9409
|
+
const value = readObject(error);
|
|
9410
|
+
return stripUndefined({ operation: value?.operation, status: value?.status });
|
|
9411
|
+
}) : undefined,
|
|
8789
9412
|
mcp_timing: payload.mcp_timing
|
|
8790
9413
|
}));
|
|
8791
9414
|
}
|
|
@@ -8839,6 +9462,54 @@ function assertProFeature(client, endpoint, flag) {
|
|
|
8839
9462
|
throw new Error(`${endpoint} is advertised but ${flag}=false on this API deployment.`);
|
|
8840
9463
|
}
|
|
8841
9464
|
}
|
|
9465
|
+
function assertActivityTabInputs(tab, args) {
|
|
9466
|
+
const allowed = new Set([...ACTIVITY_SHARED_PARAMS, ...ACTIVITY_TAB_PARAMS[tab]]);
|
|
9467
|
+
const misplaced = Object.entries(args)
|
|
9468
|
+
.filter(([key, value]) => !allowed.has(key) && value !== undefined && value !== false)
|
|
9469
|
+
.map(([key]) => key);
|
|
9470
|
+
if (misplaced.length > 0) {
|
|
9471
|
+
throw new Error(`${misplaced.join(", ")} ${misplaced.length === 1 ? "does" : "do"} not apply to tab=${tab}. Remove ${misplaced.length === 1 ? "it" : "them"} rather than querying a different ranking.`);
|
|
9472
|
+
}
|
|
9473
|
+
if (typeof args.period === "string" && !ACTIVITY_COUNT_TABS.has(tab) && !ACTIVITY_EVENT_PERIODS.includes(args.period)) {
|
|
9474
|
+
throw new Error(`tab=${tab} uses rolling 7d, 30d or 90d periods.`);
|
|
9475
|
+
}
|
|
9476
|
+
}
|
|
9477
|
+
/** One ranking page without the capabilities document the API repeats on every page. */
|
|
9478
|
+
function activityRankingPage(api, includeMap) {
|
|
9479
|
+
const page = proPage(api, "rows");
|
|
9480
|
+
const capabilities = readObject(page.capabilities);
|
|
9481
|
+
delete page.capabilities;
|
|
9482
|
+
// With no enabled default view the API answers sort=null and no rows; keep
|
|
9483
|
+
// only this tab's views so the caller can choose an explicit one.
|
|
9484
|
+
if (page.sort === null && readArray(page.rows).length === 0 && capabilities) {
|
|
9485
|
+
const views = readArray(readArray(capabilities.tabs).map(readObject).find((tab) => tab?.id === page.tab)?.views);
|
|
9486
|
+
page.available_views = views;
|
|
9487
|
+
}
|
|
9488
|
+
return omitMapPoints(page, "points", includeMap);
|
|
9489
|
+
}
|
|
9490
|
+
function omitMapPoints(payload, field, include) {
|
|
9491
|
+
if (include || !Array.isArray(payload[field]))
|
|
9492
|
+
return payload;
|
|
9493
|
+
const { [field]: points, ...rest } = payload;
|
|
9494
|
+
return { ...rest, map_points_omitted: points.length };
|
|
9495
|
+
}
|
|
9496
|
+
function assertActivitySnapshotSelector(client, endpoint, snapshot) {
|
|
9497
|
+
// Older deployments ignore unknown parameters silently; never let a binding
|
|
9498
|
+
// the caller relies on be dropped without saying so.
|
|
9499
|
+
if (snapshot === undefined || client.capabilitySnapshot().status !== "negotiated")
|
|
9500
|
+
return;
|
|
9501
|
+
if (client.endpointCapability(endpoint)?.snapshot_parameter !== "snapshot") {
|
|
9502
|
+
throw new Error(`The negotiated BBRadar API does not advertise Activity version binding for ${endpoint}. Omit snapshot or upgrade the API deployment.`);
|
|
9503
|
+
}
|
|
9504
|
+
}
|
|
9505
|
+
function assertActivityTierFilter(client, tiers) {
|
|
9506
|
+
// An API without the filter answers 422; say why locally and send nothing.
|
|
9507
|
+
if (!Array.isArray(tiers) || tiers.length === 0 || client.capabilitySnapshot().status !== "negotiated")
|
|
9508
|
+
return;
|
|
9509
|
+
if (!Array.isArray(client.endpointCapability("activity_rankings")?.opportunity_tiers)) {
|
|
9510
|
+
throw new Error("The negotiated BBRadar API does not advertise Activity opportunity tier filtering. Omit opportunity_tiers or upgrade the API deployment.");
|
|
9511
|
+
}
|
|
9512
|
+
}
|
|
8842
9513
|
function assertHistoryCapture(client) {
|
|
8843
9514
|
if (client.capabilitySnapshot().status === "negotiated" &&
|
|
8844
9515
|
client.endpointCapability("history_changes")?.history_capture_enabled === false) {
|
|
@@ -8880,6 +9551,12 @@ function errorPayload(error, decision, toolName, durationMs) {
|
|
|
8880
9551
|
};
|
|
8881
9552
|
}
|
|
8882
9553
|
function recoveryForGone(error, toolName) {
|
|
9554
|
+
if (toolName === "get_activity_change_detail") {
|
|
9555
|
+
return { action: "refresh_activity_source", message: "Rerun the originating Activity changes or status query without its cursor, keeping its filters. Use the new meta.snapshot_id and event ID for this detail request; do not use snapshot=start." };
|
|
9556
|
+
}
|
|
9557
|
+
if (toolName.startsWith("get_activity_") || toolName === "get_program_payouts") {
|
|
9558
|
+
return { action: "restart_activity_query", message: "Restart this Activity query without its expired cursor or snapshot binding, keeping the same explicit period, basis, precision, sort, direction and filters; call get_activity_snapshot for the current version if binding is needed. Do not combine old pages or pass snapshot=start or after_id." };
|
|
9559
|
+
}
|
|
8883
9560
|
const detail = typeof error.detail === "string" ? error.detail.toLowerCase() : error.message.toLowerCase();
|
|
8884
9561
|
if (detail.includes("retained analytics") || detail.includes("precede")) {
|
|
8885
9562
|
return { action: "choose_supported_interval", message: "Choose dates inside the retained analytics window." };
|