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 +1 -1
- package/CONTRIBUTORS.md +5 -16
- package/README.md +10 -3
- package/internal/app/alexandria.go +102 -0
- package/internal/app/app.go +4 -3
- package/internal/app/firecrawl_fallback.go +21 -18
- package/internal/app/firecrawl_fallback_test.go +38 -0
- package/internal/app/native_markdown.go +76 -0
- package/internal/app/native_markdown_test.go +78 -0
- package/internal/app/research.go +615 -0
- package/internal/app/research_excerpt.go +149 -0
- package/internal/app/research_excerpt_test.go +47 -0
- package/internal/app/research_navigation.go +237 -0
- package/internal/app/research_navigation_test.go +72 -0
- package/internal/app/research_openapi.go +289 -0
- package/internal/app/research_openapi_test.go +140 -0
- package/internal/app/research_test.go +154 -0
- package/internal/app/retrieval_quality.go +106 -0
- package/internal/app/retrieval_quality_test.go +63 -0
- package/internal/app/scrape.go +4 -65
- package/internal/app/structured_source.go +259 -0
- package/internal/app/structured_source_test.go +70 -0
- package/internal/app/tools.go +36 -9
- package/package.json +1 -1
- package/pkg/origo/server.go +10 -3
- package/pkg/origo/server_test.go +15 -0
- package/pkg/retrieval/retrieval.go +6 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"errors"
|
|
6
|
+
"log"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
"time"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestLongCAPTCHAPayloadCannotPassAsDocumentation(t *testing.T) {
|
|
13
|
+
longChallenge := "hCaptcha - Please verify you are human. " +
|
|
14
|
+
strings.Repeat("Deutsch Français 日本語 中文 Hindi Español English ", 1000)
|
|
15
|
+
if usableScrapedMarkdown(longChallenge) || usableNativeMarkdown(longChallenge) {
|
|
16
|
+
t.Fatal("large CAPTCHA payload must be rejected even when >2KB")
|
|
17
|
+
}
|
|
18
|
+
if challengeReason(longChallenge) != "browser verification challenge" {
|
|
19
|
+
t.Fatalf("missing browser challenge reason: %q", challengeReason(longChallenge))
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func TestNormalDocumentationAboutCAPTCHAsIsNotAutomaticallyBlocked(t *testing.T) {
|
|
24
|
+
doc := "# hCaptcha integration guide\n\nUse the hCaptcha SDK to add a verification widget to your website.\n" +
|
|
25
|
+
strings.Repeat("Install the client library and validate tokens on your server.\n", 50)
|
|
26
|
+
if !usableScrapedMarkdown(doc) {
|
|
27
|
+
t.Fatal("ordinary documentation about captcha should not be rejected")
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func TestExplicitEvidenceGapReportsMissingFields(t *testing.T) {
|
|
32
|
+
q := "Create a Checkout Session with line_items, mode, success_url and cancel_url and explain webhook signature verification"
|
|
33
|
+
excerpts := "Use line_items and success_url in a session. Verify webhooks using Stripe-Signature."
|
|
34
|
+
gaps := missingResearchEvidence(q, excerpts)
|
|
35
|
+
if !strings.Contains(strings.Join(gaps, ","), "cancel_url") {
|
|
36
|
+
t.Fatalf("missing required request field was not reported: %+v", gaps)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func TestEmbeddedJSONExampleDoesNotBypassExcerptBudget(t *testing.T) {
|
|
41
|
+
doc := "**URL:** https://example.com/guide\n\n# Guide\n" +
|
|
42
|
+
strings.Repeat("This guide describes a public API and its fields.\n", 1000) +
|
|
43
|
+
"\n" + backtickFence + "json\n" + `{"example":{"id":"ok"}}` + "\n" + backtickFence
|
|
44
|
+
out := focusExcerpt(doc, "public API fields", 3500)
|
|
45
|
+
if len(out) > 4000 {
|
|
46
|
+
t.Fatalf("returned source bypassed excerpt budget: size=%d", len(out))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func TestRetrievalDebugTraceDoesNotLogCredentials(t *testing.T) {
|
|
51
|
+
original := log.Writer()
|
|
52
|
+
t.Cleanup(func() { log.SetOutput(original) })
|
|
53
|
+
var output bytes.Buffer
|
|
54
|
+
log.SetOutput(&output)
|
|
55
|
+
t.Setenv("WEBCTX_DEBUG", "1")
|
|
56
|
+
traceRetrieval("https://docs.example.com/guide?api_key=secret-value&token=sensitive",
|
|
57
|
+
"browser", "blocked", time.Now(), errors.New("unauthorized token sensitive"))
|
|
58
|
+
result := output.String()
|
|
59
|
+
if !strings.Contains(result, `stage="browser"`) || !strings.Contains(result, `error="source-unavailable"`) ||
|
|
60
|
+
strings.Contains(result, "secret-value") || strings.Contains(result, "sensitive") {
|
|
61
|
+
t.Fatalf("debug trace leaked query parameters or credentials: %s", result)
|
|
62
|
+
}
|
|
63
|
+
}
|
package/internal/app/scrape.go
CHANGED
|
@@ -2,9 +2,6 @@ package app
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
-
"fmt"
|
|
6
|
-
"io"
|
|
7
|
-
"net/http"
|
|
8
5
|
"os"
|
|
9
6
|
"os/exec"
|
|
10
7
|
"path/filepath"
|
|
@@ -15,9 +12,10 @@ import (
|
|
|
15
12
|
)
|
|
16
13
|
|
|
17
14
|
type MarkdownResult struct {
|
|
18
|
-
URL
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
URL string
|
|
16
|
+
SourceURL string
|
|
17
|
+
Title string
|
|
18
|
+
Markdown string
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
const keychainServiceName = "webctx"
|
|
@@ -29,65 +27,6 @@ var (
|
|
|
29
27
|
keychainLookup = lookupKeychainSecret
|
|
30
28
|
)
|
|
31
29
|
|
|
32
|
-
func checkMarkdownAvailable(raw string) (bool, error) {
|
|
33
|
-
mdURL := raw
|
|
34
|
-
if !strings.HasSuffix(strings.ToLower(mdURL), ".md") {
|
|
35
|
-
mdURL += ".md"
|
|
36
|
-
}
|
|
37
|
-
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
38
|
-
defer cancel()
|
|
39
|
-
req, err := http.NewRequestWithContext(ctx, http.MethodHead, mdURL, nil)
|
|
40
|
-
if err != nil {
|
|
41
|
-
return false, err
|
|
42
|
-
}
|
|
43
|
-
resp, err := http.DefaultClient.Do(req)
|
|
44
|
-
if err != nil {
|
|
45
|
-
return false, err
|
|
46
|
-
}
|
|
47
|
-
defer resp.Body.Close()
|
|
48
|
-
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
49
|
-
return false, nil
|
|
50
|
-
}
|
|
51
|
-
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
|
|
52
|
-
contentLength := resp.ContentLength
|
|
53
|
-
return (strings.Contains(contentType, "markdown") || strings.Contains(contentType, "text/plain")) && contentLength > 50, nil
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
func fetchMarkdownContent(raw string) (*MarkdownResult, error) {
|
|
57
|
-
mdURL := raw
|
|
58
|
-
if !strings.HasSuffix(strings.ToLower(mdURL), ".md") {
|
|
59
|
-
mdURL += ".md"
|
|
60
|
-
}
|
|
61
|
-
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
62
|
-
defer cancel()
|
|
63
|
-
content, status, err := fetchText(ctx, mdURL)
|
|
64
|
-
if err != nil {
|
|
65
|
-
return nil, err
|
|
66
|
-
}
|
|
67
|
-
if status < 200 || status >= 300 {
|
|
68
|
-
return nil, fmt.Errorf("Failed to fetch markdown: %d", status)
|
|
69
|
-
}
|
|
70
|
-
title := firstHeadingOrFallback(content, filepath.Base(raw))
|
|
71
|
-
return &MarkdownResult{URL: raw, Title: title, Markdown: content}, nil
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
func fetchText(ctx context.Context, rawURL string) (string, int, error) {
|
|
75
|
-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
76
|
-
if err != nil {
|
|
77
|
-
return "", 0, err
|
|
78
|
-
}
|
|
79
|
-
resp, err := http.DefaultClient.Do(req)
|
|
80
|
-
if err != nil {
|
|
81
|
-
return "", 0, err
|
|
82
|
-
}
|
|
83
|
-
defer resp.Body.Close()
|
|
84
|
-
body, err := io.ReadAll(resp.Body)
|
|
85
|
-
if err != nil {
|
|
86
|
-
return "", resp.StatusCode, err
|
|
87
|
-
}
|
|
88
|
-
return string(body), resp.StatusCode, nil
|
|
89
|
-
}
|
|
90
|
-
|
|
91
30
|
func firstHeadingOrFallback(markdown, fallback string) string {
|
|
92
31
|
for _, line := range strings.Split(markdown, "\n") {
|
|
93
32
|
trimmed := strings.TrimSpace(line)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/url"
|
|
10
|
+
"path"
|
|
11
|
+
"regexp"
|
|
12
|
+
"sort"
|
|
13
|
+
"strings"
|
|
14
|
+
"time"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const maxDirectSourceBytes = 1024 * 1024
|
|
18
|
+
|
|
19
|
+
var (
|
|
20
|
+
jsonLDPattern = regexp.MustCompile(`(?is)<script\b[^>]*type\s*=\s*["']application/ld\+json["'][^>]*>(.*?)</script\s*>`)
|
|
21
|
+
nextDataPattern = regexp.MustCompile(`(?is)<script\b[^>]*id\s*=\s*["']__NEXT_DATA__["'][^>]*>(.*?)</script\s*>`)
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
// These URLs are already structured source documents. Never scrape a raw
|
|
25
|
+
// OpenAPI schema through HTML rendering when we can retrieve its actual bytes.
|
|
26
|
+
func directSourceLanguage(rawURL string) string {
|
|
27
|
+
u, err := url.Parse(rawURL)
|
|
28
|
+
if err != nil {
|
|
29
|
+
return ""
|
|
30
|
+
}
|
|
31
|
+
switch strings.ToLower(path.Ext(u.Path)) {
|
|
32
|
+
case ".json", ".jsonl":
|
|
33
|
+
return "json"
|
|
34
|
+
case ".yaml", ".yml":
|
|
35
|
+
return "yaml"
|
|
36
|
+
case ".toml":
|
|
37
|
+
return "toml"
|
|
38
|
+
case ".xml":
|
|
39
|
+
return "xml"
|
|
40
|
+
case ".graphql", ".gql":
|
|
41
|
+
return "graphql"
|
|
42
|
+
case ".txt":
|
|
43
|
+
return "text"
|
|
44
|
+
default:
|
|
45
|
+
return ""
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A bounded, redirect-checked direct read prevents accidental traversal from
|
|
50
|
+
// an accepted public source to an internal address.
|
|
51
|
+
func fetchPublicText(ctx context.Context, rawURL string, maxBytes int64) (string, error) {
|
|
52
|
+
if err := validateSourceURL(rawURL); err != nil {
|
|
53
|
+
return "", err
|
|
54
|
+
}
|
|
55
|
+
client := *http.DefaultClient
|
|
56
|
+
oldCheck := client.CheckRedirect
|
|
57
|
+
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
58
|
+
if err := validateSourceURL(req.URL.String()); err != nil {
|
|
59
|
+
return err
|
|
60
|
+
}
|
|
61
|
+
if oldCheck != nil {
|
|
62
|
+
return oldCheck(req, via)
|
|
63
|
+
}
|
|
64
|
+
if len(via) >= 10 {
|
|
65
|
+
return fmt.Errorf("too many redirects")
|
|
66
|
+
}
|
|
67
|
+
return nil
|
|
68
|
+
}
|
|
69
|
+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
70
|
+
if err != nil {
|
|
71
|
+
return "", err
|
|
72
|
+
}
|
|
73
|
+
req.Header.Set("Accept", "application/json, text/markdown, text/plain, text/html, application/yaml, */*;q=0.5")
|
|
74
|
+
resp, err := client.Do(req)
|
|
75
|
+
if err != nil {
|
|
76
|
+
return "", err
|
|
77
|
+
}
|
|
78
|
+
defer resp.Body.Close()
|
|
79
|
+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
80
|
+
return "", fmt.Errorf("source HTTP %d", resp.StatusCode)
|
|
81
|
+
}
|
|
82
|
+
if resp.ContentLength > maxBytes {
|
|
83
|
+
return "", fmt.Errorf("source is larger than the supported %d bytes", maxBytes)
|
|
84
|
+
}
|
|
85
|
+
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
|
86
|
+
if err != nil {
|
|
87
|
+
return "", err
|
|
88
|
+
}
|
|
89
|
+
if int64(len(body)) > maxBytes {
|
|
90
|
+
return "", fmt.Errorf("source exceeded the supported %d bytes", maxBytes)
|
|
91
|
+
}
|
|
92
|
+
return string(body), nil
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func readDirectSource(rawURL string) (string, bool) {
|
|
96
|
+
language := directSourceLanguage(rawURL)
|
|
97
|
+
if language == "" {
|
|
98
|
+
return "", false
|
|
99
|
+
}
|
|
100
|
+
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
|
101
|
+
defer cancel()
|
|
102
|
+
body, err := fetchPublicText(ctx, rawURL, maxDirectSourceBytes)
|
|
103
|
+
if err != nil || strings.TrimSpace(body) == "" {
|
|
104
|
+
return "", false
|
|
105
|
+
}
|
|
106
|
+
if strings.Contains(strings.ToLower(body[:min(len(body), 256)]), "<html") {
|
|
107
|
+
return "", false
|
|
108
|
+
}
|
|
109
|
+
if language == "json" && !json.Valid([]byte(body)) { // don't label HTML/garbage as JSON
|
|
110
|
+
return "", false
|
|
111
|
+
}
|
|
112
|
+
return fmt.Sprintf("**URL:** %s\n\n```%s\n%s\n```", rawURL, language, strings.TrimSpace(body)), true
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Extract only public machine-readable metadata actually embedded in the
|
|
116
|
+
// original HTML. Never execute arbitrary scripts or speculate about hidden APIs.
|
|
117
|
+
func embeddedStructuredContext(rawURL, question string) string {
|
|
118
|
+
if question == "" || directSourceLanguage(rawURL) != "" {
|
|
119
|
+
return ""
|
|
120
|
+
}
|
|
121
|
+
if u, err := url.Parse(rawURL); err == nil && strings.EqualFold(u.Hostname(), "github.com") {
|
|
122
|
+
return "" // GitHub's native APIs are more faithful than page hydration data.
|
|
123
|
+
}
|
|
124
|
+
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
|
125
|
+
defer cancel()
|
|
126
|
+
html, err := fetchPublicText(ctx, rawURL, maxDirectSourceBytes)
|
|
127
|
+
if err != nil || !strings.Contains(strings.ToLower(html), "<script") {
|
|
128
|
+
return ""
|
|
129
|
+
}
|
|
130
|
+
var results []string
|
|
131
|
+
for _, pattern := range []*regexp.Regexp{jsonLDPattern, nextDataPattern} {
|
|
132
|
+
for _, match := range pattern.FindAllStringSubmatch(html, 3) {
|
|
133
|
+
var raw any
|
|
134
|
+
if json.Unmarshal([]byte(strings.TrimSpace(match[1])), &raw) != nil {
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
selection := selectedJSONFields(raw, question, 2500)
|
|
138
|
+
if selection != "" {
|
|
139
|
+
results = append(results, selection)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if len(results) == 0 {
|
|
144
|
+
return ""
|
|
145
|
+
}
|
|
146
|
+
return "### Embedded source metadata\n\nStructured JSON embedded in the source page (not interpreted as an API response):\n\n" + strings.Join(results, "\n\n")
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// selectedJSONFields preserves exact JSON values and JSON Pointers rather
|
|
150
|
+
// than returning syntactically broken chunks of a large schema or payload.
|
|
151
|
+
func selectedJSONFields(raw any, question string, budget int) string {
|
|
152
|
+
type field struct {
|
|
153
|
+
path string
|
|
154
|
+
value any
|
|
155
|
+
score int
|
|
156
|
+
}
|
|
157
|
+
terms := queryTerms(question)
|
|
158
|
+
fields := make([]field, 0, 32)
|
|
159
|
+
var walk func(any, string, int)
|
|
160
|
+
walk = func(value any, parent string, depth int) {
|
|
161
|
+
if depth > 9 || len(fields) >= 500 {
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
switch v := value.(type) {
|
|
165
|
+
case map[string]any:
|
|
166
|
+
for key, child := range v {
|
|
167
|
+
pointer := parent + "/" + strings.ReplaceAll(strings.ReplaceAll(key, "~", "~0"), "/", "~1")
|
|
168
|
+
score := termScore(key, terms)*4 + termScore(pointer, terms)
|
|
169
|
+
if score > 0 {
|
|
170
|
+
fields = append(fields, field{path: pointer, value: child, score: score})
|
|
171
|
+
}
|
|
172
|
+
walk(child, pointer, depth+1)
|
|
173
|
+
}
|
|
174
|
+
case []any:
|
|
175
|
+
for i, child := range v {
|
|
176
|
+
if i >= 20 { // never flatten unbounded payload collections
|
|
177
|
+
break
|
|
178
|
+
}
|
|
179
|
+
walk(child, fmt.Sprintf("%s/%d", parent, i), depth+1)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
walk(raw, "", 0)
|
|
184
|
+
sort.SliceStable(fields, func(i, j int) bool {
|
|
185
|
+
if fields[i].score == fields[j].score {
|
|
186
|
+
return fields[i].path < fields[j].path
|
|
187
|
+
}
|
|
188
|
+
return fields[i].score > fields[j].score
|
|
189
|
+
})
|
|
190
|
+
var out []string
|
|
191
|
+
for _, f := range fields {
|
|
192
|
+
value, err := json.MarshalIndent(f.value, "", " ")
|
|
193
|
+
if err != nil || len(value) > budget/2 || len(value) < 2 {
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
item := fmt.Sprintf("**JSON Pointer:** `%s`\n\n```json\n%s\n```", f.path, value)
|
|
197
|
+
if len(strings.Join(out, "\n\n"))+len(item) > budget {
|
|
198
|
+
break
|
|
199
|
+
}
|
|
200
|
+
out = append(out, item)
|
|
201
|
+
if len(out) >= 5 {
|
|
202
|
+
break
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if len(out) > 0 {
|
|
206
|
+
return strings.Join(out, "\n\n") + "\n\n*[Selected JSON fields; full document at source URL.]*"
|
|
207
|
+
}
|
|
208
|
+
pretty, err := json.MarshalIndent(raw, "", " ")
|
|
209
|
+
if err != nil {
|
|
210
|
+
return ""
|
|
211
|
+
}
|
|
212
|
+
if len(pretty)+20 <= budget {
|
|
213
|
+
return "```json\n" + string(pretty) + "\n```"
|
|
214
|
+
}
|
|
215
|
+
// No relevant key matched: expose the shape without claiming completeness.
|
|
216
|
+
if obj, ok := raw.(map[string]any); ok {
|
|
217
|
+
keys := make([]string, 0, len(obj))
|
|
218
|
+
for key := range obj {
|
|
219
|
+
keys = append(keys, key)
|
|
220
|
+
}
|
|
221
|
+
sort.Strings(keys)
|
|
222
|
+
if len(keys) > 25 {
|
|
223
|
+
keys = keys[:25]
|
|
224
|
+
}
|
|
225
|
+
return "**Top-level JSON keys:** " + strings.Join(keys, ", ") + " (full document at source URL)"
|
|
226
|
+
}
|
|
227
|
+
return "*[Structured document is larger than the inline budget; inspect original source.]*"
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
func focusedRawJSON(markdown, question string, budget int) string {
|
|
231
|
+
// Raw JSON documents have one JSON fence just after the URL. Looking for
|
|
232
|
+
// JSON examples in a long Markdown guide previously allowed the entire
|
|
233
|
+
// preceding guide to escape the excerpt budget.
|
|
234
|
+
if !strings.HasPrefix(strings.TrimSpace(markdown), "**URL:**") {
|
|
235
|
+
return ""
|
|
236
|
+
}
|
|
237
|
+
begin := strings.Index(markdown, "```json\n")
|
|
238
|
+
if begin < 0 {
|
|
239
|
+
return ""
|
|
240
|
+
}
|
|
241
|
+
prefix := strings.TrimSpace(markdown[:begin])
|
|
242
|
+
if len(prefix) > 400 || strings.Contains(prefix, "\n#") {
|
|
243
|
+
return ""
|
|
244
|
+
}
|
|
245
|
+
jsonStart := begin + len("```json\n")
|
|
246
|
+
end := strings.LastIndex(markdown, "\n```")
|
|
247
|
+
if end <= jsonStart {
|
|
248
|
+
return ""
|
|
249
|
+
}
|
|
250
|
+
var source any
|
|
251
|
+
if json.Unmarshal([]byte(markdown[jsonStart:end]), &source) != nil {
|
|
252
|
+
return ""
|
|
253
|
+
}
|
|
254
|
+
selected := selectedJSONFields(source, question, budget-250)
|
|
255
|
+
if selected == "" {
|
|
256
|
+
return ""
|
|
257
|
+
}
|
|
258
|
+
return strings.TrimSpace(markdown[:begin]) + "\n\n**Focused JSON evidence:**\n\n" + selected
|
|
259
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"io"
|
|
5
|
+
"net/http"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestDirectOpenAPIJSONBypassesScraping(t *testing.T) {
|
|
11
|
+
old := http.DefaultClient
|
|
12
|
+
t.Cleanup(func() { http.DefaultClient = old })
|
|
13
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
14
|
+
if req.URL.Host != "docs.example.com" || req.URL.Path != "/openapi.json" || req.Method != http.MethodGet {
|
|
15
|
+
t.Fatalf("unexpected direct source request: %s", req.URL)
|
|
16
|
+
}
|
|
17
|
+
return testHTTPResponse(req, http.StatusOK, `{"openapi":"3.1.0","paths":{"/v2/scrape":{}}}`, nil), nil
|
|
18
|
+
})}
|
|
19
|
+
got, err := ReadLink("https://docs.example.com/openapi.json")
|
|
20
|
+
if err != nil || !strings.Contains(got, `"openapi":"3.1.0"`) || !strings.Contains(got, "```json") {
|
|
21
|
+
t.Fatalf("did not fetch raw OpenAPI: %q, %v", got, err)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func TestDirectSourceRejectsPrivateRedirects(t *testing.T) {
|
|
26
|
+
old := http.DefaultClient
|
|
27
|
+
t.Cleanup(func() { http.DefaultClient = old })
|
|
28
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
29
|
+
if req.URL.Host != "docs.example.com" {
|
|
30
|
+
t.Fatalf("followed unsafe redirect to %s", req.URL)
|
|
31
|
+
}
|
|
32
|
+
return testHTTPResponse(req, http.StatusFound, "", map[string]string{"Location": "http://127.0.0.1/private"}), nil
|
|
33
|
+
})}
|
|
34
|
+
if _, err := fetchPublicText(t.Context(), "https://docs.example.com/openapi.json", 100); err == nil {
|
|
35
|
+
t.Fatal("should reject public-to-private redirects")
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func TestAlexandriaDiscoveryFallsBackWhenNotEnabled(t *testing.T) {
|
|
40
|
+
old := http.DefaultClient
|
|
41
|
+
t.Cleanup(func() { http.DefaultClient = old })
|
|
42
|
+
var calls int
|
|
43
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
44
|
+
body, _ := io.ReadAll(req.Body)
|
|
45
|
+
calls++
|
|
46
|
+
if calls == 1 {
|
|
47
|
+
if !strings.Contains(string(body), `"domainTools":true`) {
|
|
48
|
+
t.Fatalf("Alexandria discovery not enabled: %s", body)
|
|
49
|
+
}
|
|
50
|
+
return testHTTPResponse(req, http.StatusForbidden, `{"error":"Alexandria unavailable"}`, nil), nil
|
|
51
|
+
}
|
|
52
|
+
if !strings.Contains(string(body), `"domainTools":false`) {
|
|
53
|
+
t.Fatalf("fallback did not disable domain matching: %s", body)
|
|
54
|
+
}
|
|
55
|
+
return testHTTPResponse(req, http.StatusOK, `{"success":true,"data":{"markdown":"# Good source\n\nAuthoritative page content."}}`, nil), nil
|
|
56
|
+
})}
|
|
57
|
+
result, err := firecrawlScrape("https://docs.example.com/reference", "fake-test-key", "")
|
|
58
|
+
if err != nil || calls != 2 || !strings.Contains(result.Markdown, "Good source") {
|
|
59
|
+
t.Fatalf("fallback broke regular scraping: %#v, %v, calls=%d", result, err, calls)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func TestAlexandriaToolDescriptionIsDiscoveryOnly(t *testing.T) {
|
|
64
|
+
got := formatAlexandriaTools([]any{
|
|
65
|
+
map[string]any{"provider": "particle", "capability": "podcasts/episodes/search", "description": "Find episodes"},
|
|
66
|
+
})
|
|
67
|
+
if !strings.Contains(got, "particle/podcasts/episodes/search") || !strings.Contains(got, "not executed") {
|
|
68
|
+
t.Fatalf("bad tool discovery metadata: %s", got)
|
|
69
|
+
}
|
|
70
|
+
}
|
package/internal/app/tools.go
CHANGED
|
@@ -51,6 +51,11 @@ func Search(params SearchParams) (string, error) {
|
|
|
51
51
|
allExcludedDomains := append(append([]string{}, defaultExcludedDomains...), params.ExcludeDomains...)
|
|
52
52
|
truncatedKeyword := truncateWords(params.IncludeKeyword, 5)
|
|
53
53
|
|
|
54
|
+
// Free Alexandria tool discovery proceeds alongside normal web search.
|
|
55
|
+
// It never executes a paid tool or replaces source-grounded web results.
|
|
56
|
+
alexandriaDone := make(chan string, 1)
|
|
57
|
+
go func() { alexandriaDone <- discoverAlexandria(params.Query) }()
|
|
58
|
+
|
|
54
59
|
type namedSearch struct {
|
|
55
60
|
name string
|
|
56
61
|
fn func(context.Context) (SearchResult, error)
|
|
@@ -118,6 +123,15 @@ func Search(params SearchParams) (string, error) {
|
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
parts := []string{fmt.Sprintf("Total Results: %d\n", len(ranked))}
|
|
126
|
+
// The ranked index remains available, but agents also get useful source
|
|
127
|
+
// context without having to issue several follow-up read-link calls.
|
|
128
|
+
if context := enrichSearch(params.Query, ranked); context != "" {
|
|
129
|
+
parts = append(parts, context, "")
|
|
130
|
+
}
|
|
131
|
+
if tools := <-alexandriaDone; tools != "" {
|
|
132
|
+
parts = append(parts, "", "## Related data providers", tools, "")
|
|
133
|
+
}
|
|
134
|
+
parts = append(parts, "## Search results", "")
|
|
121
135
|
for _, doc := range ranked {
|
|
122
136
|
title := decodeHTML(doc.Title)
|
|
123
137
|
overview := decodeHTML(doc.Overview)
|
|
@@ -153,12 +167,16 @@ func ReadLink(rawURL string) (string, error) {
|
|
|
153
167
|
return "", native.Err
|
|
154
168
|
}
|
|
155
169
|
|
|
156
|
-
if
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
170
|
+
if direct, ok := readDirectSource(rawURL); ok {
|
|
171
|
+
return direct, nil
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
nativeStarted := time.Now()
|
|
175
|
+
if result, err := readNativeMarkdown(rawURL); err == nil && result != nil {
|
|
176
|
+
traceRetrieval(rawURL, "native-markdown", "success", nativeStarted, nil)
|
|
177
|
+
return formatReadLink(result.Title, result.URL, "**Markdown source:** "+result.SourceURL+"\n\n"+result.Markdown), nil
|
|
161
178
|
}
|
|
179
|
+
traceRetrieval(rawURL, "native-markdown", "miss", nativeStarted, nil)
|
|
162
180
|
|
|
163
181
|
title, markdown, err := scrapeLinkWithFirecrawl(rawURL)
|
|
164
182
|
if err != nil {
|
|
@@ -199,28 +217,37 @@ func scrapeLinkWithFirecrawl(rawURL string) (string, string, error) {
|
|
|
199
217
|
// Firecrawl's default auto proxy already tries basic then enhanced when the
|
|
200
218
|
// transport fails. A second explicit enhanced attempt is only useful when a
|
|
201
219
|
// scrape responds successfully but returns a challenge/empty document.
|
|
220
|
+
started := time.Now()
|
|
202
221
|
first, err := firecrawlScrape(rawURL, apiKey, "")
|
|
203
222
|
if err == nil && usableScrapedMarkdown(first.Markdown) {
|
|
204
|
-
|
|
223
|
+
traceRetrieval(rawURL, "firecrawl-auto", "success", started, nil)
|
|
224
|
+
return first.Title, withSourceTools(first.Markdown, first.ToolSummary), nil
|
|
205
225
|
}
|
|
206
226
|
lastError := err
|
|
227
|
+
traceRetrieval(rawURL, "firecrawl-auto", "escalate", started, err)
|
|
207
228
|
if err == nil {
|
|
208
|
-
lastError = fmt.Errorf("Firecrawl returned empty or blocked page content")
|
|
229
|
+
lastError = fmt.Errorf("Firecrawl returned empty or blocked page content: %s", challengeReason(first.Markdown))
|
|
230
|
+
started = time.Now()
|
|
209
231
|
second, enhancedErr := firecrawlScrape(rawURL, apiKey, "enhanced")
|
|
210
232
|
if enhancedErr == nil && usableScrapedMarkdown(second.Markdown) {
|
|
211
|
-
|
|
233
|
+
traceRetrieval(rawURL, "firecrawl-enhanced", "success", started, nil)
|
|
234
|
+
return second.Title, withSourceTools(second.Markdown, second.ToolSummary), nil
|
|
212
235
|
}
|
|
236
|
+
traceRetrieval(rawURL, "firecrawl-enhanced", "escalate", started, enhancedErr)
|
|
213
237
|
if enhancedErr != nil {
|
|
214
238
|
lastError = enhancedErr
|
|
215
239
|
}
|
|
216
240
|
}
|
|
241
|
+
started = time.Now()
|
|
217
242
|
title, markdown, browserErr := firecrawlBrowserFallback(rawURL, apiKey)
|
|
218
243
|
if browserErr == nil && usableScrapedMarkdown(markdown) {
|
|
244
|
+
traceRetrieval(rawURL, "browser", "success", started, nil)
|
|
219
245
|
return title, markdown, nil
|
|
220
246
|
}
|
|
221
247
|
if browserErr == nil {
|
|
222
|
-
browserErr = fmt.Errorf("browser produced no usable content")
|
|
248
|
+
browserErr = fmt.Errorf("browser produced no usable content: %s", challengeReason(markdown))
|
|
223
249
|
}
|
|
250
|
+
traceRetrieval(rawURL, "browser", "blocked", started, browserErr)
|
|
224
251
|
return "", "", fmt.Errorf("Error reading web page: scrape: %v; browser fallback: %w", lastError, browserErr)
|
|
225
252
|
}
|
|
226
253
|
|
package/package.json
CHANGED
package/pkg/origo/server.go
CHANGED
|
@@ -18,14 +18,20 @@ type linkInput struct {
|
|
|
18
18
|
URL string `json:"url" jsonschema:"Absolute HTTP(S) URL to read as source-grounded Markdown"`
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
type readLinkInput struct {
|
|
22
|
+
URL string `json:"url" jsonschema:"Absolute HTTP(S) URL to read as source-grounded Markdown"`
|
|
23
|
+
Question string `json:"question,omitempty" jsonschema:"Optional question or task. When set, automatically follows relevant source links and returns focused, cited context."`
|
|
24
|
+
}
|
|
25
|
+
|
|
21
26
|
func makeServer() *mcp.Server {
|
|
22
27
|
s := mcp.NewServer(&mcp.Implementation{Name: "origo", Version: "0.1.0"}, nil)
|
|
23
28
|
mcp.AddTool(s, &mcp.Tool{
|
|
24
29
|
Name: "read_link",
|
|
25
30
|
Title: "Read link",
|
|
26
|
-
Description: "Read a
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
Description: "Read a URL as source-grounded Markdown. Optionally provide a question to automatically explore related docs and return relevant API contracts, code examples and precise source URLs. No search query required; no paid Alexandria tool execution.",
|
|
32
|
+
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
|
|
33
|
+
}, func(_ context.Context, _ *mcp.CallToolRequest, input readLinkInput) (*mcp.CallToolResult, any, error) {
|
|
34
|
+
result, err := retrieval.ReadLinkFocused(input.URL, input.Question)
|
|
29
35
|
if err != nil {
|
|
30
36
|
return nil, nil, err
|
|
31
37
|
}
|
|
@@ -35,6 +41,7 @@ func makeServer() *mcp.Server {
|
|
|
35
41
|
Name: "map_site",
|
|
36
42
|
Title: "Map site",
|
|
37
43
|
Description: "Discover URLs on a site using Firecrawl's sitemap-based map API. Does not perform web search.",
|
|
44
|
+
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
|
|
38
45
|
}, func(_ context.Context, _ *mcp.CallToolRequest, input linkInput) (*mcp.CallToolResult, any, error) {
|
|
39
46
|
result, err := retrieval.MapSite(input.URL)
|
|
40
47
|
if err != nil {
|
package/pkg/origo/server_test.go
CHANGED
|
@@ -60,4 +60,19 @@ func TestExactlyTwoToolsAndNoSearch(t *testing.T) {
|
|
|
60
60
|
if count := strings.Count(text, `"name":`); count != 2 {
|
|
61
61
|
t.Fatalf("expected exactly 2 tools, got %d: %s", count, text)
|
|
62
62
|
}
|
|
63
|
+
if count := strings.Count(text, `"readOnlyHint":true`); count != 2 {
|
|
64
|
+
t.Fatalf("expected both tools to declare read-only annotations, got %d: %s", count, text)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func TestQuestionIsOptionalOnReadLinkOnly(t *testing.T) {
|
|
69
|
+
t.Setenv("ORIGO_API_KEY", "secret")
|
|
70
|
+
response := request(t, "secret", `{"jsonrpc":"2.0","id":4,"method":"tools/list","params":{}}`)
|
|
71
|
+
if response.Code != http.StatusOK {
|
|
72
|
+
t.Fatalf("list tools: %s", response.Body.String())
|
|
73
|
+
}
|
|
74
|
+
text := response.Body.String()
|
|
75
|
+
if !strings.Contains(text, `"question"`) || !strings.Contains(text, `"required":["url"]`) {
|
|
76
|
+
t.Fatalf("question should be optional; URL remains required: %s", text)
|
|
77
|
+
}
|
|
63
78
|
}
|
|
@@ -8,4 +8,10 @@ import "github.com/amxv/webctx/internal/app"
|
|
|
8
8
|
|
|
9
9
|
func ReadLink(url string) (string, error) { return app.ReadLink(url) }
|
|
10
10
|
|
|
11
|
+
// ReadLinkFocused follows related documentation when the caller has a question.
|
|
12
|
+
// A URL-only call retains the original complete-document behavior.
|
|
13
|
+
func ReadLinkFocused(url, question string) (string, error) {
|
|
14
|
+
return app.ReadLinkFocused(url, question)
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
func MapSite(url string) (string, error) { return app.MapSite(url) }
|