webctx 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CONTRIBUTORS.md CHANGED
@@ -97,7 +97,7 @@ gh workflow run npm-publish-retry.yml --repo amxv/webctx --ref main -f version=0
97
97
  - `cmd/webctx/main.go`: CLI entrypoint
98
98
  - `internal/app/`: CLI parsing, search, ranking, scrape, env loading, and Firecrawl queue logic
99
99
  - `pkg/retrieval/`: small public adapter to the shared read-link / site-map engine
100
- - `pkg/origo/`: authenticated, stateless MCP server with exactly two tools
100
+ - `pkg/origo/`: authenticated, stateless MCP server with five tools (including structured-data discovery, inspection and execution)
101
101
  - `api/mcp.go`: Vercel Go Function entrypoint for Origo
102
102
  - `vercel.mjs`: project-specific Vercel settings for docs and Origo
103
103
  - `internal/buildinfo/`: build-time version plumbing for `--version`
package/README.md CHANGED
@@ -2,12 +2,15 @@
2
2
 
3
3
  Web context for agents, from the terminal.
4
4
 
5
- `webctx` gives you three small commands:
5
+ `webctx` provides six commands: three for web documents and three for structured data.
6
6
 
7
7
  ```bash
8
8
  webctx search "agent web research"
9
9
  webctx read-link <url>
10
10
  webctx map-site <url>
11
+ webctx research "podcast conversations about AI agents"
12
+ webctx inspect particle/podcasts/episodes/search
13
+ webctx execute particle/podcasts/episodes/search --inputs '{"semantic_search":"AI agents","limit":2}' --max-credits 20
11
14
  ```
12
15
 
13
16
  The output is plain text or markdown, so it is easy to hand to ChatGPT, Codex, Claude Code, a shell script, or another tool. Search now retrieves relevant source excerpts automatically instead of returning only URLs. For a specific URL, add an optional question to explore related documentation and surface exact code and API details.
@@ -19,11 +22,25 @@ webctx read-link https://docs.example.com/api --question "How do authentication
19
22
 
20
23
  ## Origo: hosted MCP access
21
24
 
22
- **Origo** is the MCP transport over the same WebCTX retrieval engine. Connect an MCP client to `https://api.origo.ashray.xyz/mcp?key=<your-private-key>` to access exactly two tools: `read_link` and `map_site`. Origo does not expose `search`; the CLI continues to provide all three commands. `read_link` accepts an optional `question` to retrieve focused source context while keeping ordinary URL-only reads intact.
25
+ **Origo** is the MCP transport over the same WebCTX retrieval engine. Connect an MCP client to `https://api.origo.ashray.xyz/mcp?key=<your-private-key>` to access five tools: `read_link`, `map_site`, `research`, `inspect`, and `execute`. Origo deliberately omits federated web `search` but exposes the complete progressive data-research workflow. `read_link` accepts an optional `question` and keeps URL-only behavior unchanged.
23
26
 
24
27
  Both surfaces share native GitHub/Markdown readers and Firecrawl fallback behavior. Firecrawl's built-in cache is limited to **30 minutes**. Difficult pages automatically escalate through Firecrawl's proxy fallback and, if necessary, a temporary Browser Sandbox session. There is no Redis or additional cache database.
25
28
 
26
- Source-aware reading also prefers raw JSON, YAML, and OpenAPI files when the URL identifies one; question-focused reads can follow up to two relevant pages on the same host, including links found in `llms.txt`, and inspect embedded structured JSON. Firecrawl Alexandria tool matching and search discovery are automatic when supported, but **no paid Alexandria provider tools are executed**. The original source URLs remain explicit so an agent can verify facts.
29
+ Source-aware reading also prefers raw JSON, YAML, and OpenAPI files when the URL identifies one; question-focused reads can follow relevant references, including links found in `llms.txt`, and inspect embedded structured JSON. The new `research`, `inspect`, and `execute` commands give agents direct control over Firecrawl Alexandria's catalogue and paid data operations without learning provider-specific transport syntax. The original source URLs remain explicit so an agent can verify facts.
30
+
31
+ ## Structured data: discover → inspect → execute
32
+
33
+ `research` discovers datasets and operations by meaning, website, source, category, or group. `inspect` retrieves exact parameter types, required fields, response shapes, pricing, and copy-ready `execute` arguments. `execute` validates the inputs and credit estimate before calling one or more data operations, and preserves their source-native JSON and pagination.
34
+
35
+ ```bash
36
+ webctx research "podcast conversations about AI agents" --limit 5
37
+ webctx research --view sources --limit 5
38
+ webctx research --mode catalogue --view operations --sources particle --limit 10
39
+ webctx inspect particle/podcasts/episodes/search
40
+ webctx execute particle/podcasts/episodes/search --inputs '{"semantic_search":"AI agents","limit":2}' --max-credits 20
41
+ ```
42
+
43
+ Browse [the structured-data guide](https://webctx.ashray.xyz/docs/data-sources) for filters, paging, batches, full upstream responses, validation, and credit handling. Discovery/inspection is free; `execute` uses paid Alexandria credits. The per-request default preflight cap is 100 credits (configurable via `WEBCTX_ALEXANDRIA_MAX_CREDITS`). Third-party provider terms require separate human acceptance; Origo never accepts them automatically.
27
44
 
28
45
  Origo deploys independently through the `origo-api` Vercel project on pushes to `main`. CLI releases and npm publishing are unchanged. See the [Origo MCP guide](https://webctx.ashray.xyz/docs/origo) for architecture, setup, and security notes.
29
46
 
@@ -78,7 +95,7 @@ Recognized native GitHub auth, private/not-found, and rate-limit failures stay a
78
95
 
79
96
  ## Search
80
97
 
81
- Normal search asks Brave, Tavily, and Exa, removes duplicate URLs, automatically reads three promising sources, follows up to two relevant links, and returns excerpts plus the ranked list. Alexandria tool discovery is included when available, without paid execution.
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.
82
99
 
83
100
  ```bash
84
101
  webctx search "next.js server components"
@@ -115,7 +132,7 @@ GH_TOKEN=...
115
132
  ```
116
133
 
117
134
  - `BRAVE_API_KEY`, `TAVILY_API_KEY`, and `EXA_API_KEY` power normal `search`.
118
- - `FIRECRAWL_API_KEY` powers `map-site` and page-crawl fallbacks.
135
+ - `FIRECRAWL_API_KEY` powers `map-site`, page-crawl fallbacks, and Alexandria discovery/inspection/paid execution.
119
136
  - `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.
120
137
 
121
138
  Environment variables, `.env.local`, and macOS Keychain are supported. `GH_TOKEN` takes precedence over `GITHUB_TOKEN`.
@@ -129,6 +146,7 @@ Start with the guides in `src/content/docs`:
129
146
  - **Use webctx with agents** — practical research, repo, PR, and CI workflows
130
147
  - **Search the web** — federated search and filtering
131
148
  - **Map a site** — discover pages before reading them
149
+ - **Research structured data** — browse data sources, inspect exact contracts, and execute paid queries with bounded credits
132
150
  - **Credentials** — keys and optional GitHub auth
133
151
  - **How URL reading works** — native/direct fast paths before Firecrawl
134
152
  - **How search ranking works** — URL normalization, position scoring, and provider agreement
@@ -1,8 +1,11 @@
1
1
  package app
2
2
 
3
3
  import (
4
+ "encoding/json"
4
5
  "fmt"
5
6
  "io"
7
+ "os"
8
+ "strconv"
6
9
  "strings"
7
10
 
8
11
  "github.com/amxv/webctx/internal/buildinfo"
@@ -74,6 +77,90 @@ func Run(args []string, stdout, stderr io.Writer) int {
74
77
  }
75
78
  _, _ = fmt.Fprintln(stdout, text)
76
79
  return 0
80
+ case "research":
81
+ research := ResearchDataInput{
82
+ Query: strings.Join(positional, " "),
83
+ Mode: flags["mode"],
84
+ URLs: splitCSV(flags["urls"]),
85
+ Sources: splitCSV(flags["sources"]),
86
+ Categories: splitCSV(flags["categories"]),
87
+ Groups: splitCSV(flags["groups"]),
88
+ Operations: splitCSV(flags["operations"]),
89
+ View: flags["view"],
90
+ Include: splitCSV(flags["include"]),
91
+ Raw: flags["raw"] == "true",
92
+ }
93
+ if q := flags["query"]; q != "" {
94
+ research.Query = q
95
+ }
96
+ if flag := flags["limit"]; flag != "" {
97
+ n, e := strconv.Atoi(flag)
98
+ if e != nil {
99
+ return cliDataError(stderr, dataError("invalid_limit", "--limit requires an integer."))
100
+ }
101
+ research.Limit = n
102
+ }
103
+ if flag := flags["offset"]; flag != "" {
104
+ n, e := strconv.Atoi(flag)
105
+ if e != nil {
106
+ return cliDataError(stderr, dataError("invalid_offset", "--offset requires an integer."))
107
+ }
108
+ research.Offset = n
109
+ }
110
+ result, e := ResearchData(research)
111
+ if e != nil {
112
+ return cliDataError(stderr, e)
113
+ }
114
+ writeDataJSON(stdout, result)
115
+ return 0
116
+ case "inspect":
117
+ if strings.TrimSpace(input) == "" {
118
+ return cliDataError(stderr, dataError("missing_id", "Usage: webctx inspect <operation-or-source-id> [--raw]"))
119
+ }
120
+ result, e := InspectData(InspectDataInput{ID: input, Include: splitCSV(flags["include"]), Raw: flags["raw"] == "true"})
121
+ if e != nil {
122
+ return cliDataError(stderr, e)
123
+ }
124
+ writeDataJSON(stdout, result)
125
+ return 0
126
+ case "execute":
127
+ request := ExecuteDataInput{Raw: flags["raw"] == "true", RequestID: flags["request-id"]}
128
+ if flag := flags["max-credits"]; flag != "" {
129
+ n, e := strconv.Atoi(flag)
130
+ if e != nil {
131
+ return cliDataError(stderr, dataError("invalid_budget", "--max-credits requires an integer."))
132
+ }
133
+ request.MaxCredits = n
134
+ }
135
+ if jsonCalls := flags["calls"]; jsonCalls != "" {
136
+ data, e := cliJSONString(jsonCalls)
137
+ if e != nil {
138
+ return cliDataError(stderr, e)
139
+ }
140
+ if e := json.Unmarshal([]byte(data), &request.Calls); e != nil {
141
+ return cliDataError(stderr, dataError("invalid_calls", "--calls must be a JSON array of {id,inputs} objects."))
142
+ }
143
+ } else if strings.TrimSpace(input) != "" {
144
+ var inputs map[string]any
145
+ if source := flags["inputs"]; source != "" {
146
+ data, e := cliJSONString(source)
147
+ if e != nil {
148
+ return cliDataError(stderr, e)
149
+ }
150
+ if e := json.Unmarshal([]byte(data), &inputs); e != nil {
151
+ return cliDataError(stderr, dataError("invalid_inputs", "--inputs must be a JSON object."))
152
+ }
153
+ }
154
+ request.Calls = []DataCall{{ID: input, Inputs: inputs}}
155
+ } else {
156
+ return cliDataError(stderr, dataError("missing_calls", "Usage: webctx execute <operation-id> --inputs JSON [--max-credits N] or --calls JSON."))
157
+ }
158
+ result, e := ExecuteData(request)
159
+ if e != nil {
160
+ return cliDataError(stderr, e)
161
+ }
162
+ writeDataJSON(stdout, result)
163
+ return 0
77
164
  default:
78
165
  _, _ = fmt.Fprintln(stderr, "Unknown tool:", tool)
79
166
  _, _ = fmt.Fprintln(stdout, usageText())
@@ -88,11 +175,19 @@ Usage:
88
175
  webctx search <query> [--exclude domain1,domain2] [--keyword phrase]
89
176
  webctx read-link <url> [--question 'what to find']
90
177
  webctx map-site <url>
178
+ webctx research [question] [--view sources|groups|operations] [--sources IDs] [--include inputs,output,examples] [--limit N] [--offset N]
179
+ webctx inspect <operation-or-source-id> [--raw]
180
+ webctx execute <operation-id> --inputs '{"field":"value"}' [--max-credits N] [--request-id ID]
181
+ webctx execute --calls '[{"id":"source/operation","inputs":{}}]' [--max-credits N]
91
182
 
92
183
  Examples:
93
184
  webctx search "next.js server components"
94
185
  webctx search "react hooks" --exclude youtube.com,vimeo.com
95
186
  webctx search "drizzle orm" --keyword "migration guide"
187
+ webctx research "podcast conversations about AI agents"
188
+ webctx research --view sources --limit 5
189
+ webctx inspect particle/podcasts/episodes/search
190
+ webctx execute particle/podcasts/episodes/search --inputs '{"semantic_search":"AI agents","limit":2}' --max-credits 20
96
191
  webctx read-link https://docs.example.com/guide
97
192
  webctx read-link https://docs.example.com/api --question "How do auth and pagination work?"
98
193
  webctx map-site https://example.com`, version)
@@ -102,6 +197,16 @@ func parseArgs(args []string) (map[string]string, []string) {
102
197
  flags := map[string]string{}
103
198
  positional := make([]string, 0, len(args))
104
199
  for i := 0; i < len(args); i++ {
200
+ if args[i] == "--raw" {
201
+ flags["raw"] = "true"
202
+ continue
203
+ }
204
+ if strings.HasPrefix(args[i], "--") {
205
+ if k, v, ok := strings.Cut(strings.TrimPrefix(args[i], "--"), "="); ok {
206
+ flags[k] = v
207
+ continue
208
+ }
209
+ }
105
210
  if strings.HasPrefix(args[i], "--") && i+1 < len(args) {
106
211
  flags[strings.TrimPrefix(args[i], "--")] = args[i+1]
107
212
  i++
@@ -112,6 +217,38 @@ func parseArgs(args []string) (map[string]string, []string) {
112
217
  return flags, positional
113
218
  }
114
219
 
220
+ func cliJSONString(source string) (string, error) {
221
+ if !strings.HasPrefix(source, "@") {
222
+ return source, nil
223
+ }
224
+ path := strings.TrimPrefix(source, "@")
225
+ if path == "" {
226
+ return "", dataError("invalid_file", "The @file argument requires a path.")
227
+ }
228
+ b, err := os.ReadFile(path)
229
+ if err != nil {
230
+ return "", dataError("invalid_file", "Unable to read JSON input file: "+err.Error())
231
+ }
232
+ if len(b) > 1<<20 {
233
+ return "", dataError("invalid_file", "JSON input file exceeds 1 MiB.")
234
+ }
235
+ return string(b), nil
236
+ }
237
+
238
+ func writeDataJSON(out io.Writer, value any) {
239
+ bytes, err := json.MarshalIndent(value, "", " ")
240
+ if err != nil {
241
+ _, _ = fmt.Fprintln(out, "{}")
242
+ return
243
+ }
244
+ _, _ = fmt.Fprintln(out, string(bytes))
245
+ }
246
+
247
+ func cliDataError(stderr io.Writer, err error) int {
248
+ writeDataJSON(stderr, dataRawOrError(err))
249
+ return 1
250
+ }
251
+
115
252
  func splitCSV(v string) []string {
116
253
  if strings.TrimSpace(v) == "" {
117
254
  return nil
@@ -0,0 +1,220 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "crypto/rand"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "errors"
9
+ "fmt"
10
+ "io"
11
+ "net/http"
12
+ "net/url"
13
+ "os"
14
+ "regexp"
15
+ "strconv"
16
+ "strings"
17
+ "time"
18
+ )
19
+
20
+ const dataAPIBase = "https://api.firecrawl.dev/v2"
21
+ const defaultDataCreditBudget = 100
22
+ const maxDataResponseBytes int64 = 12 << 20
23
+
24
+ type DataError struct {
25
+ Code string `json:"code"`
26
+ Message string `json:"message"`
27
+ RequiresAction map[string]any `json:"requires_action,omitempty"`
28
+ Details any `json:"details,omitempty"`
29
+ }
30
+
31
+ func (e *DataError) Error() string { return e.Code + ": " + e.Message }
32
+ func dataError(code, message string) error {
33
+ return &DataError{Code: code, Message: message}
34
+ }
35
+
36
+ func dataAPIKey() (string, error) {
37
+ key := strings.TrimSpace(os.Getenv("FIRECRAWL_API_KEY"))
38
+ if key == "" {
39
+ return "", dataError("missing_credentials", "Set FIRECRAWL_API_KEY to use data source discovery and queries.")
40
+ }
41
+ return key, nil
42
+ }
43
+
44
+ func dataCreditCap() int {
45
+ value, err := strconv.Atoi(strings.TrimSpace(os.Getenv("WEBCTX_ALEXANDRIA_MAX_CREDITS")))
46
+ if err != nil || value < 1 || value > 100000 {
47
+ return defaultDataCreditBudget
48
+ }
49
+ return value
50
+ }
51
+
52
+ func paidDataEnabled() bool {
53
+ switch strings.ToLower(strings.TrimSpace(os.Getenv("WEBCTX_ALEXANDRIA_PAID"))) {
54
+ case "0", "false", "no", "off":
55
+ return false
56
+ default:
57
+ return true
58
+ }
59
+ }
60
+
61
+ var safeRequestID = regexp.MustCompile("^[A-Za-z0-9_-]{8,128}$")
62
+
63
+ func newRequestID() (string, error) {
64
+ var bytes [16]byte
65
+ if _, err := rand.Read(bytes[:]); err != nil {
66
+ return "", err
67
+ }
68
+ return "webctx-" + hex.EncodeToString(bytes[:]), nil
69
+ }
70
+
71
+ func doDataAPI(ctx context.Context, endpoint string, payload any, requestID string) (map[string]any, error) {
72
+ key, err := dataAPIKey()
73
+ if err != nil {
74
+ return nil, err
75
+ }
76
+ body, err := json.Marshal(payload)
77
+ if err != nil {
78
+ return nil, dataError("invalid_request", "Could not encode request as JSON: "+err.Error())
79
+ }
80
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, dataAPIBase+endpoint, strings.NewReader(string(body)))
81
+ if err != nil {
82
+ return nil, err
83
+ }
84
+ req.Header.Set("Authorization", "Bearer "+key)
85
+ req.Header.Set("Content-Type", "application/json")
86
+ if requestID != "" {
87
+ req.Header.Set("x-request-id", requestID)
88
+ }
89
+ res, err := http.DefaultClient.Do(req)
90
+ if err != nil {
91
+ return nil, &DataError{Code: "network_error", Message: "The provider request did not complete. For a paid call, retry only with the same request_id to prevent duplicate charges."}
92
+ }
93
+ defer res.Body.Close()
94
+ raw, err := io.ReadAll(io.LimitReader(res.Body, maxDataResponseBytes+1))
95
+ if err != nil {
96
+ return nil, dataError("read_error", "Could not read provider response.")
97
+ }
98
+ if int64(len(raw)) > maxDataResponseBytes {
99
+ return nil, dataError("response_too_large", "Provider response exceeded 12 MiB; use a smaller limit or pagination.")
100
+ }
101
+ var decoded map[string]any
102
+ if json.Unmarshal(raw, &decoded) != nil {
103
+ return nil, dataError("invalid_upstream_response", fmt.Sprintf("Provider returned non-JSON HTTP %d.", res.StatusCode))
104
+ }
105
+ if res.StatusCode < 200 || res.StatusCode >= 300 || decoded["success"] == false {
106
+ code := stringValue(decoded["code"])
107
+ if code == "" {
108
+ code = fmt.Sprintf("http_%d", res.StatusCode)
109
+ }
110
+ message := stringValue(decoded["error"])
111
+ if message == "" {
112
+ message = stringValue(decoded["message"])
113
+ }
114
+ if message == "" {
115
+ message = "The provider rejected the request."
116
+ }
117
+ if len(message) > 800 {
118
+ message = message[:800]
119
+ }
120
+ out := &DataError{Code: code, Message: message}
121
+ if action, ok := decoded["requiresAction"].(map[string]any); ok {
122
+ if target := stringValue(action["url"]); target != "" {
123
+ if u, e := url.Parse(target); e == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" {
124
+ out.RequiresAction = map[string]any{"url": target}
125
+ }
126
+ }
127
+ }
128
+ return nil, out
129
+ }
130
+ return decoded, nil
131
+ }
132
+
133
+ func nativeDataCall(ctx context.Context, provider, capability string, options map[string]any, requestID string) (map[string]any, error) {
134
+ request := map[string]any{"alexandria": map[string]any{
135
+ "provider": provider, "capability": capability, "options": options,
136
+ }}
137
+ return doDataAPI(ctx, "/scrape", request, requestID)
138
+ }
139
+
140
+ func extractNativeData(response map[string]any) (map[string]any, error) {
141
+ data, ok := response["data"].(map[string]any)
142
+ if !ok {
143
+ return nil, dataError("invalid_upstream_response", "The response has no data object.")
144
+ }
145
+ items, ok := data["alexandria"].([]any)
146
+ if !ok || len(items) == 0 {
147
+ return nil, dataError("invalid_upstream_response", "The response contains no Alexandria results.")
148
+ }
149
+ item, ok := items[0].(map[string]any)
150
+ if !ok {
151
+ return nil, dataError("invalid_upstream_response", "The source returned an invalid result.")
152
+ }
153
+ return item, nil
154
+ }
155
+
156
+ func freeDataBrowse(ctx context.Context, options map[string]any) (map[string]any, map[string]any, error) {
157
+ response, err := nativeDataCall(ctx, "firecrawl", "find-tools", options, "")
158
+ if err != nil {
159
+ return nil, nil, err
160
+ }
161
+ item, err := extractNativeData(response)
162
+ if err != nil {
163
+ return nil, nil, err
164
+ }
165
+ details, ok := item["data"].(map[string]any)
166
+ if !ok {
167
+ return nil, nil, dataError("invalid_upstream_response", "The catalogue returned no browsable entries.")
168
+ }
169
+ return details, response, nil
170
+ }
171
+
172
+ func defaultDataContext() (context.Context, context.CancelFunc) {
173
+ return context.WithTimeout(context.Background(), 45*time.Second)
174
+ }
175
+
176
+ func dataRawOrError(err error) map[string]any {
177
+ var typed *DataError
178
+ if errors.As(err, &typed) {
179
+ return map[string]any{"error": typed}
180
+ }
181
+ return map[string]any{"error": map[string]any{"code": "internal_error", "message": err.Error()}}
182
+ }
183
+
184
+ // DataErrorResult preserves actionable failures in both CLI and MCP JSON.
185
+ func DataErrorResult(err error) map[string]any { return dataRawOrError(err) }
186
+
187
+ func listField(value any) []any {
188
+ switch v := value.(type) {
189
+ case []any:
190
+ return v
191
+ case []string:
192
+ out := make([]any, 0, len(v))
193
+ for _, s := range v {
194
+ out = append(out, s)
195
+ }
196
+ return out
197
+ default:
198
+ return nil
199
+ }
200
+ }
201
+
202
+ func stringField(value any) string { s, _ := value.(string); return s }
203
+ func mapField(value any) map[string]any { m, _ := value.(map[string]any); return m }
204
+ func numberField(value any) (float64, bool) {
205
+ switch n := value.(type) {
206
+ case float64:
207
+ return n, true
208
+ case float32:
209
+ return float64(n), true
210
+ case int:
211
+ return float64(n), true
212
+ case int64:
213
+ return float64(n), true
214
+ case json.Number:
215
+ f, e := n.Float64()
216
+ return f, e == nil
217
+ default:
218
+ return 0, false
219
+ }
220
+ }