webctx 0.2.7 → 0.2.8

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.
@@ -89,6 +89,7 @@ func Run(args []string, stdout, stderr io.Writer) int {
89
89
  result, e := MapSitePage(MapSiteInput{
90
90
  URL: input, Limit: limit, Offset: offset, Query: flags["query"],
91
91
  PathPrefix: flags["path-prefix"], Language: flags["language"],
92
+ limitProvided: flags["limit"] != "",
92
93
  })
93
94
  if e != nil {
94
95
  return cliDataError(stderr, e)
@@ -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
  }
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webctx",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
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
  } {