webctx 0.2.7 → 0.2.9

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/AGENTS.md CHANGED
@@ -47,13 +47,14 @@ Direct commands:
47
47
  Preserve these commands unless the user explicitly asks to change them:
48
48
 
49
49
  - `webctx search <query> [--exclude domain1,domain2] [--keyword phrase]`
50
+ - `webctx status` and `webctx providers enable|disable <brave|tavily|exa>`
50
51
  - `webctx read-link <url>`
51
52
  - `webctx map-site <url>`
52
53
  - `webctx --version`
53
54
 
54
55
  Behavioral expectations:
55
56
 
56
- - `search` combines Brave, Tavily, and Exa results, then re-ranks them with duplicate-aware scoring.
57
+ - `search` combines enabled Brave, Tavily, and Exa results, then re-ranks them with duplicate-aware scoring; an unavailable Exa can fall back to quoted-phrase Brave/Tavily queries for `--keyword` (not a strict text filter).
57
58
  - `read-link` keeps the current GitHub raw-content and `.md` fast paths. Firecrawl uses its built-in 30-minute cache, automatic and enhanced proxies, then browser extraction as a final fallback.
58
59
  - `map-site` keeps the current Firecrawl map request settings.
59
60
  - The CLI should remain agent-friendly and emit plain markdown/text output.
@@ -100,3 +101,11 @@ When cutting a release, update `src/content/docs/changelog.md` before tagging.
100
101
  - Do not include docs-site-only changes such as site styling, Zuedocs/package bumps, deploy plumbing, footer/layout changes, or documentation navigation changes.
101
102
  - Rewrite commit subjects into clear release notes instead of pasting raw commit messages.
102
103
  - If a release contains only tagging/release metadata, write: `Maintenance release. No direct code behavior changes beyond release preparation.`
104
+
105
+ ### Docs deployment
106
+
107
+ The Docs GitHub Actions workflow deploys production only for the watched site paths; Vercel Git auto-deployments and `ignoreCommand` stay disabled. Keep the project-scoped `VERCEL_TOKEN` Actions secret and ID variables configured. Check Astro locally; builds run in CI.
108
+
109
+ ### Origo deployment
110
+
111
+ The separate `Origo API` GitHub Actions workflow deploys the Go function only for changes to its import graph and Vercel configuration. Use `VERCEL_ORIGO_TOKEN` and `VERCEL_ORIGO_PROJECT_ID` exclusively for Origo; never substitute the WebCTX Docs token or a static `--prebuilt` site bundle. Keep the `/mcp` rewrite and Origo production environment variables intact.
package/CONTRIBUTORS.md CHANGED
@@ -108,9 +108,9 @@ gh workflow run npm-publish-retry.yml --repo amxv/webctx --ref main -f version=0
108
108
 
109
109
  ## Origo deployment
110
110
 
111
- The `origo-api` Vercel project is linked to this GitHub repo and uses `ORIGO_DEPLOYMENT=1` to select its Go Function configuration. It needs `ORIGO_API_KEY` and `FIRECRAWL_API_KEY` production environment variables; optionally add `GH_TOKEN` for native authenticated GitHub reads. The docs project remains `webctx-docs` and never receives the Origo connection key.
111
+ The `origo-api` Vercel project uses `ORIGO_DEPLOYMENT=1` to select its Go Function configuration. It needs `ORIGO_API_KEY` and `FIRECRAWL_API_KEY` production environment variables; optionally add `GH_TOKEN` for native authenticated GitHub reads. The docs project remains `webctx-docs` and never receives the Origo connection key.
112
112
 
113
- Both projects deploy automatically from `main`. `scripts/should-build.mjs` gates docs and API builds independently, while `v*` GitHub tags still drive the CLI release workflow.
113
+ The independent `Docs` and `Origo API` GitHub Actions workflows filter their own build inputs before deploying to Vercel. Origo uses the `VERCEL_ORIGO_TOKEN` Actions secret and `VERCEL_ORIGO_PROJECT_ID` variable, separate from the docs credentials. Git-triggered Vercel deployments are disabled; `v*` tags still drive CLI releases.
114
114
 
115
115
  ## Notes on package naming
116
116
 
package/README.md CHANGED
@@ -95,7 +95,7 @@ Recognized native GitHub auth, private/not-found, and rate-limit failures stay a
95
95
 
96
96
  ## Search
97
97
 
98
- Normal search asks Brave, Tavily, and Exa, removes duplicate URLs, automatically reads promising sources, follows relevant links, and returns excerpts plus the ranked list. Alexandria discovery is included when available; use `execute` when you need provider-native data.
98
+ Normal search asks the enabled Brave, Tavily, and Exa providers, removes duplicate URLs, automatically reads promising sources, follows relevant links, and returns excerpts plus the ranked list. One working search provider is enough; Alexandria discovery is included when available. Use `execute` when you need provider-native data.
99
99
 
100
100
  ```bash
101
101
  webctx search "next.js server components"
@@ -103,6 +103,20 @@ webctx search "react hooks" --exclude youtube.com,medium.com
103
103
  webctx search "drizzle orm" --keyword "migration guide"
104
104
  ```
105
105
 
106
+ ### Enable or disable search providers
107
+
108
+ ```bash
109
+ webctx status # enabled/disabled providers and locally configured keys
110
+ webctx providers disable exa # stop all Exa search requests, even with a key present
111
+ webctx providers enable exa # turn Exa back on later
112
+ webctx providers disable brave
113
+ webctx providers enable brave
114
+ ```
115
+
116
+ Provider settings persist across runs, independently of environment variables and Keychain credentials. The settings file lives in the OS user config directory (`webctx/config.json`); set `WEBCTX_CONFIG_FILE` to override its location. Disabled providers are never queried. `status` only reports key presence, **not** live credit balances or API health.
117
+
118
+ `--keyword` prefers Exa's strict include-text filter when available. If Exa is disabled, unconfigured, or fails, Brave/Tavily search for the quoted phrase instead; the output explicitly notes that this fallback cannot guarantee the phrase appears in the page text. Firecrawl is a separate reading/site-map/Alexandria integration, not one of these three search providers.
119
+
106
120
  ## Map a site
107
121
 
108
122
  Use `map-site` when you want to discover the useful pages before reading them.
@@ -131,7 +145,7 @@ FIRECRAWL_API_KEY=...
131
145
  GH_TOKEN=...
132
146
  ```
133
147
 
134
- - `BRAVE_API_KEY`, `TAVILY_API_KEY`, and `EXA_API_KEY` power normal `search`.
148
+ - `BRAVE_API_KEY`, `TAVILY_API_KEY`, and `EXA_API_KEY` power normal `search`. Configure any one or more, and optionally disable providers you no longer want to query.
135
149
  - `FIRECRAWL_API_KEY` powers `map-site`, page-crawl fallbacks, and Alexandria discovery/inspection/paid execution.
136
150
  - `GH_TOKEN` or `GITHUB_TOKEN` is optional. It increases GitHub capacity and unlocks reads such as blame, Discussions, some Actions job logs, private resources the token can access, and richer PR review state.
137
151
 
@@ -16,8 +16,6 @@ const commandName = "webctx"
16
16
  var version = buildinfo.CurrentVersion()
17
17
 
18
18
  func Run(args []string, stdout, stderr io.Writer) int {
19
- loadEnvLocal()
20
-
21
19
  if len(args) == 0 || isHelpArg(args[0]) {
22
20
  _, _ = fmt.Fprintln(stdout, usageText())
23
21
  return 0
@@ -29,6 +27,37 @@ func Run(args []string, stdout, stderr io.Writer) int {
29
27
  }
30
28
 
31
29
  tool := args[0]
30
+ isProviderTool := tool == "status" || tool == "providers" || tool == "provider"
31
+ isProviderStatus := (tool == "status" && len(args) == 1) ||
32
+ ((tool == "providers" || tool == "provider") && (len(args) == 1 || (len(args) == 2 && args[1] == "status")))
33
+ if !isProviderTool || isProviderStatus {
34
+ loadEnvLocal()
35
+ }
36
+ if isProviderTool {
37
+ if isProviderStatus {
38
+ if err := printProviderStatus(stdout); err != nil {
39
+ _, _ = fmt.Fprintln(stderr, err)
40
+ return 1
41
+ }
42
+ return 0
43
+ }
44
+ if tool == "status" || len(args) != 3 || (args[1] != "enable" && args[1] != "disable") {
45
+ _, _ = fmt.Fprintln(stderr, "Usage: webctx providers [status|enable <brave|tavily|exa>|disable <brave|tavily|exa>]")
46
+ return 1
47
+ }
48
+ enabled := args[1] == "enable"
49
+ if _, err := setSearchProviderEnabled(args[2], enabled); err != nil {
50
+ _, _ = fmt.Fprintln(stderr, err)
51
+ return 1
52
+ }
53
+ state := "disabled"
54
+ if enabled {
55
+ state = "enabled"
56
+ }
57
+ _, _ = fmt.Fprintf(stdout, "%s %s. Run 'webctx status' to inspect all providers.\n", strings.ToLower(strings.TrimSpace(args[2])), state)
58
+ return 0
59
+ }
60
+
32
61
  flags, positional := parseArgs(args[1:])
33
62
  input := ""
34
63
  if len(positional) > 0 {
@@ -89,6 +118,7 @@ func Run(args []string, stdout, stderr io.Writer) int {
89
118
  result, e := MapSitePage(MapSiteInput{
90
119
  URL: input, Limit: limit, Offset: offset, Query: flags["query"],
91
120
  PathPrefix: flags["path-prefix"], Language: flags["language"],
121
+ limitProvided: flags["limit"] != "",
92
122
  })
93
123
  if e != nil {
94
124
  return cliDataError(stderr, e)
@@ -196,6 +226,8 @@ func usageText() string {
196
226
 
197
227
  Usage:
198
228
  webctx search <query> [--exclude domain1,domain2] [--keyword phrase]
229
+ webctx status
230
+ webctx providers [status|enable <brave|tavily|exa>|disable <brave|tavily|exa>]
199
231
  webctx read-link <url> [--question 'what to find']
200
232
  webctx map-site <url> [--query topic] [--limit 30] [--offset 0] [--path-prefix /api] [--language en|all]
201
233
  webctx research [question] [--view sources|groups|operations] [--sources IDs] [--include inputs,output,examples] [--limit N] [--offset N]
@@ -207,6 +239,8 @@ Examples:
207
239
  webctx search "next.js server components"
208
240
  webctx search "react hooks" --exclude youtube.com,vimeo.com
209
241
  webctx search "drizzle orm" --keyword "migration guide"
242
+ webctx providers disable exa
243
+ webctx providers enable exa
210
244
  webctx research "podcast conversations about AI agents"
211
245
  webctx research --view sources --limit 5
212
246
  webctx inspect particle/podcasts/episodes/search
@@ -84,6 +84,7 @@ func TestScoreAndRankResultsPreservesInsertionOrderForTies(t *testing.T) {
84
84
  }
85
85
 
86
86
  func TestSearchMissingCredentialsErrorIsHelpful(t *testing.T) {
87
+ t.Setenv("WEBCTX_CONFIG_FILE", filepath.Join(t.TempDir(), "config.json"))
87
88
  t.Setenv("BRAVE_API_KEY", "")
88
89
  t.Setenv("TAVILY_API_KEY", "")
89
90
  t.Setenv("EXA_API_KEY", "")
@@ -34,6 +34,16 @@ func TestAuditPerRecordBoundsFromActualOperations(t *testing.T) {
34
34
  "options": []any{map[string]any{"name": "ids", "type": "array"}}},
35
35
  map[string]any{"ids": []any{"A", "B"}}, 4,
36
36
  },
37
+ {
38
+ map[string]any{"id": "fullenrich/contacts/mobile", "creditsCost": 175, "perRecord": true,
39
+ "response": map[string]any{"paginated": true, "about": "One record in results when a mobile number was found, an empty array when not."}},
40
+ map[string]any{"linkedin_url": "https://www.linkedin.com/in/example-test-person/"}, 175,
41
+ },
42
+ {
43
+ map[string]any{"id": "example/search", "creditsCost": 2, "perRecord": true,
44
+ "options": []any{map[string]any{"name": "k", "type": "integer"}, map[string]any{"name": "limit", "type": "integer"}}},
45
+ map[string]any{"limit": 1, "k": 120}, 240,
46
+ },
37
47
  }
38
48
  for _, tt := range tests {
39
49
  got, err := expectedCallCredits(tt.contract, tt.inputs)
@@ -43,6 +53,92 @@ func TestAuditPerRecordBoundsFromActualOperations(t *testing.T) {
43
53
  }
44
54
  }
45
55
 
56
+ func TestFilteredCataloguePaginationBeyondFirstHundred(t *testing.T) {
57
+ previous := http.DefaultClient
58
+ t.Cleanup(func() { http.DefaultClient = previous })
59
+ t.Setenv("FIRECRAWL_API_KEY", "test-filtered-pagination")
60
+ const total = 215
61
+ var upstreamOffsets []int
62
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
63
+ if req.URL.Path != "/v2/scrape" {
64
+ return nil, fmt.Errorf("unexpected endpoint %s", req.URL.Path)
65
+ }
66
+ var body map[string]any
67
+ if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
68
+ return nil, err
69
+ }
70
+ options := mapField(mapField(body["alexandria"])["options"])
71
+ offset, _ := numberField(options["offset"])
72
+ upstreamOffsets = append(upstreamOffsets, int(offset))
73
+ start, end := int(offset), int(offset)+100
74
+ if end > total {
75
+ end = total
76
+ }
77
+ var items []any
78
+ for i := start; i < end; i++ {
79
+ items = append(items, map[string]any{
80
+ "id": fmt.Sprintf("fred-stlouisfed-org/data/series_%03d", i),
81
+ "name": "Inflation series observations", "description": "Retrieve inflation observations",
82
+ "creditsCost": 5, "provider": "fred-stlouisfed-org",
83
+ })
84
+ }
85
+ encoded, _ := json.Marshal(map[string]any{"success": true, "data": map[string]any{
86
+ "alexandria": []any{map[string]any{"provider": "firecrawl", "capability": "find-tools",
87
+ "data": map[string]any{"items": items, "total": total}}}}})
88
+ return testHTTPResponse(req, 200, string(encoded), nil), nil
89
+ })}
90
+ input := ResearchDataInput{Query: "inflation series", Mode: "ranked", Sources: []string{"fred-stlouisfed-org"}, Limit: 10}
91
+ first, err := ResearchData(input)
92
+ if err != nil {
93
+ t.Fatal(err)
94
+ }
95
+ if first["ranking_incomplete"] != true || first["ranking_scope"] != "catalogue_page" {
96
+ t.Fatalf("ranked first page should disclose scope: %+v", first)
97
+ }
98
+ seen := map[string]bool{}
99
+ pages := 0
100
+ next := first
101
+ for {
102
+ pages++
103
+ rows := next["results"].([]map[string]any)
104
+ if len(rows) == 0 {
105
+ t.Fatalf("empty page %d", pages)
106
+ }
107
+ for _, row := range rows {
108
+ id := row["id"].(string)
109
+ if seen[id] {
110
+ t.Fatalf("pagination repeated %s", id)
111
+ }
112
+ seen[id] = true
113
+ }
114
+ cont := mapField(next["next"])
115
+ if cont == nil {
116
+ break
117
+ }
118
+ if pages > 30 {
119
+ t.Fatal("pagination did not terminate")
120
+ }
121
+ args := cont["arguments"].(ResearchDataInput)
122
+ next, err = ResearchData(args)
123
+ if err != nil {
124
+ t.Fatalf("page %d: %v", pages, err)
125
+ }
126
+ }
127
+ if len(seen) != total || pages != 22 {
128
+ t.Fatalf("pagination missed items: got %d of %d over %d pages", len(seen), total, pages)
129
+ }
130
+ hasSecondBatch := false
131
+ for _, offset := range upstreamOffsets {
132
+ if offset == 100 {
133
+ hasSecondBatch = true
134
+ break
135
+ }
136
+ }
137
+ if !hasSecondBatch {
138
+ t.Fatalf("never fetched beyond the first 100 entries: %v", upstreamOffsets)
139
+ }
140
+ }
141
+
46
142
  func TestAuditPaginationAdaptersPreserveFilters(t *testing.T) {
47
143
  tests := []struct {
48
144
  name string
@@ -327,6 +423,46 @@ func TestAuditWorkerIdempotentReplayAndCollision(t *testing.T) {
327
423
  }
328
424
  }
329
425
 
426
+ func TestConcurrentSameIDHasExactlyOnePaidExecution(t *testing.T) {
427
+ mock := installDataTestTransport(t)
428
+ const workers = 12
429
+ id := "audit-parallel-replay-20261010"
430
+ input := ExecuteDataInput{RequestID: id, Calls: []DataCall{{
431
+ ID: "particle/podcasts/episodes/search",
432
+ Inputs: map[string]any{"semantic_search": "AI agents", "limit": float64(2)},
433
+ }}}
434
+ start := make(chan struct{})
435
+ type result struct {
436
+ output map[string]any
437
+ err error
438
+ }
439
+ out := make(chan result, workers)
440
+ for range workers {
441
+ go func() {
442
+ <-start
443
+ response, err := ExecuteData(input)
444
+ out <- result{response, err}
445
+ }()
446
+ }
447
+ close(start)
448
+ replays := 0
449
+ for range workers {
450
+ r := <-out
451
+ if r.err != nil {
452
+ t.Fatalf("same-ID concurrent request failed: %v", r.err)
453
+ }
454
+ if r.output["replayed"] == true {
455
+ replays++
456
+ }
457
+ }
458
+ mock.mu.Lock()
459
+ calls := mock.executions
460
+ mock.mu.Unlock()
461
+ if calls != 1 || replays != workers-1 {
462
+ t.Fatalf("concurrent same-ID calls executed %d provider requests; %d cached replays; wanted 1 and %d", calls, replays, workers-1)
463
+ }
464
+ }
465
+
330
466
  func TestAuditMixedTermsBatchKeepsIndependentResultsAndCredits(t *testing.T) {
331
467
  previous := http.DefaultClient
332
468
  t.Cleanup(func() { http.DefaultClient = previous })
@@ -374,7 +510,7 @@ func TestAuditMixedTermsBatchKeepsIndependentResultsAndCredits(t *testing.T) {
374
510
  {ID: "fred/data/search", Inputs: map[string]any{"query": "inflation"}},
375
511
  {ID: "sec/data/search", Inputs: map[string]any{"query": "filings"}},
376
512
  }
377
- result, err := ExecuteData(ExecuteDataInput{Calls: calls})
513
+ result, err := ExecuteData(ExecuteDataInput{Calls: calls, RequestID: "audit-partial-replay-20261010"})
378
514
  if err != nil {
379
515
  t.Fatal(err)
380
516
  }
@@ -400,6 +536,11 @@ func TestAuditMixedTermsBatchKeepsIndependentResultsAndCredits(t *testing.T) {
400
536
  if paid["particle"] != 1 || paid["fred"] != 1 || paid["sec"] != 1 {
401
537
  t.Fatalf("provider calls were not independent: %+v", paid)
402
538
  }
539
+ replayed, err := ExecuteData(ExecuteDataInput{Calls: calls, RequestID: "audit-partial-replay-20261010"})
540
+ if err != nil || replayed["replayed"] != true || paid["fred"] != 1 || paid["sec"] != 1 ||
541
+ replayed["credits_charged_this_request"] != 0 {
542
+ t.Fatalf("replay of partial batch re-executed charged successes: reply=%+v paid=%+v error=%v", replayed, paid, err)
543
+ }
403
544
  }
404
545
 
405
546
  func TestAuditHighPriceOperationCannotBeRecommendedAsRunnable(t *testing.T) {
@@ -210,14 +210,16 @@ func expectedCallCredits(contract map[string]any, inputs map[string]any) (int, e
210
210
  count := 0.0
211
211
  for _, key := range []string{"limit", "k", "top_k", "topK", "per_page", "page_size", "max_results", "count", "size", "num_results", "max_records", "results_per_page"} {
212
212
  if n, ok := numberField(inputs[key]); ok && n >= 1 {
213
- count = n
214
- break
213
+ // Providers occasionally accept several independent count
214
+ // fields. Use the largest explicit bound instead of
215
+ // underestimating charges from the first matching key.
216
+ count = math.Max(count, n)
215
217
  }
216
218
  }
217
219
  // For bulk identifier lookups, a response may be charged per input.
218
220
  for _, key := range []string{"ids", "domains", "urls", "companies", "people", "symbols"} {
219
- if records := listField(inputs[key]); count == 0 && len(records) > 0 {
220
- count = float64(len(records))
221
+ if records := listField(inputs[key]); len(records) > 0 {
222
+ count = math.Max(count, float64(len(records)))
221
223
  }
222
224
  }
223
225
  if count == 0 {
@@ -226,7 +228,7 @@ func expectedCallCredits(contract map[string]any, inputs map[string]any) (int, e
226
228
  for _, key := range []string{"limit", "k", "top_k", "topK", "per_page", "page_size", "max_results", "count", "size", "num_results", "max_records", "results_per_page"} {
227
229
  if stringField(f["name"]) == key {
228
230
  if n, ok := numberField(f["default"]); ok && n >= 1 {
229
- count = n
231
+ count = math.Max(count, n)
230
232
  }
231
233
  }
232
234
  }
@@ -252,6 +254,20 @@ func expectedCallCredits(contract map[string]any, inputs map[string]any) (int, e
252
254
  count = 1
253
255
  }
254
256
  }
257
+ // FullEnrich's contact mobile enrichment is per one result even
258
+ // though its contract sets paginated=true. The response expressly
259
+ // states "One record in results", with empty/no-match free.
260
+ response := mapField(contract["response"])
261
+ description := strings.ToLower(stringField(response["about"]))
262
+ if count == 0 && strings.Contains(description, "one record in results") &&
263
+ (strings.HasSuffix(id, "/mobile") || strings.HasSuffix(id, "/lookup")) {
264
+ for _, key := range []string{"linkedin_url", "first_name", "domain", "professional_network_url"} {
265
+ if strings.TrimSpace(stringField(inputs[key])) != "" {
266
+ count = 1
267
+ break
268
+ }
269
+ }
270
+ }
255
271
  }
256
272
  if count == 0 {
257
273
  return 0, dataError("unbounded_per_record_cost",
@@ -476,6 +492,8 @@ func ExecuteData(input ExecuteDataInput) (map[string]any, error) {
476
492
  }
477
493
  }
478
494
  if input.RequestID != "" {
495
+ unlock := lockPaidRequest(requestID)
496
+ defer unlock()
479
497
  if cached, err := cachedPaidReplay(requestID, input.Calls); err != nil {
480
498
  return nil, err
481
499
  } else if cached != nil {
@@ -540,9 +558,10 @@ func ExecuteData(input ExecuteDataInput) (map[string]any, error) {
540
558
  if input.Raw {
541
559
  output["raw"] = rawResponses
542
560
  }
543
- if failed == 0 {
544
- storePaidReplay(requestID, input.Calls, output)
545
- }
561
+ // Cache partial responses too. Retrying a mixed batch must not resubmit
562
+ // already successful paid operations just because a different provider
563
+ // rejected its terms.
564
+ storePaidReplay(requestID, input.Calls, output)
546
565
  return output, nil
547
566
  }
548
567
 
@@ -20,6 +20,41 @@ var paidReplayMemory = struct {
20
20
  items map[string]localPaidReplay
21
21
  }{items: map[string]localPaidReplay{}}
22
22
 
23
+ // In a single warm worker, serialize requests with the same idempotency key.
24
+ // Without this, two concurrent requests can both miss the replay cache and
25
+ // execute before either writes the receipt.
26
+ type paidReplayGate struct {
27
+ mu sync.Mutex
28
+ users int
29
+ }
30
+
31
+ var paidReplayGates = struct {
32
+ sync.Mutex
33
+ items map[string]*paidReplayGate
34
+ }{items: map[string]*paidReplayGate{}}
35
+
36
+ func lockPaidRequest(id string) func() {
37
+ key := localReplayKey(id)
38
+ paidReplayGates.Lock()
39
+ gate := paidReplayGates.items[key]
40
+ if gate == nil {
41
+ gate = &paidReplayGate{}
42
+ paidReplayGates.items[key] = gate
43
+ }
44
+ gate.users++
45
+ paidReplayGates.Unlock()
46
+ gate.mu.Lock()
47
+ return func() {
48
+ gate.mu.Unlock()
49
+ paidReplayGates.Lock()
50
+ gate.users--
51
+ if gate.users == 0 {
52
+ delete(paidReplayGates.items, key)
53
+ }
54
+ paidReplayGates.Unlock()
55
+ }
56
+ }
57
+
23
58
  func localReplayKey(id string) string {
24
59
  h := sha256.Sum256([]byte(os.Getenv("FIRECRAWL_API_KEY") + "\x00" + id))
25
60
  return hex.EncodeToString(h[:])
@@ -70,6 +70,12 @@ func normalizeResearchInput(input ResearchDataInput) (ResearchDataInput, string,
70
70
  if input.Offset < 0 || input.Offset > 100000 {
71
71
  return input, "", nil, dataError("invalid_offset", "offset must be between 0 and 100000.")
72
72
  }
73
+ if input.Mode == "ranked" && input.Offset > 0 && strings.TrimSpace(input.Query) != "" &&
74
+ len(input.Sources) == 0 && len(input.URLs) == 0 && len(input.Groups) == 0 &&
75
+ len(input.Categories) == 0 && len(input.Operations) == 0 {
76
+ return input, "", nil, dataError("ranked_search_not_pageable",
77
+ "Ranked semantic search has no native pagination cursor. Use mode=catalogue with offset, or supply a source/category filter to browse and rerank catalogue pages.")
78
+ }
73
79
  for _, target := range input.URLs {
74
80
  if err := validateSourceURL(target); err != nil {
75
81
  return input, "", nil, dataError("invalid_url", "Website filters require public HTTP(S) URLs.")
@@ -212,11 +218,13 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
212
218
  if localFilteredRanking {
213
219
  // Provider-specified lexical filters can exclude operations whose
214
220
  // descriptions describe the requested data indirectly. Fetch a
215
- // catalogue slice from the actual source, then rank it ourselves.
221
+ // catalogue page from the actual source, then rank that page.
222
+ // Do not reset every continuation to offset zero: large sources
223
+ // can have hundreds of operations.
216
224
  delete(options, "query")
217
225
  options["limit"] = 100
218
- options["offset"] = 0
219
- note = "The source/category filters select candidates first. The question ranks those candidates locally, rather than excluding matching source operations."
226
+ options["offset"] = (input.Offset / 100) * 100
227
+ note = "Source/category filters select candidates first. The question ranks each catalogue page locally (up to 100 operations per page); this is not a global ranking across unvisited pages."
220
228
  if input.Mode != "catalogue" {
221
229
  mode = "ranked_filtered"
222
230
  }
@@ -261,7 +269,7 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
261
269
  }
262
270
  if localFilteredRanking {
263
271
  count := len(items)
264
- start := input.Offset
272
+ start := input.Offset % 100
265
273
  if start > count {
266
274
  start = count
267
275
  }
@@ -270,12 +278,16 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
270
278
  end = count
271
279
  }
272
280
  items = items[start:end]
273
- if count > end {
281
+ sourceTotal, hasTotal := numberField(total)
282
+ more := count > end || (hasTotal && sourceTotal > float64((input.Offset/100)*100+count))
283
+ if more {
274
284
  next = map[string]any{"tool": "research", "arguments": ResearchDataInput{
275
285
  Query: input.Query, Mode: input.Mode, URLs: input.URLs, Sources: input.Sources,
276
286
  Categories: input.Categories, Groups: input.Groups, View: niceDataView(view),
277
- Include: input.Include, Limit: input.Limit, Offset: end,
287
+ Include: input.Include, Limit: input.Limit, Offset: input.Offset + len(items),
278
288
  }}
289
+ } else {
290
+ next = nil
279
291
  }
280
292
  }
281
293
  results := make([]map[string]any, 0, len(items))
@@ -359,6 +371,12 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
359
371
  if note != "" {
360
372
  output["mode_note"] = note
361
373
  }
374
+ if localFilteredRanking {
375
+ output["ranking_scope"] = "catalogue_page"
376
+ if sourceTotal, ok := numberField(total); ok {
377
+ output["ranking_incomplete"] = sourceTotal > 100
378
+ }
379
+ }
362
380
  if len(input.URLs) > 0 && len(input.Sources) > 0 {
363
381
  output["resolved_url_sources"] = input.Sources
364
382
  }
@@ -0,0 +1,161 @@
1
+ package app
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "io"
7
+ "os"
8
+ "path/filepath"
9
+ "slices"
10
+ "strings"
11
+ )
12
+
13
+ // These are the configurable search providers. Firecrawl powers independent
14
+ // read-link, map-site, and Alexandria operations, not federated web search.
15
+ var searchProviderKeys = []struct {
16
+ name string
17
+ key string
18
+ }{
19
+ {"brave", "BRAVE_API_KEY"},
20
+ {"tavily", "TAVILY_API_KEY"},
21
+ {"exa", "EXA_API_KEY"},
22
+ }
23
+
24
+ type providerConfig struct {
25
+ DisabledProviders []string `json:"disabled_providers,omitempty"`
26
+ }
27
+
28
+ func providerConfigPath() (string, error) {
29
+ if custom := strings.TrimSpace(os.Getenv("WEBCTX_CONFIG_FILE")); custom != "" {
30
+ return custom, nil
31
+ }
32
+ dir, err := os.UserConfigDir()
33
+ if err != nil {
34
+ return "", fmt.Errorf("locate WebCTX config directory: %w", err)
35
+ }
36
+ return filepath.Join(dir, "webctx", "config.json"), nil
37
+ }
38
+
39
+ func readProviderConfig(path string) (providerConfig, error) {
40
+ contents, err := os.ReadFile(path)
41
+ if os.IsNotExist(err) {
42
+ return providerConfig{}, nil // all providers default to enabled
43
+ }
44
+ if err != nil {
45
+ return providerConfig{}, fmt.Errorf("read provider settings %s: %w", path, err)
46
+ }
47
+ var config providerConfig
48
+ if err := json.Unmarshal(contents, &config); err != nil {
49
+ return providerConfig{}, fmt.Errorf("invalid provider settings %s: %w", path, err)
50
+ }
51
+ return config, nil
52
+ }
53
+
54
+ func currentProviderConfig() (providerConfig, error) {
55
+ path, err := providerConfigPath()
56
+ if err != nil {
57
+ return providerConfig{}, err
58
+ }
59
+ return readProviderConfig(path)
60
+ }
61
+
62
+ func (c providerConfig) enabled(name string) bool {
63
+ return !slices.Contains(c.DisabledProviders, name)
64
+ }
65
+
66
+ func validSearchProvider(name string) bool {
67
+ for _, p := range searchProviderKeys {
68
+ if p.name == name {
69
+ return true
70
+ }
71
+ }
72
+ return false
73
+ }
74
+
75
+ func setSearchProviderEnabled(name string, enabled bool) (string, error) {
76
+ name = strings.ToLower(strings.TrimSpace(name))
77
+ if !validSearchProvider(name) {
78
+ return "", fmt.Errorf("unknown search provider %q (choose brave, tavily, or exa)", name)
79
+ }
80
+ path, err := providerConfigPath()
81
+ if err != nil {
82
+ return "", err
83
+ }
84
+ config, err := readProviderConfig(path)
85
+ if err != nil {
86
+ return "", err
87
+ }
88
+ if config.enabled(name) == enabled {
89
+ return path, nil
90
+ }
91
+ if enabled {
92
+ config.DisabledProviders = slices.DeleteFunc(config.DisabledProviders, func(p string) bool { return p == name })
93
+ } else {
94
+ config.DisabledProviders = append(config.DisabledProviders, name)
95
+ }
96
+ slices.Sort(config.DisabledProviders)
97
+ if err := writeProviderConfig(path, config); err != nil {
98
+ return "", err
99
+ }
100
+ return path, nil
101
+ }
102
+
103
+ func writeProviderConfig(path string, config providerConfig) error {
104
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
105
+ return fmt.Errorf("create provider config directory: %w", err)
106
+ }
107
+ data, err := json.MarshalIndent(config, "", " ")
108
+ if err != nil {
109
+ return err
110
+ }
111
+ file, err := os.CreateTemp(filepath.Dir(path), ".webctx-config-*")
112
+ if err != nil {
113
+ return fmt.Errorf("write provider settings: %w", err)
114
+ }
115
+ defer os.Remove(file.Name())
116
+ if err := file.Chmod(0o600); err != nil {
117
+ _ = file.Close()
118
+ return err
119
+ }
120
+ if _, err := file.Write(append(data, '\n')); err != nil {
121
+ _ = file.Close()
122
+ return err
123
+ }
124
+ if err := file.Close(); err != nil {
125
+ return err
126
+ }
127
+ if err := os.Rename(file.Name(), path); err != nil {
128
+ return fmt.Errorf("save provider settings: %w", err)
129
+ }
130
+ return nil
131
+ }
132
+
133
+ func printProviderStatus(out io.Writer) error {
134
+ path, err := providerConfigPath()
135
+ if err != nil {
136
+ return err
137
+ }
138
+ config, err := readProviderConfig(path)
139
+ if err != nil {
140
+ return err
141
+ }
142
+ _, _ = fmt.Fprintf(out, "Search providers (settings: %s)\n", path)
143
+ for _, p := range searchProviderKeys {
144
+ state, key := "enabled", "key missing"
145
+ if !config.enabled(p.name) {
146
+ state = "disabled"
147
+ }
148
+ if strings.TrimSpace(os.Getenv(p.key)) != "" {
149
+ key = "key configured"
150
+ }
151
+ _, _ = fmt.Fprintf(out, " %-7s %-9s %s\n", p.name, state, key)
152
+ }
153
+ _, _ = fmt.Fprintln(out, "\nFirecrawl (page reading, site maps, and Alexandria; separate from search):")
154
+ if strings.TrimSpace(os.Getenv("FIRECRAWL_API_KEY")) != "" {
155
+ _, _ = fmt.Fprintln(out, " key configured")
156
+ } else {
157
+ _, _ = fmt.Fprintln(out, " key missing")
158
+ }
159
+ _, _ = fmt.Fprintln(out, "\nKey presence is local configuration only; quotas and API availability are not checked.")
160
+ return nil
161
+ }
@@ -0,0 +1,302 @@
1
+ package app
2
+
3
+ import (
4
+ "bytes"
5
+ "compress/gzip"
6
+ "context"
7
+ "encoding/json"
8
+ "io"
9
+ "net/http"
10
+ "net/http/httptest"
11
+ "os"
12
+ "path/filepath"
13
+ "strings"
14
+ "sync"
15
+ "testing"
16
+ )
17
+
18
+ func TestProviderCommandsPersistAndReport(t *testing.T) {
19
+ path := filepath.Join(t.TempDir(), "webctx", "config.json")
20
+ t.Setenv("WEBCTX_CONFIG_FILE", path)
21
+ t.Setenv("BRAVE_API_KEY", "test-brave-secret")
22
+ t.Setenv("TAVILY_API_KEY", "")
23
+ t.Setenv("EXA_API_KEY", "test-exa-secret")
24
+ t.Setenv("FIRECRAWL_API_KEY", "")
25
+
26
+ run := func(args ...string) (int, string, string) {
27
+ t.Helper()
28
+ var out, errOut bytes.Buffer
29
+ code := Run(args, &out, &errOut)
30
+ return code, out.String(), errOut.String()
31
+ }
32
+ if code, text, errText := run("status"); code != 0 || !strings.Contains(text, "exa enabled") || !strings.Contains(text, "brave enabled") || errText != "" {
33
+ t.Fatalf("default status: code=%d text=%q error=%q", code, text, errText)
34
+ }
35
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
36
+ t.Fatalf("status should not create config file, got error %v", err)
37
+ }
38
+ if code, text, errText := run("providers", "disable", "ExA"); code != 0 || !strings.Contains(text, "exa disabled") || errText != "" {
39
+ t.Fatalf("disable: code=%d text=%q error=%q", code, text, errText)
40
+ }
41
+ if code, text, _ := run("providers"); code != 0 || !strings.Contains(text, "exa disabled") || !strings.Contains(text, "key configured") {
42
+ t.Fatalf("persisted status: code=%d text=%q", code, text)
43
+ } else if strings.Contains(text, "test-exa-secret") || strings.Contains(text, "test-brave-secret") {
44
+ t.Fatalf("status exposed a credential: %q", text)
45
+ }
46
+ content, err := os.ReadFile(path)
47
+ if err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ var config providerConfig
51
+ if err := json.Unmarshal(content, &config); err != nil || !strings.EqualFold(strings.Join(config.DisabledProviders, ","), "exa") {
52
+ t.Fatalf("settings not persisted: %s; error=%v", content, err)
53
+ }
54
+ if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 {
55
+ t.Fatalf("config permissions: info=%v error=%v", info, err)
56
+ }
57
+ if code, text, errText := run("providers", "enable", "exa"); code != 0 || !strings.Contains(text, "exa enabled") || errText != "" {
58
+ t.Fatalf("enable: code=%d text=%q error=%q", code, text, errText)
59
+ }
60
+ if code, text, _ := run("status"); code != 0 || !strings.Contains(text, "exa enabled") {
61
+ t.Fatalf("restored status: code=%d text=%q", code, text)
62
+ }
63
+ }
64
+
65
+ func TestProviderCommandsRejectInvalidInputAndMalformedConfig(t *testing.T) {
66
+ path := filepath.Join(t.TempDir(), "config.json")
67
+ t.Setenv("WEBCTX_CONFIG_FILE", path)
68
+ for _, args := range [][]string{{"providers", "disable", "firecrawl"}, {"providers", "disable", "unknown"}, {"providers", "disable"}, {"status", "enable", "exa"}} {
69
+ var out, errOut bytes.Buffer
70
+ if Run(args, &out, &errOut) == 0 || errOut.Len() == 0 {
71
+ t.Fatalf("invalid command %q succeeded: %q", args, out.String())
72
+ }
73
+ }
74
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
75
+ t.Fatalf("invalid command wrote config: %v", err)
76
+ }
77
+ if err := os.WriteFile(path, []byte("{invalid"), 0o600); err != nil {
78
+ t.Fatal(err)
79
+ }
80
+ var out, errOut bytes.Buffer
81
+ if Run([]string{"status"}, &out, &errOut) == 0 || !strings.Contains(errOut.String(), "invalid provider settings") {
82
+ t.Fatalf("malformed config not reported: %q", errOut.String())
83
+ }
84
+ if Run([]string{"providers", "disable", "exa"}, &out, &errOut) == 0 {
85
+ t.Fatal("malformed config should not be overwritten")
86
+ }
87
+ }
88
+
89
+ type providerRequests struct {
90
+ mu sync.Mutex
91
+ queries map[string][]string
92
+ failedExa bool
93
+ emptyExa bool
94
+ }
95
+
96
+ func mockSearchProviders(t *testing.T, exaFailure bool) *providerRequests {
97
+ t.Helper()
98
+ prev := http.DefaultClient.Transport
99
+ stats := &providerRequests{queries: make(map[string][]string), failedExa: exaFailure}
100
+ http.DefaultClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
101
+ provider, query := "", ""
102
+ switch req.URL.Host {
103
+ case "api.search.brave.com":
104
+ provider, query = "brave", req.URL.Query().Get("q")
105
+ case "api.tavily.com", "api.exa.ai":
106
+ if req.URL.Host == "api.tavily.com" {
107
+ provider = "tavily"
108
+ } else {
109
+ provider = "exa"
110
+ }
111
+ data, _ := io.ReadAll(req.Body)
112
+ var payload map[string]any
113
+ _ = json.Unmarshal(data, &payload)
114
+ query, _ = payload["query"].(string)
115
+ case "provider-test.example":
116
+ return testHTTPResponse(req, http.StatusOK, "# Example documentation\n\nThis markdown page contains enough content for a test.", map[string]string{"Content-Type": "text/markdown"}), nil
117
+ default:
118
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
119
+ }
120
+ stats.mu.Lock()
121
+ stats.queries[provider] = append(stats.queries[provider], query)
122
+ stats.mu.Unlock()
123
+ switch provider {
124
+ case "brave":
125
+ return testHTTPResponse(req, http.StatusOK, `{"web":{"results":[{"url":"https://provider-test.example/guide.md","title":"Guide","description":"Documentation"}]}}`, nil), nil
126
+ case "tavily":
127
+ return testHTTPResponse(req, http.StatusOK, `{"results":[]}`, nil), nil
128
+ case "exa":
129
+ if stats.failedExa {
130
+ return testHTTPResponse(req, http.StatusPaymentRequired, `{"error":"NO_MORE_CREDITS"}`, nil), nil
131
+ }
132
+ if stats.emptyExa {
133
+ return testHTTPResponse(req, http.StatusOK, `{"results":[]}`, nil), nil
134
+ }
135
+ return testHTTPResponse(req, http.StatusOK, `{"results":[{"url":"https://provider-test.example/guide.md","title":"Guide","text":"Documentation"}]}`, nil), nil
136
+ }
137
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
138
+ })
139
+ t.Cleanup(func() { http.DefaultClient.Transport = prev })
140
+ return stats
141
+ }
142
+
143
+ func (p *providerRequests) received(provider string) []string {
144
+ p.mu.Lock()
145
+ defer p.mu.Unlock()
146
+ return append([]string(nil), p.queries[provider]...)
147
+ }
148
+
149
+ func searchTestEnv(t *testing.T) {
150
+ t.Helper()
151
+ t.Setenv("WEBCTX_CONFIG_FILE", filepath.Join(t.TempDir(), "config.json"))
152
+ t.Setenv("BRAVE_API_KEY", "test-brave")
153
+ t.Setenv("TAVILY_API_KEY", "test-tavily")
154
+ t.Setenv("EXA_API_KEY", "test-exa")
155
+ t.Setenv("FIRECRAWL_API_KEY", "")
156
+ }
157
+
158
+ func TestSearchDoesNotCallDisabledExa(t *testing.T) {
159
+ searchTestEnv(t)
160
+ stats := mockSearchProviders(t, false)
161
+ if _, err := setSearchProviderEnabled("exa", false); err != nil {
162
+ t.Fatal(err)
163
+ }
164
+ result, err := Search(SearchParams{Query: "runtime docs"})
165
+ if err != nil || !strings.Contains(result, "Total Results: 1") {
166
+ t.Fatalf("search without Exa failed: %v; %q", err, result)
167
+ }
168
+ if got := stats.received("exa"); len(got) != 0 {
169
+ t.Fatalf("Exa was contacted despite being disabled: %v", got)
170
+ }
171
+ if got := stats.received("brave"); len(got) != 1 || got[0] != "runtime docs" {
172
+ t.Fatalf("Brave was not used: %v", got)
173
+ }
174
+ if got := stats.received("tavily"); len(got) != 1 {
175
+ t.Fatalf("Tavily was not used: %v", got)
176
+ }
177
+ }
178
+
179
+ func TestSearchSupportsOnlyBraveEnabled(t *testing.T) {
180
+ searchTestEnv(t)
181
+ stats := mockSearchProviders(t, false)
182
+ for _, name := range []string{"exa", "tavily"} {
183
+ if _, err := setSearchProviderEnabled(name, false); err != nil {
184
+ t.Fatal(err)
185
+ }
186
+ }
187
+ result, err := Search(SearchParams{Query: "runtime docs"})
188
+ if err != nil || !strings.Contains(result, "Total Results: 1") {
189
+ t.Fatalf("single-provider search failed: %v; %q", err, result)
190
+ }
191
+ if len(stats.received("exa")) != 0 || len(stats.received("tavily")) != 0 || len(stats.received("brave")) != 1 {
192
+ t.Fatalf("unexpected requests: %#v", stats.queries)
193
+ }
194
+ }
195
+
196
+ func TestKeywordFallbackSkipsDisabledExa(t *testing.T) {
197
+ searchTestEnv(t)
198
+ stats := mockSearchProviders(t, false)
199
+ if _, err := setSearchProviderEnabled("exa", false); err != nil {
200
+ t.Fatal(err)
201
+ }
202
+ result, err := Search(SearchParams{Query: "drizzle orm", IncludeKeyword: "migration guide"})
203
+ if err != nil || !strings.Contains(result, "Keyword fallback") {
204
+ t.Fatalf("keyword fallback failed: %v; %q", err, result)
205
+ }
206
+ if len(stats.received("exa")) != 0 {
207
+ t.Fatal("disabled Exa received keyword request")
208
+ }
209
+ for _, provider := range []string{"brave", "tavily"} {
210
+ got := stats.received(provider)
211
+ if len(got) != 1 || !strings.Contains(got[0], `"migration guide"`) {
212
+ t.Fatalf("%s did not receive a quoted phrase: %v", provider, got)
213
+ }
214
+ }
215
+ }
216
+
217
+ func TestKeywordFallbackOnExaCreditExhaustion(t *testing.T) {
218
+ searchTestEnv(t)
219
+ stats := mockSearchProviders(t, true)
220
+ result, err := Search(SearchParams{Query: "drizzle orm", IncludeKeyword: "migration guide"})
221
+ if err != nil || !strings.Contains(result, "Keyword fallback") || !strings.Contains(result, "Total Results: 1") {
222
+ t.Fatalf("keyword fallback after Exa error failed: %v; %q", err, result)
223
+ }
224
+ if len(stats.received("exa")) != 1 || len(stats.received("brave")) != 1 {
225
+ t.Fatalf("unexpected provider request sequence: %#v", stats.queries)
226
+ }
227
+ }
228
+
229
+ func TestKeywordExaSuccessDoesNotContactOtherProviders(t *testing.T) {
230
+ searchTestEnv(t)
231
+ stats := mockSearchProviders(t, false)
232
+ result, err := Search(SearchParams{Query: "drizzle orm", IncludeKeyword: "migration guide"})
233
+ if err != nil || strings.Contains(result, "Keyword fallback") || !strings.Contains(result, "Total Results: 1") {
234
+ t.Fatalf("unexpected keyword results: %v; %q", err, result)
235
+ }
236
+ if len(stats.received("exa")) != 1 || len(stats.received("brave")) != 0 || len(stats.received("tavily")) != 0 {
237
+ t.Fatalf("unexpected provider requests: %#v", stats.queries)
238
+ }
239
+ }
240
+
241
+ func TestKeywordExaNoMatchesDoesNotBroadenSearch(t *testing.T) {
242
+ searchTestEnv(t)
243
+ stats := mockSearchProviders(t, false)
244
+ stats.emptyExa = true
245
+ _, err := Search(SearchParams{Query: "drizzle orm", IncludeKeyword: "migration guide"})
246
+ if err == nil || !strings.Contains(err.Error(), "failed to return results") {
247
+ t.Fatalf("unexpected result for zero Exa matches: %v", err)
248
+ }
249
+ if len(stats.received("exa")) != 1 || len(stats.received("brave")) != 0 || len(stats.received("tavily")) != 0 {
250
+ t.Fatalf("strict keyword search was broadened: %#v", stats.queries)
251
+ }
252
+ }
253
+
254
+ func TestSearchWithAllProvidersDisabledFailsFast(t *testing.T) {
255
+ searchTestEnv(t)
256
+ stats := mockSearchProviders(t, false)
257
+ for _, name := range []string{"brave", "tavily", "exa"} {
258
+ if _, err := setSearchProviderEnabled(name, false); err != nil {
259
+ t.Fatal(err)
260
+ }
261
+ }
262
+ _, err := Search(SearchParams{Query: "anything"})
263
+ if err == nil || !strings.Contains(err.Error(), "all search providers are disabled") {
264
+ t.Fatalf("unexpected error when all disabled: %v", err)
265
+ }
266
+ if len(stats.received("exa"))+len(stats.received("tavily"))+len(stats.received("brave")) != 0 {
267
+ t.Fatal("search contacted provider with all disabled")
268
+ }
269
+ }
270
+
271
+ func TestBraveSearchTransparentlyDecodesGzip(t *testing.T) {
272
+ t.Setenv("BRAVE_API_KEY", "test-brave")
273
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
274
+ if r.Header.Get("Accept-Encoding") != "gzip" {
275
+ t.Errorf("expected Go HTTP transport to request gzip, got %q", r.Header.Get("Accept-Encoding"))
276
+ }
277
+ w.Header().Set("Content-Encoding", "gzip")
278
+ w.Header().Set("Content-Type", "application/json")
279
+ writer := gzip.NewWriter(w)
280
+ _, _ = writer.Write([]byte(`{"web":{"results":[{"url":"https://example.com/guide","title":"Guide","description":"Documentation"}]}}`))
281
+ _ = writer.Close()
282
+ }))
283
+ defer server.Close()
284
+
285
+ originalTransport := http.DefaultClient.Transport
286
+ http.DefaultClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
287
+ if req.Header.Get("Accept-Encoding") != "" {
288
+ t.Errorf("Brave search forced Accept-Encoding instead of letting Go decode it: %q", req.Header.Get("Accept-Encoding"))
289
+ }
290
+ copy := req.Clone(req.Context())
291
+ copy.URL.Scheme = "http"
292
+ copy.URL.Host = strings.TrimPrefix(server.URL, "http://")
293
+ copy.Host = copy.URL.Host
294
+ return http.DefaultTransport.RoundTrip(copy)
295
+ })
296
+ t.Cleanup(func() { http.DefaultClient.Transport = originalTransport })
297
+
298
+ result, err := searchWithBrave(context.Background(), "Go docs")
299
+ if err != nil || len(result.Docs) != 1 || result.Docs[0].Title != "Guide" {
300
+ t.Fatalf("Brave gzip response not decoded: %v; %#v", err, result)
301
+ }
302
+ }
@@ -1,6 +1,7 @@
1
1
  package app
2
2
 
3
3
  import (
4
+ "bytes"
4
5
  "context"
5
6
  "crypto/sha256"
6
7
  "encoding/hex"
@@ -20,12 +21,30 @@ import (
20
21
  // MapSiteInput retrieves an agent-sized page rather than dumping thousands
21
22
  // of URLs. An omitted language prefers English; "all" allows all locales.
22
23
  type MapSiteInput struct {
23
- URL string `json:"url" jsonschema:"Website URL to map; HTTP(S) only"`
24
- Query string `json:"query,omitempty" jsonschema:"Optional topic to filter and rank discovered pages"`
25
- Limit int `json:"limit,omitempty" jsonschema:"Number of URLs to return, 1-100; defaults to 30"`
26
- Offset int `json:"offset,omitempty" jsonschema:"Pagination offset, zero-based"`
27
- PathPrefix string `json:"path_prefix,omitempty" jsonschema:"Optional URL path prefix, e.g. /api/"`
28
- Language string `json:"language,omitempty" jsonschema:"Locale code such as en or fr; defaults to English, use all for all languages"`
24
+ URL string `json:"url" jsonschema:"Website URL to map; HTTP(S) only"`
25
+ Query string `json:"query,omitempty" jsonschema:"Optional topic to filter and rank discovered pages"`
26
+ Limit int `json:"limit,omitempty" jsonschema:"Number of URLs to return, 1-100; defaults to 30"`
27
+ Offset int `json:"offset,omitempty" jsonschema:"Pagination offset, zero-based"`
28
+ PathPrefix string `json:"path_prefix,omitempty" jsonschema:"Optional URL path prefix, e.g. /api/"`
29
+ Language string `json:"language,omitempty" jsonschema:"Locale code such as en or fr; defaults to English, use all for all languages"`
30
+ limitProvided bool `json:"-"`
31
+ }
32
+
33
+ func (input *MapSiteInput) UnmarshalJSON(raw []byte) error {
34
+ type fields MapSiteInput
35
+ var parsed fields
36
+ decoder := json.NewDecoder(bytes.NewReader(raw))
37
+ decoder.DisallowUnknownFields()
38
+ if err := decoder.Decode(&parsed); err != nil {
39
+ return err
40
+ }
41
+ var presence map[string]json.RawMessage
42
+ if err := json.Unmarshal(raw, &presence); err != nil {
43
+ return err
44
+ }
45
+ *input = MapSiteInput(parsed)
46
+ _, input.limitProvided = presence["limit"]
47
+ return nil
29
48
  }
30
49
 
31
50
  type siteMapCacheValue struct {
@@ -139,7 +158,7 @@ func mapPageLocale(raw string) string {
139
158
  }
140
159
 
141
160
  func MapSitePage(input MapSiteInput) (map[string]any, error) {
142
- if input.Limit == 0 {
161
+ if input.Limit == 0 && !input.limitProvided {
143
162
  input.Limit = 30
144
163
  }
145
164
  if input.Limit < 1 || input.Limit > 100 {
@@ -10,6 +10,7 @@ import (
10
10
  "net/url"
11
11
  "os"
12
12
  "sort"
13
+ "strconv"
13
14
  "strings"
14
15
  "sync"
15
16
  "time"
@@ -37,6 +38,11 @@ type providerDocs struct {
37
38
  Err error
38
39
  }
39
40
 
41
+ type namedSearch struct {
42
+ name string
43
+ fn func(context.Context) (SearchResult, error)
44
+ }
45
+
40
46
  type scoredURL struct {
41
47
  Result SearchDoc
42
48
  InsertionOrder int
@@ -47,6 +53,13 @@ type scoredURL struct {
47
53
  }
48
54
 
49
55
  func Search(params SearchParams) (string, error) {
56
+ config, err := currentProviderConfig()
57
+ if err != nil {
58
+ return "", err
59
+ }
60
+ if !config.enabled("brave") && !config.enabled("tavily") && !config.enabled("exa") {
61
+ return "", errors.New("Error searching the web: all search providers are disabled; run 'webctx providers enable brave', 'webctx providers enable tavily', or 'webctx providers enable exa'")
62
+ }
50
63
  defaultExcludedDomains := []string{"youtube.com", "vimeo.com", "dailymotion.com", "twitch.tv", "tiktok.com", "instagram.com", "facebook.com"}
51
64
  allExcludedDomains := append(append([]string{}, defaultExcludedDomains...), params.ExcludeDomains...)
52
65
  truncatedKeyword := truncateWords(params.IncludeKeyword, 5)
@@ -56,46 +69,44 @@ func Search(params SearchParams) (string, error) {
56
69
  alexandriaDone := make(chan string, 1)
57
70
  go func() { alexandriaDone <- discoverAlexandria(params.Query) }()
58
71
 
59
- type namedSearch struct {
60
- name string
61
- fn func(context.Context) (SearchResult, error)
62
- }
63
-
64
72
  searches := []namedSearch{}
73
+ keywordFallback := false
65
74
  if strings.TrimSpace(truncatedKeyword) != "" {
66
- searches = append(searches, namedSearch{name: "Exa", fn: func(ctx context.Context) (SearchResult, error) {
67
- return searchWithExa(ctx, params.Query, nil, truncatedKeyword)
68
- }})
75
+ if config.enabled("exa") && strings.TrimSpace(os.Getenv("EXA_API_KEY")) != "" {
76
+ searches = append(searches, namedSearch{name: "Exa", fn: func(ctx context.Context) (SearchResult, error) {
77
+ return searchWithExa(ctx, params.Query, nil, truncatedKeyword)
78
+ }})
79
+ }
69
80
  } else {
70
- searches = append(searches,
71
- namedSearch{name: "Brave", fn: func(ctx context.Context) (SearchResult, error) { return searchWithBrave(ctx, params.Query) }},
72
- namedSearch{name: "Tavily", fn: func(ctx context.Context) (SearchResult, error) { return searchWithTavily(ctx, params.Query, nil) }},
73
- namedSearch{name: "Exa", fn: func(ctx context.Context) (SearchResult, error) { return searchWithExa(ctx, params.Query, nil, "") }},
74
- )
81
+ searches = append(searches, alternativeSearches(config, params.Query)...)
82
+ if config.enabled("exa") {
83
+ searches = append(searches, namedSearch{name: "Exa", fn: func(ctx context.Context) (SearchResult, error) {
84
+ return searchWithExa(ctx, params.Query, nil, "")
85
+ }})
86
+ }
75
87
  }
76
88
 
77
- results := make([]providerDocs, len(searches))
78
- var wg sync.WaitGroup
79
- for i, s := range searches {
80
- wg.Add(1)
81
- go func(i int, s namedSearch) {
82
- defer wg.Done()
83
- ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
84
- defer cancel()
85
- res, err := s.fn(ctx)
86
- if err != nil {
87
- results[i] = providerDocs{Provider: s.name, Docs: []SearchDoc{}, Err: err}
88
- return
89
- }
90
- results[i] = providerDocs{Provider: s.name, Docs: res.Docs}
91
- }(i, s)
89
+ results := runSearches(searches)
90
+ if truncatedKeyword != "" && (len(searches) == 0 || (len(results) == 1 && results[0].Err != nil)) {
91
+ // Exa's includeText filter has no equivalent in Brave or Tavily. When Exa
92
+ // is disabled, unconfigured, or fails, search for the quoted phrase instead
93
+ // of making keyword searches unusable. Disclose the weaker semantics.
94
+ fallbackQuery := params.Query + " " + strconv.Quote(truncatedKeyword)
95
+ fallbackSearches := alternativeSearches(config, fallbackQuery)
96
+ if len(fallbackSearches) > 0 {
97
+ results = append(results, runSearches(fallbackSearches)...)
98
+ keywordFallback = true
99
+ } else if len(searches) == 0 && config.enabled("exa") {
100
+ // Preserve the helpful missing-key error if Exa is the only enabled
101
+ // provider and it has no key.
102
+ results = append(results, providerDocs{Provider: "Exa", Err: errors.New("missing EXA_API_KEY")})
103
+ }
92
104
  }
93
- wg.Wait()
94
-
95
- total := 0
96
- for _, r := range results {
97
- total += len(r.Docs)
105
+ if len(results) == 0 {
106
+ return "", errors.New("Error searching the web: no enabled search providers are available for this query; use 'webctx status' to inspect settings")
98
107
  }
108
+
109
+ total := countSearchDocs(results)
99
110
  if total == 0 {
100
111
  missingKeys := uniqueMissingCredentialKeys(results)
101
112
  if len(missingKeys) > 0 {
@@ -123,6 +134,9 @@ func Search(params SearchParams) (string, error) {
123
134
  }
124
135
 
125
136
  parts := []string{fmt.Sprintf("Total Results: %d\n", len(ranked))}
137
+ if keywordFallback {
138
+ parts = append(parts, "> **Keyword fallback:** Exa was disabled or unavailable. Brave/Tavily searched for the quoted phrase; this is not a strict page-text inclusion filter.", "")
139
+ }
126
140
  // The ranked index remains available, but agents also get useful source
127
141
  // context without having to issue several follow-up read-link calls.
128
142
  if context := enrichSearch(params.Query, ranked); context != "" {
@@ -144,6 +158,46 @@ func Search(params SearchParams) (string, error) {
144
158
  return strings.Join(parts, "\n"), nil
145
159
  }
146
160
 
161
+ func alternativeSearches(config providerConfig, query string) []namedSearch {
162
+ searches := []namedSearch{}
163
+ if config.enabled("brave") {
164
+ searches = append(searches, namedSearch{name: "Brave", fn: func(ctx context.Context) (SearchResult, error) { return searchWithBrave(ctx, query) }})
165
+ }
166
+ if config.enabled("tavily") {
167
+ searches = append(searches, namedSearch{name: "Tavily", fn: func(ctx context.Context) (SearchResult, error) { return searchWithTavily(ctx, query, nil) }})
168
+ }
169
+ return searches
170
+ }
171
+
172
+ func countSearchDocs(results []providerDocs) int {
173
+ count := 0
174
+ for _, r := range results {
175
+ count += len(r.Docs)
176
+ }
177
+ return count
178
+ }
179
+
180
+ func runSearches(searches []namedSearch) []providerDocs {
181
+ results := make([]providerDocs, len(searches))
182
+ var wg sync.WaitGroup
183
+ for i, s := range searches {
184
+ wg.Add(1)
185
+ go func(i int, s namedSearch) {
186
+ defer wg.Done()
187
+ ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
188
+ defer cancel()
189
+ res, err := s.fn(ctx)
190
+ if err != nil {
191
+ results[i] = providerDocs{Provider: s.name, Docs: []SearchDoc{}, Err: err}
192
+ return
193
+ }
194
+ results[i] = providerDocs{Provider: s.name, Docs: res.Docs}
195
+ }(i, s)
196
+ }
197
+ wg.Wait()
198
+ return results
199
+ }
200
+
147
201
  func ReadLink(rawURL string) (string, error) {
148
202
  if err := validateSourceURL(rawURL); err != nil {
149
203
  return "", err
@@ -338,7 +392,6 @@ func searchWithBrave(ctx context.Context, query string) (SearchResult, error) {
338
392
  params.Set("limit", "20")
339
393
  body, err := doRawRequest(ctx, http.MethodGet, "https://api.search.brave.com/res/v1/web/search?"+params.Encode(), map[string]string{
340
394
  "Accept": "application/json",
341
- "Accept-Encoding": "gzip",
342
395
  "x-subscription-token": apiKey,
343
396
  }, nil)
344
397
  if err != nil {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webctx",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Go CLI for agent-friendly web research, structured-data discovery, and source-native querying",
5
5
  "license": "MIT",
6
6
  "author": "amxv",
@@ -74,6 +74,7 @@ func TestAuditMCPRejectsZeroLimitAndInvalidSourceURLsWithoutExceptions(t *testin
74
74
  code string
75
75
  }{
76
76
  {"research", map[string]any{"mode": "catalogue", "view": "sources", "limit": 0}, "invalid_limit"},
77
+ {"map_site", map[string]any{"url": "https://docs.firecrawl.dev", "limit": 0}, "invalid_limit"},
77
78
  {"read_link", map[string]any{"url": "file:///etc/passwd", "question": "summarize"}, "invalid_url"},
78
79
  {"map_site", map[string]any{"url": "file:///etc/passwd"}, "invalid_url"},
79
80
  } {