webctx 0.2.4 → 0.2.6

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.
@@ -0,0 +1,444 @@
1
+ package app
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "io"
7
+ "net/http"
8
+ "strings"
9
+ "sync"
10
+ "testing"
11
+ )
12
+
13
+ func dataTestContract() map[string]any {
14
+ return map[string]any{
15
+ "id": "particle/podcasts/episodes/search",
16
+ "provider": "particle",
17
+ "capability": "podcasts/episodes/search",
18
+ "name": "Search podcast conversations",
19
+ "description": "Find episodes by what people discussed.",
20
+ "creditsCost": 15,
21
+ "perRecord": false,
22
+ "requiresOneOf": []any{[]any{"semantic_search", "keyword_search"}},
23
+ "options": []any{
24
+ map[string]any{"name": "semantic_search", "type": "string", "about": "Topic described in your own words."},
25
+ map[string]any{"name": "keyword_search", "type": "string", "about": "Exact transcript keyword."},
26
+ map[string]any{"name": "limit", "type": "number", "default": 25, "min": 1, "max": 100},
27
+ map[string]any{"name": "cursor", "type": "string", "about": "Opaque pagination token."},
28
+ },
29
+ "response": map[string]any{"key": "data", "paginated": true, "fields": []any{map[string]any{"name": "episode", "type": "object"}}},
30
+ }
31
+ }
32
+
33
+ type dataTestTransport struct {
34
+ mu sync.Mutex
35
+ executions int
36
+ lastHeaders http.Header
37
+ lastPayload map[string]any
38
+ blockPaid bool
39
+ }
40
+
41
+ func (d *dataTestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
42
+ if req.URL.Host != "api.firecrawl.dev" || req.Method != http.MethodPost {
43
+ return testHTTPResponse(req, http.StatusNotFound, "{}", nil), nil
44
+ }
45
+ body, err := io.ReadAll(req.Body)
46
+ if err != nil {
47
+ return nil, err
48
+ }
49
+ var payload map[string]any
50
+ if err := json.Unmarshal(body, &payload); err != nil {
51
+ return nil, err
52
+ }
53
+ d.mu.Lock()
54
+ d.lastPayload = payload
55
+ d.lastHeaders = req.Header.Clone()
56
+ d.mu.Unlock()
57
+ write := func(status int, content map[string]any) (*http.Response, error) {
58
+ b, _ := json.Marshal(content)
59
+ return testHTTPResponse(req, status, string(b), nil), nil
60
+ }
61
+ if req.URL.Path == "/v2/search" {
62
+ if strings.Contains(stringField(payload["query"]), "relevance ordering") {
63
+ low := dataTestContract()
64
+ low["id"] = "particle/podcasts/episodes/list"
65
+ low["similarity"] = 0.1
66
+ high := dataTestContract()
67
+ high["id"] = "particle/podcasts/episodes/search"
68
+ high["similarity"] = 0.97
69
+ middle := dataTestContract()
70
+ middle["id"] = "particle/podcasts/episode"
71
+ middle["similarity"] = 0.8
72
+ return write(200, map[string]any{"success": true, "data": map[string]any{"tools": []any{low, high, middle}}})
73
+ }
74
+ return write(200, map[string]any{"success": true, "data": map[string]any{
75
+ "tools": []any{dataTestContract()},
76
+ }})
77
+ }
78
+ if req.URL.Path != "/v2/scrape" {
79
+ return write(404, map[string]any{"success": false})
80
+ }
81
+ if operation := mapField(payload["alexandria"]); operation != nil &&
82
+ operation["provider"] == "firecrawl" && operation["capability"] == "find-tools" {
83
+ opts := mapField(operation["options"])
84
+ if opts["level"] == "providers" {
85
+ return write(200, map[string]any{"success": true, "data": map[string]any{
86
+ "alexandria": []any{map[string]any{"provider": "firecrawl", "capability": "find-tools", "creditsCost": 0, "data": map[string]any{
87
+ "level": "providers", "total": 3,
88
+ "items": []any{map[string]any{"id": "particle", "name": "Particle", "description": "Podcast data", "toolCount": 40}},
89
+ "next": map[string]any{"provider": "firecrawl", "capability": "find-tools", "options": map[string]any{
90
+ "level": "providers", "limit": 2, "offset": 2,
91
+ }},
92
+ }}},
93
+ }})
94
+ }
95
+ return write(200, map[string]any{"success": true, "data": map[string]any{
96
+ "alexandria": []any{map[string]any{"provider": "firecrawl", "capability": "find-tools", "creditsCost": 0, "data": map[string]any{
97
+ "level": "tools", "total": 1, "items": []any{dataTestContract()},
98
+ }}},
99
+ }})
100
+ }
101
+ if calls := listField(payload["alexandria"]); len(calls) > 0 {
102
+ d.mu.Lock()
103
+ d.executions++
104
+ d.mu.Unlock()
105
+ if d.blockPaid {
106
+ return write(403, map[string]any{
107
+ "success": false, "code": "THIRD_PARTY_DATA_TERMS_REQUIRED",
108
+ "error": "Provider terms have not been accepted.",
109
+ "requiresAction": map[string]any{"url": "https://firecrawl.dev/app/settings?tab=data-sources"},
110
+ })
111
+ }
112
+ var results []any
113
+ var total int
114
+ for _, value := range calls {
115
+ call := mapField(value)
116
+ results = append(results, map[string]any{
117
+ "provider": call["provider"], "capability": call["capability"],
118
+ "creditsCost": 15, "alexandriaId": "paid-data-id-1",
119
+ "data": map[string]any{"data": []any{map[string]any{"episode": map[string]any{"id": "ep_1"}}}, "next_cursor": "after-1"},
120
+ })
121
+ total += 15
122
+ }
123
+ return write(200, map[string]any{
124
+ "success": true, "data": map[string]any{"alexandria": results, "creditsCost": total},
125
+ })
126
+ }
127
+ return write(400, map[string]any{"success": false, "error": "Bad test request."})
128
+ }
129
+
130
+ func TestResearchSortsMatchesByActualSimilarity(t *testing.T) {
131
+ installDataTestTransport(t)
132
+ results, err := ResearchData(ResearchDataInput{Query: "relevance ordering", Limit: 3})
133
+ if err != nil {
134
+ t.Fatal(err)
135
+ }
136
+ found := results["results"].([]map[string]any)
137
+ if found[0]["id"] != "particle/podcasts/episodes/search" ||
138
+ found[1]["id"] != "particle/podcasts/episode" ||
139
+ found[2]["id"] != "particle/podcasts/episodes/list" {
140
+ t.Fatalf("results were not ordered by reported relevance: %#v", found)
141
+ }
142
+ }
143
+
144
+ func installDataTestTransport(t *testing.T) *dataTestTransport {
145
+ t.Helper()
146
+ old := http.DefaultClient
147
+ t.Cleanup(func() { http.DefaultClient = old })
148
+ d := &dataTestTransport{}
149
+ http.DefaultClient = &http.Client{Transport: d}
150
+ t.Setenv("FIRECRAWL_API_KEY", "test-key")
151
+ t.Setenv("WEBCTX_ALEXANDRIA_MAX_CREDITS", "100")
152
+ return d
153
+ }
154
+
155
+ func TestProgressiveDataDiscoveryAndExecution(t *testing.T) {
156
+ mock := installDataTestTransport(t)
157
+ found, err := ResearchData(ResearchDataInput{Query: "podcast conversations about AI agents", Limit: 2})
158
+ if err != nil {
159
+ t.Fatal(err)
160
+ }
161
+ if found["cost_credits"] != 0 || found["mode"] != "ranked_search" {
162
+ t.Fatalf("ranked discovery not free: %#v", found)
163
+ }
164
+ results := found["results"].([]map[string]any)
165
+ id := results[0]["id"].(string)
166
+ if id != "particle/podcasts/episodes/search" {
167
+ t.Fatalf("wrong discovery ID: %s", id)
168
+ }
169
+ next := mapField(results[0]["next"])
170
+ if next["tool"] != "inspect" || mapField(next["arguments"])["id"] != id {
171
+ t.Fatalf("discovery did not provide inspect arguments: %#v", next)
172
+ }
173
+ details, err := InspectData(InspectDataInput{ID: id})
174
+ if err != nil {
175
+ t.Fatal(err)
176
+ }
177
+ if mapField(details["price"])["credits_per_call"] != float64(15) {
178
+ t.Fatalf("missing pricing: %#v", details)
179
+ }
180
+ if len(listField(mapField(details["inputs"])["fields"])) != 4 {
181
+ t.Fatalf("missing upstream contract fields: %#v", details["inputs"])
182
+ }
183
+ generated := listField(details["examples"])
184
+ if len(generated) == 0 || mapField(generated[0])["valid_input_shape"] != true {
185
+ t.Fatalf("inspection must generate an explicitly labeled, schema-valid example: %#v", details["examples"])
186
+ }
187
+ continued := mapField(details["next"])
188
+ if continued["tool"] != "execute" {
189
+ t.Fatalf("no next execution: %#v", continued)
190
+ }
191
+ args := mapField(continued["arguments"])
192
+ skeleton := listField(args["calls"])
193
+ if len(skeleton) != 1 {
194
+ t.Fatalf("invalid next argument shape: %#v", args)
195
+ }
196
+ inspectedCall := mapField(skeleton[0])
197
+ if inspectedCall == nil || inspectedCall["id"] != id || mapField(inspectedCall["inputs"])["semantic_search"] != nil {
198
+ t.Fatalf("invalid template: %#v", skeleton)
199
+ }
200
+ got, err := ExecuteData(ExecuteDataInput{
201
+ Calls: []DataCall{{ID: id, Inputs: map[string]any{"semantic_search": "AI agents", "limit": float64(2)}}},
202
+ })
203
+ if err != nil {
204
+ t.Fatal(err)
205
+ }
206
+ if got["credits_used"] != float64(15) {
207
+ t.Fatalf("credits missing: %#v", got)
208
+ }
209
+ executions := got["results"].([]map[string]any)
210
+ if executions[0]["data_id"] != "paid-data-id-1" {
211
+ t.Fatalf("provider provenance missing: %#v", got)
212
+ }
213
+ followup := mapField(executions[0]["next"])
214
+ if followup["tool"] != "execute" {
215
+ t.Fatalf("missing cursor continuation: %#v", followup)
216
+ }
217
+ followArgs := mapField(followup["arguments"])
218
+ nextCall := mapField(listField(followArgs["calls"])[0])
219
+ if mapField(nextCall["inputs"])["cursor"] != "after-1" {
220
+ t.Fatalf("pagination lost filters: %#v", nextCall)
221
+ }
222
+ mock.mu.Lock()
223
+ defer mock.mu.Unlock()
224
+ if mock.executions != 1 || !safeRequestID.MatchString(got["request_id"].(string)) ||
225
+ mock.lastHeaders.Get("x-request-id") != got["request_id"].(string) {
226
+ t.Fatalf("request not paid once with stable ID: executions=%d req=%s", mock.executions, got["request_id"])
227
+ }
228
+ }
229
+
230
+ func TestCatalogueBrowsingReturnsExactPaginationCall(t *testing.T) {
231
+ installDataTestTransport(t)
232
+ result, err := ResearchData(ResearchDataInput{View: "sources", Limit: 2})
233
+ if err != nil {
234
+ t.Fatal(err)
235
+ }
236
+ if result["view"] != "sources" || result["total"] != float64(3) {
237
+ t.Fatalf("bad catalogue: %#v", result)
238
+ }
239
+ next := mapField(result["next"])
240
+ args := mapField(next["arguments"])
241
+ if next["tool"] != "research" || args["offset"] != float64(2) || args["view"] != "sources" || args["mode"] != "catalogue" {
242
+ t.Fatalf("invalid pagination continuation: %#v", next)
243
+ }
244
+ fromSource, err := InspectData(InspectDataInput{ID: "particle"})
245
+ if err != nil {
246
+ t.Fatal(err)
247
+ }
248
+ if fromSource["view"] != "operations" {
249
+ t.Fatalf("provider exploration failed: %#v", fromSource)
250
+ }
251
+ }
252
+
253
+ func TestExecutionValidationAndServerLimitRejectBeforePaidCall(t *testing.T) {
254
+ mock := installDataTestTransport(t)
255
+ id := "particle/podcasts/episodes/search"
256
+ for _, testCase := range []struct {
257
+ inputs map[string]any
258
+ code string
259
+ }{
260
+ {map[string]any{}, "invalid_inputs"},
261
+ {map[string]any{"semantic_search": "AI agents", "limit": 101}, "invalid_inputs"},
262
+ {map[string]any{"semantic_search": "AI agents", "unknown_field": 123}, "invalid_inputs"},
263
+ } {
264
+ _, err := ExecuteData(ExecuteDataInput{Calls: []DataCall{{ID: id, Inputs: testCase.inputs}}})
265
+ typed, ok := err.(*DataError)
266
+ if !ok || typed.Code != testCase.code {
267
+ t.Fatalf("wanted %s, got %v", testCase.code, err)
268
+ }
269
+ }
270
+ mock.mu.Lock()
271
+ defer mock.mu.Unlock()
272
+ if mock.executions != 0 {
273
+ t.Fatalf("invalid calls charged credits: %d", mock.executions)
274
+ }
275
+ }
276
+
277
+ func TestExecuteHandlesProviderTermsWithoutAccepting(t *testing.T) {
278
+ mock := installDataTestTransport(t)
279
+ mock.blockPaid = true
280
+ _, err := ExecuteData(ExecuteDataInput{Calls: []DataCall{{
281
+ ID: "particle/podcasts/episodes/search",
282
+ Inputs: map[string]any{"semantic_search": "AI agents"},
283
+ }}})
284
+ typed, ok := err.(*DataError)
285
+ if !ok || typed.Code != "THIRD_PARTY_DATA_TERMS_REQUIRED" ||
286
+ mapField(typed.RequiresAction)["url"] != "https://firecrawl.dev/app/settings?tab=data-sources" {
287
+ t.Fatalf("terms condition lost: %+v", err)
288
+ }
289
+ if mapField(typed.Details)["request_id"] == nil {
290
+ t.Fatalf("lost retriable request identifier: %+v", typed)
291
+ }
292
+ }
293
+
294
+ func TestCLIResearchAndInspectJSON(t *testing.T) {
295
+ installDataTestTransport(t)
296
+ var out, errBuf bytes.Buffer
297
+ if code := Run([]string{"research", "podcasts about AI agents", "--limit", "2", "--raw"}, &out, &errBuf); code != 0 {
298
+ t.Fatalf("CLI research failed: %s", errBuf.String())
299
+ }
300
+ var result map[string]any
301
+ if json.Unmarshal(out.Bytes(), &result) != nil || mapField(result["raw"]) == nil {
302
+ t.Fatalf("CLI JSON or --raw missing: %s", out.String())
303
+ }
304
+ out.Reset()
305
+ if code := Run([]string{"inspect", "particle/podcasts/episodes/search"}, &out, &errBuf); code != 0 {
306
+ t.Fatalf("CLI inspect failed: %s", errBuf.String())
307
+ }
308
+ if !strings.Contains(out.String(), "semantic_search") || strings.Contains(out.String(), "max_credits") {
309
+ t.Fatalf("CLI inspect omitted exact execute syntax: %s", out.String())
310
+ }
311
+ }
312
+
313
+ func TestProviderNextOffsetCreatesCopyReadyContinuation(t *testing.T) {
314
+ id := "fred-stlouisfed-org/economic-data/series_observations"
315
+ inputs := map[string]any{"series_id": "CPIAUCSL", "limit": 2.0, "offset": 0.0}
316
+ next := continuationForDataResult(id, inputs, map[string]any{"next_offset": 2.0, "count": 956.0})
317
+ if mapField(next)["tool"] != "execute" {
318
+ t.Fatalf("expected executable pagination: %+v", next)
319
+ }
320
+ args := mapField(mapField(next)["arguments"])
321
+ call := mapField(listField(args["calls"])[0])
322
+ fields := mapField(call["inputs"])
323
+ if fields["offset"] != 2 || fields["series_id"] != "CPIAUCSL" {
324
+ t.Fatalf("next offset dropped exact source inputs: %+v", fields)
325
+ }
326
+ if original, _ := numberField(inputs["offset"]); original != 0 {
327
+ t.Fatal("original inputs mutated")
328
+ }
329
+ }
330
+
331
+ func TestPerRecordPricingRequiresKnownBound(t *testing.T) {
332
+ contract := map[string]any{"creditsCost": float64(5), "perRecord": true, "options": []any{}}
333
+ if n, err := expectedCallCredits(contract, map[string]any{"limit": float64(3)}); err != nil || n != 15 {
334
+ t.Fatalf("bad predicted per-record charge: %d %v", n, err)
335
+ }
336
+ if _, err := expectedCallCredits(contract, map[string]any{}); err == nil {
337
+ t.Fatal("unknown record count must not trigger unbounded paid call")
338
+ }
339
+ }
340
+
341
+ func TestNumericEnumsMayArriveAsUpstreamStrings(t *testing.T) {
342
+ contract := map[string]any{"id": "example/enum", "options": []any{
343
+ map[string]any{"name": "year", "type": "number", "required": true, "oneOf": []any{"2021", "2023"}},
344
+ }}
345
+ if err := validateDataInputs(contract, map[string]any{"year": float64(2023)}); err != nil {
346
+ t.Fatalf("upstream numeric enum from strings should work: %v", err)
347
+ }
348
+ if err := validateDataInputs(contract, map[string]any{"year": float64(2026)}); err == nil {
349
+ t.Fatal("unlisted numeric value must be rejected")
350
+ }
351
+ }
352
+
353
+ func TestInspectOnlyOutputStillHasExecuteInputTemplate(t *testing.T) {
354
+ installDataTestTransport(t)
355
+ details, err := InspectData(InspectDataInput{ID: "particle/podcasts/episodes/search", Include: []string{"output"}})
356
+ if err != nil {
357
+ t.Fatal(err)
358
+ }
359
+ if mapField(details["input_template"])["semantic_search"] != nil || len(listField(mapField(details["inputs"])["fields"])) != 4 {
360
+ t.Fatalf("missing necessary next-call schema after include=output: %+v", details)
361
+ }
362
+ }
363
+
364
+ func TestServerManagedCreditCeilingCannotBeOverridden(t *testing.T) {
365
+ mock := installDataTestTransport(t)
366
+ // The ceiling defaults to 200 and an invalidly high environment value
367
+ // cannot silently widen it beyond 200.
368
+ t.Setenv("WEBCTX_ALEXANDRIA_MAX_CREDITS", "200")
369
+ if got := dataCreditCap(); got != 200 {
370
+ t.Fatalf("bad production cap: %d", got)
371
+ }
372
+ t.Setenv("WEBCTX_ALEXANDRIA_MAX_CREDITS", "2000")
373
+ if got := dataCreditCap(); got != 200 {
374
+ t.Fatalf("server cap exceeded 200: %d", got)
375
+ }
376
+ t.Setenv("WEBCTX_ALEXANDRIA_MAX_CREDITS", "120")
377
+ if got := dataCreditCap(); got != 120 {
378
+ t.Fatalf("server environment not respected: %d", got)
379
+ }
380
+ id := "particle/podcasts/episodes/search"
381
+ validInputs := map[string]any{"semantic_search": "AI agents"}
382
+ // 9*15=135: reject before ANY provider is queried.
383
+ batch := make([]DataCall, 9)
384
+ for i := range batch {
385
+ batch[i] = DataCall{ID: id, Inputs: validInputs}
386
+ }
387
+ _, err := ExecuteData(ExecuteDataInput{Calls: batch})
388
+ typed, ok := err.(*DataError)
389
+ if !ok || typed.Code != "server_credit_limit_exceeded" ||
390
+ mapField(typed.Details)["estimated_credits"] != 135 {
391
+ t.Fatalf("expected server-only limit, got %#v", err)
392
+ }
393
+ mock.mu.Lock()
394
+ defer mock.mu.Unlock()
395
+ if mock.executions != 0 {
396
+ t.Fatalf("server limit failed to prevent execution, calls=%d", mock.executions)
397
+ }
398
+ }
399
+
400
+ func TestClientCreditControlIsNotAvailableInCLI(t *testing.T) {
401
+ installDataTestTransport(t)
402
+ var stdout, stderr bytes.Buffer
403
+ code := Run([]string{"execute", "particle/podcasts/episodes/search", "--inputs", `{"semantic_search":"AI agents"}`, "--max-credits", "1"}, &stdout, &stderr)
404
+ if code != 1 || !strings.Contains(stderr.String(), `"code": "unsupported_flag"`) {
405
+ t.Fatalf("old client credit option should fail clearly, status=%d error=%s", code, stderr.String())
406
+ }
407
+ }
408
+
409
+ func TestCPIInterpretationDoesNotConfuseIndexWithInflation(t *testing.T) {
410
+ levels := dataMeasurementNote(map[string]any{"series_id": "CPIAUCSL", "units": "lin"})
411
+ if !strings.Contains(levels, "index levels") || !strings.Contains(levels, "not annual inflation percentages") {
412
+ t.Fatalf("CPI level explanation missing: %s", levels)
413
+ }
414
+ percentage := dataMeasurementNote(map[string]any{"series_id": "CPIAUCSL", "units": "pc1"})
415
+ if !strings.Contains(percentage, "percent change from one year ago") {
416
+ t.Fatalf("transformation note missing: %s", percentage)
417
+ }
418
+ if dataMeasurementNote(map[string]any{"series_id": "UNRATE", "units": "lin"}) != "" {
419
+ t.Fatal("unemployment must not get CPI-specific semantics")
420
+ }
421
+ }
422
+
423
+ func TestGeneratedFREDExampleUsesDocumentedSeries(t *testing.T) {
424
+ c := map[string]any{
425
+ "id": "fred-stlouisfed-org/economic-data/series_observations",
426
+ "options": []any{
427
+ map[string]any{"name": "series_id", "type": "string", "required": true, "about": "FRED series id, e.g. GDP, UNRATE and CPIAUCSL"},
428
+ map[string]any{"name": "limit", "type": "number", "default": 1000, "min": 1, "max": 10000},
429
+ },
430
+ }
431
+ examples := listField(contractExamples(c))
432
+ if len(examples) != 1 {
433
+ t.Fatalf("no fallback example: %#v", examples)
434
+ }
435
+ example := mapField(examples[0])
436
+ inputs := mapField(example["inputs"])
437
+ if example["kind"] != "generated_from_inspected_schema" || example["valid_input_shape"] != true ||
438
+ inputs["series_id"] != "CPIAUCSL" || inputs["limit"] != 5 {
439
+ t.Fatalf("unexpected generated FRED sample: %#v", examples)
440
+ }
441
+ if mapField(example["next"])["tool"] != "execute" {
442
+ t.Fatalf("generated example cannot be copied into execute: %#v", example)
443
+ }
444
+ }
@@ -0,0 +1,39 @@
1
+ package app
2
+
3
+ // ResearchData discovers data sources and their available operations. Search
4
+ // and catalogue browsing are both free Firecrawl Alexandria operations.
5
+ type ResearchDataInput struct {
6
+ Query string `json:"query,omitempty" jsonschema:"Optional natural-language description of the information needed"`
7
+ Mode string `json:"mode,omitempty" jsonschema:"ranked semantic discovery or catalogue browsing with filters and pagination; defaults to ranked for a query"`
8
+ URLs []string `json:"urls,omitempty" jsonschema:"Filter by websites associated with a data source"`
9
+ Sources []string `json:"sources,omitempty" jsonschema:"Filter by source IDs returned by research"`
10
+ Categories []string `json:"categories,omitempty" jsonschema:"Filter by catalogue category IDs"`
11
+ Groups []string `json:"groups,omitempty" jsonschema:"Filter by catalogue group IDs"`
12
+ Operations []string `json:"operations,omitempty" jsonschema:"Filter by exact operation IDs"`
13
+ View string `json:"view,omitempty" jsonschema:"What to browse: sources, groups, or operations; default operations for a query, otherwise sources"`
14
+ Include []string `json:"include,omitempty" jsonschema:"Additional contract details: inputs, output, examples. Default is a concise overview"`
15
+ Limit int `json:"limit,omitempty" jsonschema:"Maximum results in this page, 1-100; default 10"`
16
+ Offset int `json:"offset,omitempty" jsonschema:"Catalogue pagination offset, zero-based"`
17
+ Raw bool `json:"raw,omitempty" jsonschema:"Also include the complete original Firecrawl response"`
18
+ }
19
+
20
+ // InspectDataInput returns the exact contract for a selected operation, or
21
+ // browses all operations of a source when passed just a source ID.
22
+ type InspectDataInput struct {
23
+ ID string `json:"id" jsonschema:"Source or operation ID returned by research, e.g. particle/podcasts/episodes/search"`
24
+ Include []string `json:"include,omitempty" jsonschema:"Contract sections: inputs, output, examples; all by default"`
25
+ Raw bool `json:"raw,omitempty" jsonschema:"Include the complete original Firecrawl response"`
26
+ }
27
+
28
+ type DataCall struct {
29
+ ID string `json:"id" jsonschema:"Exact operation ID returned by research or inspect"`
30
+ Inputs map[string]any `json:"inputs,omitempty" jsonschema:"Provider-specific input fields copied from the inspected contract"`
31
+ }
32
+
33
+ // ExecuteDataInput supports up to ten calls in a single provider request.
34
+ // It validates each call against the free catalogue before using credits.
35
+ type ExecuteDataInput struct {
36
+ Calls []DataCall `json:"calls" jsonschema:"One to ten operation calls; inspect each operation to discover exact input field names"`
37
+ RequestID string `json:"request_id,omitempty" jsonschema:"Optional stable idempotency identifier for retrying a paid request; reuse unchanged if retrying"`
38
+ Raw bool `json:"raw,omitempty" jsonschema:"Include the complete original provider response"`
39
+ }
@@ -87,6 +87,7 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
87
87
  if err != nil {
88
88
  return "", err
89
89
  }
90
+ primary = copyableCurlExamples(primary)
90
91
 
91
92
  // Combine on-page links and authoritative Markdown index links instead of
92
93
  // filling the entire budget from whichever links happen to appear first.
@@ -109,7 +110,11 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
109
110
  "**Source:** " + rawURL,
110
111
  "",
111
112
  }
112
- primaryExcerpt := focusExcerpt(primary, question, focusedPrimaryBudget)
113
+ primaryBudget := focusedPrimaryBudget
114
+ if asksForAPIContracts(question) {
115
+ primaryBudget = 5300
116
+ }
117
+ primaryExcerpt := focusExcerpt(primary, question, primaryBudget)
113
118
  parts = append(parts, primaryExcerpt)
114
119
  successful := 1
115
120
  relatedSuccessful := 0
@@ -124,7 +129,11 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
124
129
  relatedSuccessful++
125
130
  seenURLs[source.url] = true
126
131
  allRetrieved += "\n" + source.content
127
- excerpt := focusExcerpt(source.content, question, relatedExcerptBudget(source.url))
132
+ relatedBudget := relatedExcerptBudget(source.url)
133
+ if asksForAPIContracts(question) && relatedBudget > 3000 {
134
+ relatedBudget = 3000
135
+ }
136
+ excerpt := focusExcerpt(copyableCurlExamples(source.content), question, relatedBudget)
128
137
  outputEvidence += "\n" + excerpt
129
138
  parts = append(parts, "", "## Related source", "**Source:** "+source.url, "", excerpt)
130
139
  }
@@ -172,7 +181,11 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
172
181
  successful++
173
182
  relatedSuccessful++
174
183
  allRetrieved += "\n" + source.content
175
- excerpt := focusExcerpt(source.content, question, relatedExcerptBudget(source.url))
184
+ followupBudget := relatedExcerptBudget(source.url)
185
+ if asksForAPIContracts(question) && followupBudget > 3000 {
186
+ followupBudget = 3000
187
+ }
188
+ excerpt := focusExcerpt(copyableCurlExamples(source.content), question, followupBudget)
176
189
  outputEvidence += "\n" + excerpt
177
190
  parts = append(parts, "", "## Supporting implementation reference", "**Source:** "+source.url, "", excerpt)
178
191
  }
@@ -181,7 +194,10 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
181
194
  if contract := <-openAPIDone; contract.Content != "" {
182
195
  successful++
183
196
  outputEvidence += "\n" + contract.Content
184
- parts = append(parts, "", "## REST API contract", "**Source:** "+contract.URL, "", contract.Content)
197
+ // Put exact REST contracts first so coding agents encounter the
198
+ // implementation-ready operations before lengthy source excerpts.
199
+ front := []string{"## REST API contract", "**Source:** " + contract.URL, "", contract.Content, ""}
200
+ parts = append(append(append([]string(nil), parts[:4]...), front...), parts[4:]...)
185
201
  }
186
202
  }
187
203
  if structured := <-structuredDone; structured != "" {
@@ -0,0 +1,48 @@
1
+ package app
2
+
3
+ import (
4
+ "encoding/json"
5
+ "regexp"
6
+ "strings"
7
+ )
8
+
9
+ var trailingPayloadSlash = regexp.MustCompile("(?m)\\\\[ \\t]*(\\r?\\n)")
10
+
11
+ // Some source scrapers leave JavaScript-style line-continuation slashes
12
+ // inside shell-quoted JSON. Those slashes make the published cURL request
13
+ // uncopyable. Repair only payloads that are initially invalid JSON and become
14
+ // valid JSON after removing those continuation markers. Label every repair.
15
+ // The original source URL remains in the surrounding cited context.
16
+ func copyableCurlExamples(markdown string) string {
17
+ parts := strings.Split(markdown, backtickFence)
18
+ if len(parts) < 3 {
19
+ return markdown
20
+ }
21
+ for i := 1; i < len(parts); i += 2 {
22
+ code := parts[i]
23
+ if !strings.Contains(code, "curl ") {
24
+ continue
25
+ }
26
+ start := strings.Index(code, "-d '{")
27
+ if start < 0 {
28
+ continue
29
+ }
30
+ bodyStart := start + len("-d '")
31
+ endRel := strings.LastIndex(code[bodyStart:], "}'")
32
+ if endRel < 0 {
33
+ continue
34
+ }
35
+ bodyEnd := bodyStart + endRel + 1
36
+ original := code[bodyStart:bodyEnd]
37
+ if json.Valid([]byte(original)) {
38
+ continue
39
+ }
40
+ fixed := trailingPayloadSlash.ReplaceAllString(original, "$1")
41
+ if fixed == original || !json.Valid([]byte(fixed)) {
42
+ continue
43
+ }
44
+ parts[i] = code[:bodyStart] + fixed + code[bodyEnd:]
45
+ parts[i-1] += "\n\n*[Copy-ready correction: removed stray line-continuation backslashes inside the published JSON payload; see the cited URL for the original.]*\n"
46
+ }
47
+ return strings.Join(parts, backtickFence)
48
+ }
@@ -0,0 +1,57 @@
1
+ package app
2
+
3
+ import (
4
+ "encoding/json"
5
+ "strings"
6
+ "testing"
7
+ )
8
+
9
+ func TestMalformedAlexandriaJSONBecomesCopyable(t *testing.T) {
10
+ source := "# REST example\n" + backtickFence + "bash\n" +
11
+ "curl https://api.firecrawl.dev/v2/search \\\n" +
12
+ " -H 'Content-Type: application/json' \\\n" +
13
+ " -d '{\n \"query\": \"podcast conversations\",\n \"sources\": [\\\n \"web\",\\\n \"alexandria\"\\\n ],\n \"limit\": 2\n}'\n" +
14
+ backtickFence
15
+ got := copyableCurlExamples(source)
16
+ if !strings.Contains(got, "Copy-ready correction") {
17
+ t.Fatalf("lost truthful correction label: %s", got)
18
+ }
19
+ start := strings.Index(got, "-d '{")
20
+ if start < 0 {
21
+ t.Fatalf("lost source cURL: %s", got)
22
+ }
23
+ bodyStart := start + len("-d '")
24
+ bodyEnd := bodyStart + strings.LastIndex(got[bodyStart:], "}'") + 1
25
+ if !json.Valid([]byte(got[bodyStart:bodyEnd])) {
26
+ t.Fatalf("JSON request still uncopyable: %s", got[bodyStart:bodyEnd])
27
+ }
28
+ if !strings.Contains(got, " -H 'Content-Type: application/json' \\") {
29
+ t.Fatalf("source shell continuations should be preserved: %s", got)
30
+ }
31
+ if strings.Contains(got, "\"sources\": [\\") {
32
+ t.Fatalf("malformed JSON continuation not repaired: %s", got)
33
+ }
34
+ if copyableCurlExamples(got) != got {
35
+ t.Fatalf("correction should be idempotent")
36
+ }
37
+ }
38
+
39
+ func TestCorrectSourceExamplesAreNeverRewritten(t *testing.T) {
40
+ source := backtickFence + "bash\ncurl https://api.example.com/v2/search -d '{\"sources\":[\"alexandria\"]}'\n" + backtickFence
41
+ if got := copyableCurlExamples(source); got != source {
42
+ t.Fatalf("valid published snippet was modified: %s", got)
43
+ }
44
+ }
45
+
46
+ func TestRESTContractsLeadAndExcerptsStayConcise(t *testing.T) {
47
+ if focusedPrimaryBudget <= 5300 {
48
+ t.Fatal("REST excerpt budget should be narrower than normal focused context")
49
+ }
50
+ source := "# Official API docs\n" + strings.Repeat("Large unrelated introduction about navigation.\n", 700) +
51
+ "\n## REST API endpoint example\n" +
52
+ backtickFence + "bash\ncurl https://api.example.com/v2/search -d '{\"sources\":[\"alexandria\"]}'\n" + backtickFence
53
+ got := focusExcerpt(source, "What are the REST API endpoints and exact request examples?", 5300)
54
+ if len(got) > 6000 || !strings.Contains(got, "/v2/search") {
55
+ t.Fatalf("REST excerpt did not prioritize complete example under budget: len=%d", len(got))
56
+ }
57
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "webctx",
3
- "version": "0.2.4",
4
- "description": "Pure Go web search and browsing CLI using Brave, Tavily, Exa, and Firecrawl",
3
+ "version": "0.2.6",
4
+ "description": "Go CLI for agent-friendly web research, structured-data discovery, and source-native querying",
5
5
  "license": "MIT",
6
6
  "author": "amxv",
7
7
  "repository": {