webctx 0.2.2 → 0.2.4

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
@@ -74,7 +74,7 @@ Behavioral expectations:
74
74
 
75
75
  Release pipeline triggers on `v*` tags and expects:
76
76
 
77
- - `NPM_TOKEN` GitHub secret present.
77
+ - The `NPM_TOKEN` GitHub Actions secret must contain a valid npm access token with publish rights for `webctx`; publishing uses `NODE_AUTH_TOKEN` (not OIDC trusted publishing).
78
78
  - npm package name in `package.json` is publishable under your account/org.
79
79
  - repository URL matches the release origin used by `scripts/postinstall.js`.
80
80
 
package/CONTRIBUTORS.md CHANGED
@@ -80,27 +80,16 @@ make release-tag VERSION=x.y.z
80
80
  - GitHub release publish
81
81
  - npm publish
82
82
 
83
- ## Required GitHub secret
83
+ ## Automatic npm access-token publishing
84
84
 
85
- - `NPM_TOKEN`: npm automation token with publish rights for your package.
85
+ This project publishes npm releases using the repository's GitHub Actions secret `NPM_TOKEN`. Set it to a valid npm access token with publish access to the `webctx` package and appropriate npm two-factor-authentication settings for automated publishing. GitHub Actions supplies it as `NODE_AUTH_TOKEN` during the `release.yml` workflow's npm step.
86
86
 
87
- Set via GitHub CLI:
87
+ If a token expires, rotate it in npm and update `NPM_TOKEN` using `gh secret set NPM_TOKEN --repo amxv/webctx`. Never commit tokens or print them in job logs. Trusted publishing is not used.
88
88
 
89
- ```bash
90
- gh secret set NPM_TOKEN --repo amxv/webctx
91
- ```
92
-
93
- ## npm token setup
94
-
95
- Create token at npm:
96
-
97
- - Profile -> Access Tokens -> Create New Token
98
- - Use an automation/granular token scoped to required package/org
99
-
100
- Validate auth locally:
89
+ If a release's GitHub binaries were successfully published while npm authentication failed, dispatch `npm-publish-retry.yml` with the existing version. Do not create or move the release tag just to retry npm publishing:
101
90
 
102
91
  ```bash
103
- npm whoami
92
+ gh workflow run npm-publish-retry.yml --repo amxv/webctx --ref main -f version=0.2.2
104
93
  ```
105
94
 
106
95
  ## Project layout
package/README.md CHANGED
@@ -10,14 +10,21 @@ webctx read-link <url>
10
10
  webctx map-site <url>
11
11
  ```
12
12
 
13
- The output is plain text or markdown, so it is easy to hand to ChatGPT, Codex, Claude Code, a shell script, or another tool.
13
+ 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.
14
+
15
+ ```bash
16
+ webctx search "Go MCP SDK read-only tool annotations"
17
+ webctx read-link https://docs.example.com/api --question "How do authentication and pagination work?"
18
+ ```
14
19
 
15
20
  ## Origo: hosted MCP access
16
21
 
17
- **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.
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.
18
23
 
19
24
  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.
20
25
 
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.
27
+
21
28
  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.
22
29
 
23
30
  ## Install
@@ -71,7 +78,7 @@ Recognized native GitHub auth, private/not-found, and rate-limit failures stay a
71
78
 
72
79
  ## Search
73
80
 
74
- Normal search asks Brave, Tavily, and Exa, removes duplicate URLs, and returns one useful list.
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.
75
82
 
76
83
  ```bash
77
84
  webctx search "next.js server components"
@@ -0,0 +1,102 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "os"
9
+ "strings"
10
+ "time"
11
+ )
12
+
13
+ // formatAlexandriaTools describes discovered capabilities, not their data.
14
+ // Discovery is free but actual provider execution is not, and is never
15
+ // silently authorized by reading a link or searching the web.
16
+ func formatAlexandriaTools(raw any) string {
17
+ items, ok := raw.([]any)
18
+ if !ok || len(items) == 0 {
19
+ return ""
20
+ }
21
+ parts := []string{"**Available structured-data capabilities (not executed):**"}
22
+ count := 0
23
+ for _, item := range items {
24
+ tool, ok := item.(map[string]any)
25
+ if !ok {
26
+ continue
27
+ }
28
+ provider := safeToolField(tool["provider"], 80)
29
+ capability := safeToolField(tool["capability"], 160)
30
+ if provider == "" || capability == "" {
31
+ continue
32
+ }
33
+ description := safeToolField(tool["description"], 180)
34
+ if description == "" {
35
+ description = safeToolField(tool["name"], 180)
36
+ }
37
+ line := fmt.Sprintf("- `%s/%s`", provider, capability)
38
+ if description != "" {
39
+ line += ": " + description
40
+ }
41
+ parts = append(parts, line)
42
+ count++
43
+ if count >= 3 {
44
+ break
45
+ }
46
+ }
47
+ if count == 0 {
48
+ return ""
49
+ }
50
+ return strings.Join(parts, "\n")
51
+ }
52
+
53
+ func safeToolField(value any, limit int) string {
54
+ s, ok := value.(string)
55
+ if !ok {
56
+ return ""
57
+ }
58
+ s = strings.Join(strings.Fields(s), " ")
59
+ s = strings.ReplaceAll(s, "`", "'")
60
+ if len(s) > limit {
61
+ s = s[:limit] + "…"
62
+ }
63
+ return s
64
+ }
65
+
66
+ func withSourceTools(markdown, toolSummary string) string {
67
+ if toolSummary == "" {
68
+ return markdown
69
+ }
70
+ return markdown + "\n\n---\n\n" + toolSummary + "\n\n*Matched through Firecrawl Alexandria. Discovery does not verify, license, or execute these providers.*"
71
+ }
72
+
73
+ func discoverAlexandria(query string) string {
74
+ apiKey := strings.TrimSpace(os.Getenv("FIRECRAWL_API_KEY"))
75
+ if apiKey == "" {
76
+ return ""
77
+ }
78
+ ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
79
+ defer cancel()
80
+ response, err := doJSONRequest(ctx, http.MethodPost, "https://api.firecrawl.dev/v2/search", map[string]string{
81
+ "Authorization": "Bearer " + apiKey,
82
+ "Content-Type": "application/json",
83
+ }, map[string]any{
84
+ "query": query,
85
+ "sources": []string{"alexandria"},
86
+ "limit": 3,
87
+ "toolDetail": "compact",
88
+ })
89
+ if err != nil {
90
+ return "" // unavailable Alexandria is never a search failure
91
+ }
92
+ var result struct {
93
+ Success bool `json:"success"`
94
+ Data struct {
95
+ Tools []any `json:"tools"`
96
+ } `json:"data"`
97
+ }
98
+ if json.Unmarshal(response, &result) != nil || !result.Success {
99
+ return ""
100
+ }
101
+ return formatAlexandriaTools(result.Data.Tools)
102
+ }
@@ -51,10 +51,10 @@ func Run(args []string, stdout, stderr io.Writer) int {
51
51
  case "read-link":
52
52
  if strings.TrimSpace(input) == "" {
53
53
  _, _ = fmt.Fprintln(stderr, "Error: read-link requires a URL")
54
- _, _ = fmt.Fprintln(stdout, "Usage: webctx read-link <url>")
54
+ _, _ = fmt.Fprintln(stdout, "Usage: webctx read-link <url> [--question 'what to find']")
55
55
  return 1
56
56
  }
57
- text, err := ReadLink(input)
57
+ text, err := ReadLinkFocused(input, flags["question"])
58
58
  if err != nil {
59
59
  _, _ = fmt.Fprintln(stderr, err.Error())
60
60
  return 1
@@ -86,7 +86,7 @@ func usageText() string {
86
86
 
87
87
  Usage:
88
88
  webctx search <query> [--exclude domain1,domain2] [--keyword phrase]
89
- webctx read-link <url>
89
+ webctx read-link <url> [--question 'what to find']
90
90
  webctx map-site <url>
91
91
 
92
92
  Examples:
@@ -94,6 +94,7 @@ Examples:
94
94
  webctx search "react hooks" --exclude youtube.com,vimeo.com
95
95
  webctx search "drizzle orm" --keyword "migration guide"
96
96
  webctx read-link https://docs.example.com/guide
97
+ webctx read-link https://docs.example.com/api --question "How do auth and pagination work?"
97
98
  webctx map-site https://example.com`, version)
98
99
  }
99
100
 
@@ -12,31 +12,20 @@ import (
12
12
  )
13
13
 
14
14
  type scrapedPage struct {
15
- Title string
16
- Markdown string
15
+ Title string
16
+ Markdown string
17
+ ToolSummary string
17
18
  }
18
19
 
19
20
  func usableScrapedMarkdown(markdown string) bool {
20
- text := strings.ToLower(strings.TrimSpace(markdown))
21
- if len(text) < 10 {
22
- return false
23
- }
24
- for _, marker := range []string{
25
- "just a moment...", "checking your browser", "verify you are human",
26
- "enable javascript and cookies", "access denied | cloudflare",
27
- "attention required! | cloudflare", "no content extracted",
28
- } {
29
- if strings.Contains(text, marker) && len(text) < 2000 {
30
- return false
31
- }
32
- }
33
- return true
21
+ return usableSourceContent(markdown)
34
22
  }
35
23
 
36
24
  func firecrawlScrape(rawURL, apiKey, proxy string) (scrapedPage, error) {
37
25
  requestBody := map[string]any{
38
26
  "url": rawURL,
39
27
  "formats": []string{"markdown"},
28
+ "domainTools": true, // Alexandria discovery, not paid execution.
40
29
  "onlyMainContent": true,
41
30
  "blockAds": true,
42
31
  "removeBase64Images": true,
@@ -58,6 +47,16 @@ func firecrawlScrape(rawURL, apiKey, proxy string) (scrapedPage, error) {
58
47
  "Authorization": "Bearer " + apiKey,
59
48
  "Content-Type": "application/json",
60
49
  }, requestBody)
50
+ // Alexandria is a separately enabled Firecrawl capability. A 403 from
51
+ // domain matching must never break ordinary page extraction for users that
52
+ // haven't enabled Alexandria or have zero-data-retention configured.
53
+ if err != nil && strings.Contains(err.Error(), "403") {
54
+ requestBody["domainTools"] = false
55
+ body, err = doJSONRequest(ctx, http.MethodPost, "https://api.firecrawl.dev/v2/scrape", map[string]string{
56
+ "Authorization": "Bearer " + apiKey,
57
+ "Content-Type": "application/json",
58
+ }, requestBody)
59
+ }
61
60
  if err != nil {
62
61
  return nil, err
63
62
  }
@@ -76,8 +75,9 @@ func firecrawlScrape(rawURL, apiKey, proxy string) (scrapedPage, error) {
76
75
  pageData, _ := data["data"].(map[string]any)
77
76
  metadata, _ := pageData["metadata"].(map[string]any)
78
77
  page := scrapedPage{
79
- Title: stringValue(metadata["title"]),
80
- Markdown: stringValue(pageData["markdown"]),
78
+ Title: stringValue(metadata["title"]),
79
+ Markdown: stringValue(pageData["markdown"]),
80
+ ToolSummary: formatAlexandriaTools(pageData["tools"]),
81
81
  }
82
82
  if status, ok := metadata["statusCode"].(float64); ok && status >= 400 {
83
83
  return page, fmt.Errorf("source responded with HTTP %d", int(status))
@@ -152,6 +152,9 @@ func firecrawlBrowserFallback(rawURL, apiKey string) (string, string, error) {
152
152
  if parsed, err := strconv.Unquote(strings.TrimSpace(text)); err == nil {
153
153
  text = parsed
154
154
  }
155
+ if reason := challengeReason(text); reason != "" {
156
+ return "", "", fmt.Errorf("browser fallback returned %s rather than documentation", reason)
157
+ }
155
158
  return "Browser extraction", text, nil
156
159
  }
157
160
 
@@ -1,6 +1,7 @@
1
1
  package app
2
2
 
3
3
  import (
4
+ "encoding/json"
4
5
  "io"
5
6
  "net/http"
6
7
  "strings"
@@ -18,6 +19,43 @@ func TestSourceURLSafety(t *testing.T) {
18
19
  }
19
20
  }
20
21
 
22
+ func TestLongBrowserCAPTCHAFallbackFailsClearly(t *testing.T) {
23
+ original := http.DefaultClient
24
+ t.Cleanup(func() { http.DefaultClient = original })
25
+ challenge := "hCaptcha Please verify you are human. " +
26
+ strings.Repeat("French Deutsch Español English 日本語 中文 ", 150)
27
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
28
+ switch req.Method + " " + req.URL.Path {
29
+ case "POST /v2/scrape":
30
+ content, _ := json.Marshal(map[string]any{
31
+ "success": true,
32
+ "data": map[string]any{"markdown": challenge},
33
+ })
34
+ return testHTTPResponse(req, http.StatusOK, string(content), nil), nil
35
+ case "POST /v2/interact":
36
+ return testHTTPResponse(req, http.StatusOK, `{"success":true,"id":"test-captcha"}`, nil), nil
37
+ case "POST /v2/interact/test-captcha/execute":
38
+ body, _ := json.Marshal(map[string]any{
39
+ "success": true, "stdout": challenge, "exitCode": 0,
40
+ })
41
+ return testHTTPResponse(req, http.StatusOK, string(body), nil), nil
42
+ case "DELETE /v2/interact/test-captcha":
43
+ return testHTTPResponse(req, http.StatusOK, `{"success":true}`, nil), nil
44
+ default:
45
+ t.Fatalf("unexpected request: %s %s", req.Method, req.URL)
46
+ return nil, nil
47
+ }
48
+ })}
49
+ t.Setenv("FIRECRAWL_API_KEY", "unit-test")
50
+ _, returned, err := scrapeLinkWithFirecrawl("https://docs.example.com/challenged")
51
+ if err == nil || returned != "" || !strings.Contains(err.Error(), "challenge") {
52
+ t.Fatalf("long browser challenge leaked into result or lacked clear failure: %q %v", returned, err)
53
+ }
54
+ if strings.Contains(err.Error(), "French Deutsch") {
55
+ t.Fatalf("challenge payload should not be logged verbatim in errors: %s", err)
56
+ }
57
+ }
58
+
21
59
  func TestScrapeEscalatesToEnhancedOnBlockedBody(t *testing.T) {
22
60
  originalClient := http.DefaultClient
23
61
  t.Cleanup(func() { http.DefaultClient = originalClient })
@@ -0,0 +1,76 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "net/url"
6
+ "path"
7
+ "strings"
8
+ "time"
9
+ )
10
+
11
+ const maxNativeMarkdownBytes = 1024 * 1024
12
+
13
+ // Documentation sites frequently expose canonical HTML and an authored
14
+ // Markdown representation. Use bounded GET probes instead of HEAD:
15
+ // Stripe sends Content-Length: 0 on a successful Markdown HEAD response,
16
+ // and Cloudflare uses /index.md for directory URLs.
17
+ func markdownCandidateURLs(rawURL string) []string {
18
+ u, err := url.Parse(rawURL)
19
+ if err != nil {
20
+ return nil
21
+ }
22
+ p := u.Path
23
+ if strings.HasSuffix(strings.ToLower(p), ".md") {
24
+ return []string{rawURL}
25
+ }
26
+ var candidates []string
27
+ if strings.HasSuffix(p, "/") || p == "" {
28
+ candidates = append(candidates, strings.TrimSuffix(p, "/")+"/index.md")
29
+ } else if path.Ext(p) == "" {
30
+ candidates = append(candidates, p+".md", p+"/index.md")
31
+ } else {
32
+ return nil
33
+ }
34
+ seen := map[string]bool{}
35
+ var out []string
36
+ for _, candidate := range candidates {
37
+ next := *u
38
+ next.Path = candidate
39
+ next.RawPath = ""
40
+ next.Fragment = ""
41
+ next.RawFragment = ""
42
+ value := next.String()
43
+ if !seen[value] && validateSourceURL(value) == nil {
44
+ out = append(out, value)
45
+ seen[value] = true
46
+ }
47
+ }
48
+ return out
49
+ }
50
+
51
+ func usableNativeMarkdown(content string) bool {
52
+ text := strings.TrimSpace(content)
53
+ if len(text) < 18 || strings.IndexByte(text, 0) >= 0 || challengeReason(text) != "" {
54
+ return false
55
+ }
56
+ prefix := strings.ToLower(text[:min(len(text), 700)])
57
+ if strings.Contains(prefix, "<!doctype html") || strings.Contains(prefix, "<html") ||
58
+ strings.Contains(prefix, "<head") || strings.Contains(prefix, "<body") {
59
+ return false
60
+ }
61
+ return true
62
+ }
63
+
64
+ func readNativeMarkdown(rawURL string) (*MarkdownResult, error) {
65
+ for _, candidate := range markdownCandidateURLs(rawURL) {
66
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
67
+ body, err := fetchPublicText(ctx, candidate, maxNativeMarkdownBytes)
68
+ cancel()
69
+ if err != nil || !usableNativeMarkdown(body) {
70
+ continue
71
+ }
72
+ title := firstHeadingOrFallback(body, path.Base(rawURL))
73
+ return &MarkdownResult{URL: rawURL, SourceURL: candidate, Title: title, Markdown: body}, nil
74
+ }
75
+ return nil, nil
76
+ }
@@ -0,0 +1,78 @@
1
+ package app
2
+
3
+ import (
4
+ "net/http"
5
+ "strings"
6
+ "testing"
7
+ )
8
+
9
+ func TestCanonicalStripeGuidePrefersMarkdownWithZeroHeadLength(t *testing.T) {
10
+ previous := http.DefaultClient
11
+ t.Cleanup(func() { http.DefaultClient = previous })
12
+ calls := []string{}
13
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
14
+ calls = append(calls, req.Method+" "+req.URL.Path)
15
+ if req.Method == http.MethodHead {
16
+ t.Fatal("native Markdown detection must not require HEAD metadata")
17
+ }
18
+ if req.Method == http.MethodGet && req.URL.Path == "/payments/checkout.md" {
19
+ r := testHTTPResponse(req, http.StatusOK, "# Build a payments page\n\nStripe-hosted checkout is supported.", map[string]string{"Content-Type": "text/markdown"})
20
+ r.ContentLength = 0 // Publishers can return zero or unknown length metadata.
21
+ return r, nil
22
+ }
23
+ t.Fatalf("unexpected extra request: %s %s", req.Method, req.URL)
24
+ return nil, nil
25
+ })}
26
+ out, err := ReadLink("https://docs.stripe.com/payments/checkout")
27
+ if err != nil || !strings.Contains(out, "**Markdown source:** https://docs.stripe.com/payments/checkout.md") {
28
+ t.Fatalf("native source not selected: %v %s", err, out)
29
+ }
30
+ if len(calls) != 1 {
31
+ t.Fatalf("unexpected calls: %+v", calls)
32
+ }
33
+ }
34
+
35
+ func TestCanonicalCloudflareDirectoryReadsIndexMarkdown(t *testing.T) {
36
+ previous := http.DefaultClient
37
+ t.Cleanup(func() { http.DefaultClient = previous })
38
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
39
+ if req.Method != http.MethodGet || req.URL.Path != "/durable-objects/index.md" {
40
+ t.Fatalf("unexpected URL: %s %s", req.Method, req.URL)
41
+ }
42
+ return testHTTPResponse(req, http.StatusOK,
43
+ "# Cloudflare Durable Objects\n\n[Get started](https://developers.cloudflare.com/durable-objects/get-started/)",
44
+ map[string]string{"Content-Type": "text/markdown"}), nil
45
+ })}
46
+ out, err := ReadLink("https://developers.cloudflare.com/durable-objects/")
47
+ if err != nil || !strings.Contains(out, "**Markdown source:** https://developers.cloudflare.com/durable-objects/index.md") {
48
+ t.Fatalf("directory-index native Markdown failed: %v %s", err, out)
49
+ }
50
+ }
51
+
52
+ func TestCAPTCHADisguisedAsMarkdownIsRejected(t *testing.T) {
53
+ previous := http.DefaultClient
54
+ t.Cleanup(func() { http.DefaultClient = previous })
55
+ var scrapeCalled bool
56
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
57
+ if req.URL.Path == "/guide.md" {
58
+ return testHTTPResponse(req, http.StatusOK, "# hCaptcha\nPlease verify you are human. "+
59
+ strings.Repeat("English French Deutsch Español Chinese 日本語 ", 200), map[string]string{"Content-Type": "text/markdown"}), nil
60
+ }
61
+ if req.URL.Path == "/guide/index.md" {
62
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
63
+ }
64
+ if req.Method == http.MethodPost && req.URL.Path == "/v2/scrape" {
65
+ scrapeCalled = true
66
+ return testHTTPResponse(req, http.StatusOK,
67
+ `{"success":true,"data":{"markdown":"# Legitimate guide\n\nFull instructions for a public API."}}`,
68
+ nil), nil
69
+ }
70
+ t.Fatalf("unexpected request: %s %s", req.Method, req.URL)
71
+ return nil, nil
72
+ })}
73
+ t.Setenv("FIRECRAWL_API_KEY", "test")
74
+ out, err := ReadLink("https://docs.example.com/guide")
75
+ if err != nil || !scrapeCalled || strings.Contains(out, "Deutsch Español") || !strings.Contains(out, "Full instructions") {
76
+ t.Fatalf("CAPTCHA disguised as Markdown leaked or fallback failed: %v %s", err, out)
77
+ }
78
+ }