webctx 0.2.5 → 0.2.7
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/README.md +4 -4
- package/internal/app/app.go +34 -11
- package/internal/app/data_audit_test.go +433 -0
- package/internal/app/data_client.go +196 -14
- package/internal/app/data_contract_constraints.go +65 -0
- package/internal/app/data_examples.go +185 -0
- package/internal/app/data_execute.go +231 -79
- package/internal/app/data_inspect.go +21 -40
- package/internal/app/data_pagination.go +126 -0
- package/internal/app/data_replay.go +83 -0
- package/internal/app/data_research.go +178 -6
- package/internal/app/data_tools_test.go +124 -16
- package/internal/app/data_types.go +38 -16
- package/internal/app/research.go +24 -5
- package/internal/app/research_answers.go +30 -0
- package/internal/app/research_copyable.go +48 -0
- package/internal/app/research_copyable_test.go +57 -0
- package/internal/app/site_map_page.go +256 -0
- package/internal/app/tools.go +16 -46
- package/package.json +1 -1
- package/pkg/origo/data_test.go +31 -5
- package/pkg/origo/server.go +106 -28
- package/pkg/origo/server_test.go +56 -4
- package/pkg/retrieval/data.go +3 -0
package/internal/app/research.go
CHANGED
|
@@ -87,6 +87,10 @@ 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)
|
|
91
|
+
if answer, ok := directlyAnsweredMetadata(rawURL, question, primary); ok {
|
|
92
|
+
return answer, nil
|
|
93
|
+
}
|
|
90
94
|
|
|
91
95
|
// Combine on-page links and authoritative Markdown index links instead of
|
|
92
96
|
// filling the entire budget from whichever links happen to appear first.
|
|
@@ -94,7 +98,7 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
|
|
|
94
98
|
// SQLite, webhook, and API references over incidental alarm/concept pages.
|
|
95
99
|
links := researchCandidates(rawURL, primary, question, maxRelatedSources)
|
|
96
100
|
structuredDone := make(chan string, 1)
|
|
97
|
-
if strings.Contains(primary, "**Markdown source:**") {
|
|
101
|
+
if strings.Contains(primary, "**Markdown source:**") || !asksForAPIContracts(question) {
|
|
98
102
|
structuredDone <- ""
|
|
99
103
|
} else {
|
|
100
104
|
go func() { structuredDone <- embeddedStructuredContext(rawURL, question) }()
|
|
@@ -109,7 +113,11 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
|
|
|
109
113
|
"**Source:** " + rawURL,
|
|
110
114
|
"",
|
|
111
115
|
}
|
|
112
|
-
|
|
116
|
+
primaryBudget := focusedPrimaryBudget
|
|
117
|
+
if asksForAPIContracts(question) {
|
|
118
|
+
primaryBudget = 5300
|
|
119
|
+
}
|
|
120
|
+
primaryExcerpt := focusExcerpt(primary, question, primaryBudget)
|
|
113
121
|
parts = append(parts, primaryExcerpt)
|
|
114
122
|
successful := 1
|
|
115
123
|
relatedSuccessful := 0
|
|
@@ -124,7 +132,11 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
|
|
|
124
132
|
relatedSuccessful++
|
|
125
133
|
seenURLs[source.url] = true
|
|
126
134
|
allRetrieved += "\n" + source.content
|
|
127
|
-
|
|
135
|
+
relatedBudget := relatedExcerptBudget(source.url)
|
|
136
|
+
if asksForAPIContracts(question) && relatedBudget > 3000 {
|
|
137
|
+
relatedBudget = 3000
|
|
138
|
+
}
|
|
139
|
+
excerpt := focusExcerpt(copyableCurlExamples(source.content), question, relatedBudget)
|
|
128
140
|
outputEvidence += "\n" + excerpt
|
|
129
141
|
parts = append(parts, "", "## Related source", "**Source:** "+source.url, "", excerpt)
|
|
130
142
|
}
|
|
@@ -172,7 +184,11 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
|
|
|
172
184
|
successful++
|
|
173
185
|
relatedSuccessful++
|
|
174
186
|
allRetrieved += "\n" + source.content
|
|
175
|
-
|
|
187
|
+
followupBudget := relatedExcerptBudget(source.url)
|
|
188
|
+
if asksForAPIContracts(question) && followupBudget > 3000 {
|
|
189
|
+
followupBudget = 3000
|
|
190
|
+
}
|
|
191
|
+
excerpt := focusExcerpt(copyableCurlExamples(source.content), question, followupBudget)
|
|
176
192
|
outputEvidence += "\n" + excerpt
|
|
177
193
|
parts = append(parts, "", "## Supporting implementation reference", "**Source:** "+source.url, "", excerpt)
|
|
178
194
|
}
|
|
@@ -181,7 +197,10 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
|
|
|
181
197
|
if contract := <-openAPIDone; contract.Content != "" {
|
|
182
198
|
successful++
|
|
183
199
|
outputEvidence += "\n" + contract.Content
|
|
184
|
-
|
|
200
|
+
// Put exact REST contracts first so coding agents encounter the
|
|
201
|
+
// implementation-ready operations before lengthy source excerpts.
|
|
202
|
+
front := []string{"## REST API contract", "**Source:** " + contract.URL, "", contract.Content, ""}
|
|
203
|
+
parts = append(append(append([]string(nil), parts[:4]...), front...), parts[4:]...)
|
|
185
204
|
}
|
|
186
205
|
}
|
|
187
206
|
if structured := <-structuredDone; structured != "" {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"regexp"
|
|
6
|
+
"strings"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
var githubLicenseField = regexp.MustCompile(`(?mi)^license:\s*["']?([A-Za-z0-9.+-]+)["']?\s*$`)
|
|
10
|
+
var repositoryLicenseURL = regexp.MustCompile(`https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/blob/[A-Za-z0-9_.-]+/(?:LICENSE|COPYING)(?:\.md|\.txt)?`)
|
|
11
|
+
|
|
12
|
+
func directlyAnsweredMetadata(rawURL, question, markdown string) (string, bool) {
|
|
13
|
+
q := strings.ToLower(question)
|
|
14
|
+
if !strings.Contains(q, "license") || !strings.Contains(strings.ToLower(rawURL), "github.com/") {
|
|
15
|
+
return "", false
|
|
16
|
+
}
|
|
17
|
+
match := githubLicenseField.FindStringSubmatch(markdown)
|
|
18
|
+
if len(match) != 2 {
|
|
19
|
+
return "", false
|
|
20
|
+
}
|
|
21
|
+
value := match[1]
|
|
22
|
+
evidenceURL := rawURL
|
|
23
|
+
if source := repositoryLicenseURL.FindString(markdown); source != "" {
|
|
24
|
+
evidenceURL = source
|
|
25
|
+
}
|
|
26
|
+
return fmt.Sprintf(
|
|
27
|
+
"## Answer\n\nThe repository lists its license as **%s**.\n\n**Source:** %s\n\n**Evidence:** GitHub repository metadata lists `license: %q`.\n",
|
|
28
|
+
value, evidenceURL, value,
|
|
29
|
+
), true
|
|
30
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"encoding/hex"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"fmt"
|
|
9
|
+
"net/http"
|
|
10
|
+
"net/url"
|
|
11
|
+
"os"
|
|
12
|
+
"sort"
|
|
13
|
+
"strings"
|
|
14
|
+
"sync"
|
|
15
|
+
"time"
|
|
16
|
+
|
|
17
|
+
"golang.org/x/sync/singleflight"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// MapSiteInput retrieves an agent-sized page rather than dumping thousands
|
|
21
|
+
// of URLs. An omitted language prefers English; "all" allows all locales.
|
|
22
|
+
type MapSiteInput struct {
|
|
23
|
+
URL string `json:"url" jsonschema:"Website URL to map; HTTP(S) only"`
|
|
24
|
+
Query string `json:"query,omitempty" jsonschema:"Optional topic to filter and rank discovered pages"`
|
|
25
|
+
Limit int `json:"limit,omitempty" jsonschema:"Number of URLs to return, 1-100; defaults to 30"`
|
|
26
|
+
Offset int `json:"offset,omitempty" jsonschema:"Pagination offset, zero-based"`
|
|
27
|
+
PathPrefix string `json:"path_prefix,omitempty" jsonschema:"Optional URL path prefix, e.g. /api/"`
|
|
28
|
+
Language string `json:"language,omitempty" jsonschema:"Locale code such as en or fr; defaults to English, use all for all languages"`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type siteMapCacheValue struct {
|
|
32
|
+
links []any
|
|
33
|
+
expires time.Time
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
var siteMapCache = struct {
|
|
37
|
+
sync.Mutex
|
|
38
|
+
items map[string]siteMapCacheValue
|
|
39
|
+
}{items: map[string]siteMapCacheValue{}}
|
|
40
|
+
var siteMapFlight singleflight.Group
|
|
41
|
+
|
|
42
|
+
func rawMappedLinks(ctx context.Context, rawURL string) ([]any, error) {
|
|
43
|
+
if err := validateSourceURL(rawURL); err != nil {
|
|
44
|
+
return nil, &DataError{Code: "invalid_url", Message: err.Error()}
|
|
45
|
+
}
|
|
46
|
+
credential := strings.TrimSpace(os.Getenv("FIRECRAWL_API_KEY"))
|
|
47
|
+
if credential == "" {
|
|
48
|
+
return nil, missingCredentialsError("Error mapping website", []string{"FIRECRAWL_API_KEY"}, "")
|
|
49
|
+
}
|
|
50
|
+
digest := sha256.Sum256([]byte(credential + "\x00" + rawURL))
|
|
51
|
+
cacheKey := hex.EncodeToString(digest[:])
|
|
52
|
+
useCache := !strings.HasPrefix(credential, "test")
|
|
53
|
+
if useCache {
|
|
54
|
+
siteMapCache.Lock()
|
|
55
|
+
cached, ok := siteMapCache.items[cacheKey]
|
|
56
|
+
siteMapCache.Unlock()
|
|
57
|
+
if ok && time.Now().Before(cached.expires) {
|
|
58
|
+
return cached.links, nil
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
fetch := func() (any, error) {
|
|
62
|
+
request := map[string]any{
|
|
63
|
+
"url": rawURL, "sitemap": "include",
|
|
64
|
+
"includeSubdomains": true, "ignoreQueryParameters": true, "limit": 5000,
|
|
65
|
+
}
|
|
66
|
+
body, err := doJSONRequest(ctx, http.MethodPost, "https://api.firecrawl.dev/v2/map", map[string]string{
|
|
67
|
+
"Authorization": "Bearer " + credential, "Content-Type": "application/json",
|
|
68
|
+
}, request)
|
|
69
|
+
if err != nil {
|
|
70
|
+
return nil, fmt.Errorf("Error mapping website: %w", err)
|
|
71
|
+
}
|
|
72
|
+
var result map[string]any
|
|
73
|
+
if err := json.Unmarshal(body, &result); err != nil {
|
|
74
|
+
return nil, fmt.Errorf("Error decoding map: %w", err)
|
|
75
|
+
}
|
|
76
|
+
if result["success"] != true {
|
|
77
|
+
return nil, dataError("map_failed", fmt.Sprintf("The source could not be mapped: %v", result["error"]))
|
|
78
|
+
}
|
|
79
|
+
links := listField(result["links"])
|
|
80
|
+
if useCache {
|
|
81
|
+
siteMapCache.Lock()
|
|
82
|
+
if len(siteMapCache.items) > 50 {
|
|
83
|
+
siteMapCache.items = map[string]siteMapCacheValue{}
|
|
84
|
+
}
|
|
85
|
+
siteMapCache.items[cacheKey] = siteMapCacheValue{links: links, expires: time.Now().Add(3 * time.Minute)}
|
|
86
|
+
siteMapCache.Unlock()
|
|
87
|
+
}
|
|
88
|
+
return links, nil
|
|
89
|
+
}
|
|
90
|
+
if !useCache {
|
|
91
|
+
data, err := fetch()
|
|
92
|
+
if err != nil {
|
|
93
|
+
return nil, err
|
|
94
|
+
}
|
|
95
|
+
return data.([]any), nil
|
|
96
|
+
}
|
|
97
|
+
data, err, _ := siteMapFlight.Do(cacheKey, fetch)
|
|
98
|
+
if err != nil {
|
|
99
|
+
return nil, err
|
|
100
|
+
}
|
|
101
|
+
return data.([]any), nil
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
var knownSiteLocales = map[string]bool{
|
|
105
|
+
"en": true, "fr": true, "es": true, "de": true, "ja": true, "zh": true, "zh-cn": true, "zh-tw": true,
|
|
106
|
+
"pt": true, "pt-br": true, "it": true, "ko": true, "ru": true, "ar": true, "hi": true, "id": true,
|
|
107
|
+
"nl": true, "pl": true, "tr": true, "vi": true, "th": true, "sv": true, "da": true,
|
|
108
|
+
"es-es": true, "en-us": true, "en-gb": true, "fr-fr": true, "fr-ca": true,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func mapPageLocale(raw string) string {
|
|
112
|
+
u, err := url.Parse(raw)
|
|
113
|
+
if err != nil {
|
|
114
|
+
return "en"
|
|
115
|
+
}
|
|
116
|
+
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
|
117
|
+
if len(parts) > 0 {
|
|
118
|
+
first := strings.ToLower(parts[0])
|
|
119
|
+
if knownSiteLocales[first] {
|
|
120
|
+
if strings.HasPrefix(first, "en") {
|
|
121
|
+
return "en"
|
|
122
|
+
}
|
|
123
|
+
if strings.HasPrefix(first, "zh") {
|
|
124
|
+
return "zh"
|
|
125
|
+
}
|
|
126
|
+
if strings.HasPrefix(first, "es") {
|
|
127
|
+
return "es"
|
|
128
|
+
}
|
|
129
|
+
if strings.HasPrefix(first, "fr") {
|
|
130
|
+
return "fr"
|
|
131
|
+
}
|
|
132
|
+
if strings.HasPrefix(first, "pt") {
|
|
133
|
+
return "pt"
|
|
134
|
+
}
|
|
135
|
+
return first
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return "en"
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func MapSitePage(input MapSiteInput) (map[string]any, error) {
|
|
142
|
+
if input.Limit == 0 {
|
|
143
|
+
input.Limit = 30
|
|
144
|
+
}
|
|
145
|
+
if input.Limit < 1 || input.Limit > 100 {
|
|
146
|
+
return nil, dataError("invalid_limit", "map_site limit must be 1-100.")
|
|
147
|
+
}
|
|
148
|
+
if input.Offset < 0 || input.Offset > 100000 {
|
|
149
|
+
return nil, dataError("invalid_offset", "map_site offset must be nonnegative.")
|
|
150
|
+
}
|
|
151
|
+
if input.Language == "" {
|
|
152
|
+
input.Language = "en"
|
|
153
|
+
}
|
|
154
|
+
input.Language = strings.ToLower(strings.TrimSpace(input.Language))
|
|
155
|
+
if input.PathPrefix != "" && !strings.HasPrefix(input.PathPrefix, "/") {
|
|
156
|
+
input.PathPrefix = "/" + input.PathPrefix
|
|
157
|
+
}
|
|
158
|
+
if err := validateSourceURL(input.URL); err != nil {
|
|
159
|
+
return nil, &DataError{Code: "invalid_url", Message: err.Error()}
|
|
160
|
+
}
|
|
161
|
+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
162
|
+
defer cancel()
|
|
163
|
+
links, err := rawMappedLinks(ctx, input.URL)
|
|
164
|
+
if err != nil {
|
|
165
|
+
return nil, err
|
|
166
|
+
}
|
|
167
|
+
type located struct {
|
|
168
|
+
entry map[string]any
|
|
169
|
+
score int
|
|
170
|
+
path string
|
|
171
|
+
}
|
|
172
|
+
var matched []located
|
|
173
|
+
seen := map[string]bool{}
|
|
174
|
+
terms := queryTerms(input.Query)
|
|
175
|
+
for _, v := range links {
|
|
176
|
+
item := map[string]any{}
|
|
177
|
+
if s, ok := v.(string); ok {
|
|
178
|
+
item["url"] = s
|
|
179
|
+
} else {
|
|
180
|
+
item = mapField(v)
|
|
181
|
+
}
|
|
182
|
+
raw := stringField(item["url"])
|
|
183
|
+
if raw == "" || seen[raw] {
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
seen[raw] = true
|
|
187
|
+
u, e := url.Parse(raw)
|
|
188
|
+
if e != nil || u.Host == "" {
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
if input.Language != "all" && mapPageLocale(raw) != input.Language {
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
if input.PathPrefix != "" && !strings.HasPrefix(strings.ToLower(u.Path), strings.ToLower(input.PathPrefix)) {
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
name := stringField(item["title"])
|
|
198
|
+
desc := stringField(item["description"])
|
|
199
|
+
score := termScore(u.Path+" "+name, terms)*3 + termScore(desc, terms)
|
|
200
|
+
if len(terms) > 0 && score == 0 {
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
clean := map[string]any{"url": raw}
|
|
204
|
+
if name != "" {
|
|
205
|
+
clean["title"] = boundedSiteText(name, 140)
|
|
206
|
+
}
|
|
207
|
+
if desc != "" {
|
|
208
|
+
clean["description"] = boundedSiteText(desc, 230)
|
|
209
|
+
}
|
|
210
|
+
matched = append(matched, located{entry: clean, score: score, path: u.Path})
|
|
211
|
+
}
|
|
212
|
+
sort.SliceStable(matched, func(i, j int) bool {
|
|
213
|
+
if len(terms) > 0 && matched[i].score != matched[j].score {
|
|
214
|
+
return matched[i].score > matched[j].score
|
|
215
|
+
}
|
|
216
|
+
if depthI, depthJ := strings.Count(matched[i].path, "/"), strings.Count(matched[j].path, "/"); depthI != depthJ {
|
|
217
|
+
return depthI < depthJ
|
|
218
|
+
}
|
|
219
|
+
return matched[i].path < matched[j].path
|
|
220
|
+
})
|
|
221
|
+
start := input.Offset
|
|
222
|
+
if start > len(matched) {
|
|
223
|
+
start = len(matched)
|
|
224
|
+
}
|
|
225
|
+
end := start + input.Limit
|
|
226
|
+
if end > len(matched) {
|
|
227
|
+
end = len(matched)
|
|
228
|
+
}
|
|
229
|
+
page := make([]map[string]any, 0, end-start)
|
|
230
|
+
for _, entry := range matched[start:end] {
|
|
231
|
+
page = append(page, entry.entry)
|
|
232
|
+
}
|
|
233
|
+
output := map[string]any{
|
|
234
|
+
"url": input.URL, "total_discovered": len(links),
|
|
235
|
+
"total_matching": len(matched), "links": page,
|
|
236
|
+
"count": len(page), "offset": start, "limit": input.Limit,
|
|
237
|
+
"language": input.Language, "provider_page_ceiling": 5000,
|
|
238
|
+
"may_be_incomplete": len(links) >= 5000,
|
|
239
|
+
"guidance": "Use next to request another bounded page, or set query/path_prefix/language to narrow results. Language=all includes translated pages.",
|
|
240
|
+
}
|
|
241
|
+
if end < len(matched) {
|
|
242
|
+
output["next"] = map[string]any{"tool": "map_site", "arguments": MapSiteInput{
|
|
243
|
+
URL: input.URL, Query: input.Query, Limit: input.Limit, Offset: end,
|
|
244
|
+
PathPrefix: input.PathPrefix, Language: input.Language,
|
|
245
|
+
}}
|
|
246
|
+
}
|
|
247
|
+
return output, nil
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
func boundedSiteText(value string, max int) string {
|
|
251
|
+
value = strings.Join(strings.Fields(value), " ")
|
|
252
|
+
if len(value) > max {
|
|
253
|
+
return value[:max] + "…"
|
|
254
|
+
}
|
|
255
|
+
return value
|
|
256
|
+
}
|
package/internal/app/tools.go
CHANGED
|
@@ -252,57 +252,27 @@ func scrapeLinkWithFirecrawl(rawURL string) (string, string, error) {
|
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
func MapSite(rawURL string) (string, error) {
|
|
255
|
-
|
|
255
|
+
data, err := MapSitePage(MapSiteInput{URL: rawURL})
|
|
256
|
+
if err != nil {
|
|
256
257
|
return "", err
|
|
257
258
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
259
|
+
parts := []string{
|
|
260
|
+
fmt.Sprintf("total urls found: %v", data["total_discovered"]),
|
|
261
|
+
fmt.Sprintf("matching pages: %v; displayed: %v (offset %v)", data["total_matching"], data["count"], data["offset"]),
|
|
262
|
+
"",
|
|
261
263
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
"
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
"
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
272
|
-
defer cancel()
|
|
273
|
-
body, err := doJSONRequest(ctx, http.MethodPost, "https://api.firecrawl.dev/v2/map", map[string]string{
|
|
274
|
-
"Authorization": "Bearer " + apiKey,
|
|
275
|
-
"Content-Type": "application/json",
|
|
276
|
-
}, requestBody)
|
|
277
|
-
if err != nil {
|
|
278
|
-
return "", fmt.Errorf("Error mapping website: %v", err)
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
var parsed map[string]any
|
|
282
|
-
if err := json.Unmarshal(body, &parsed); err != nil {
|
|
283
|
-
return "", fmt.Errorf("Error mapping website: %v", err)
|
|
284
|
-
}
|
|
285
|
-
if success, _ := parsed["success"].(bool); !success {
|
|
286
|
-
return "", fmt.Errorf("Error mapping website: Mapping failed for %s: %v", rawURL, parsed["error"])
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
linksAny, _ := parsed["links"].([]any)
|
|
290
|
-
parts := []string{fmt.Sprintf("total urls found: %d", len(linksAny)), ""}
|
|
291
|
-
for _, item := range linksAny {
|
|
292
|
-
switch link := item.(type) {
|
|
293
|
-
case string:
|
|
294
|
-
parts = append(parts, "- "+link, "")
|
|
295
|
-
case map[string]any:
|
|
296
|
-
parts = append(parts, "- "+stringValue(link["url"]))
|
|
297
|
-
if v := stringValue(link["title"]); v != "" {
|
|
298
|
-
parts = append(parts, "- "+v)
|
|
299
|
-
}
|
|
300
|
-
if v := stringValue(link["description"]); v != "" {
|
|
301
|
-
parts = append(parts, "- "+v)
|
|
302
|
-
}
|
|
303
|
-
parts = append(parts, "")
|
|
264
|
+
for _, entry := range data["links"].([]map[string]any) {
|
|
265
|
+
parts = append(parts, "- "+stringField(entry["url"]))
|
|
266
|
+
if title := stringField(entry["title"]); title != "" {
|
|
267
|
+
parts = append(parts, " - "+title)
|
|
268
|
+
}
|
|
269
|
+
if desc := stringField(entry["description"]); desc != "" {
|
|
270
|
+
parts = append(parts, " - "+desc)
|
|
304
271
|
}
|
|
305
272
|
}
|
|
273
|
+
if data["next"] != nil {
|
|
274
|
+
parts = append(parts, "", "More pages available; use --offset with webctx map-site for the next page.")
|
|
275
|
+
}
|
|
306
276
|
return strings.Join(parts, "\n"), nil
|
|
307
277
|
}
|
|
308
278
|
|
package/package.json
CHANGED
package/pkg/origo/data_test.go
CHANGED
|
@@ -49,26 +49,52 @@ func TestDataToolErrorsAreActionableStructuredMCPResults(t *testing.T) {
|
|
|
49
49
|
{"research", map[string]any{"view": "invalid"}, "invalid_view"},
|
|
50
50
|
{"inspect", map[string]any{"id": "not-a-valid-operation/"}, "invalid_operation"},
|
|
51
51
|
{"execute", map[string]any{"calls": []any{}}, "invalid_calls"},
|
|
52
|
+
{"execute", map[string]any{"calls": []any{map[string]any{"id": "example/data/list"}}, "max_credits": 1}, "invalid_arguments"},
|
|
52
53
|
} {
|
|
53
54
|
response := toolResponse(t, tc.name, tc.input, 11)
|
|
54
|
-
if response["isError"]
|
|
55
|
-
t.Fatalf("%s should return
|
|
55
|
+
if response["isError"] == true {
|
|
56
|
+
t.Fatalf("%s should return normal structured response, not an MCP error: %#v", tc.name, response)
|
|
56
57
|
}
|
|
57
58
|
output, ok := response["structuredContent"].(map[string]any)
|
|
58
59
|
if !ok {
|
|
59
60
|
t.Fatalf("%s did not preserve structuredContent: %#v", tc.name, response)
|
|
60
61
|
}
|
|
61
62
|
errorResult, ok := output["error"].(map[string]any)
|
|
62
|
-
if !ok || errorResult["code"] != tc.code {
|
|
63
|
+
if !ok || errorResult["code"] != tc.code || output["ok"] != false {
|
|
63
64
|
t.Fatalf("%s error should be %s: %#v", tc.name, tc.code, response)
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
func TestAuditMCPRejectsZeroLimitAndInvalidSourceURLsWithoutExceptions(t *testing.T) {
|
|
70
|
+
t.Setenv("ORIGO_API_KEY", "secret")
|
|
71
|
+
for _, tt := range []struct {
|
|
72
|
+
name string
|
|
73
|
+
args map[string]any
|
|
74
|
+
code string
|
|
75
|
+
}{
|
|
76
|
+
{"research", map[string]any{"mode": "catalogue", "view": "sources", "limit": 0}, "invalid_limit"},
|
|
77
|
+
{"read_link", map[string]any{"url": "file:///etc/passwd", "question": "summarize"}, "invalid_url"},
|
|
78
|
+
{"map_site", map[string]any{"url": "file:///etc/passwd"}, "invalid_url"},
|
|
79
|
+
} {
|
|
80
|
+
reply := toolResponse(t, tt.name, tt.args, 990)
|
|
81
|
+
if reply["isError"] == true {
|
|
82
|
+
t.Fatalf("%s surfaced a host error: %+v", tt.name, reply)
|
|
83
|
+
}
|
|
84
|
+
data, ok := reply["structuredContent"].(map[string]any)
|
|
85
|
+
if !ok || data["ok"] != false {
|
|
86
|
+
t.Fatalf("%s did not return a structured error: %+v", tt.name, reply)
|
|
87
|
+
}
|
|
88
|
+
failure := data["error"].(map[string]any)
|
|
89
|
+
if failure["code"] != tt.code {
|
|
90
|
+
t.Fatalf("%s expected %s, got %+v", tt.name, tt.code, failure)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
68
95
|
func TestDataToolResearchInspectExecuteOverHTTP(t *testing.T) {
|
|
69
96
|
t.Setenv("ORIGO_API_KEY", "secret")
|
|
70
97
|
t.Setenv("FIRECRAWL_API_KEY", "test")
|
|
71
|
-
t.Setenv("WEBCTX_ALEXANDRIA_PAID", "true")
|
|
72
98
|
original := http.DefaultClient
|
|
73
99
|
t.Cleanup(func() { http.DefaultClient = original })
|
|
74
100
|
var paidCount int
|
|
@@ -126,7 +152,7 @@ func TestDataToolResearchInspectExecuteOverHTTP(t *testing.T) {
|
|
|
126
152
|
}
|
|
127
153
|
result := toolResponse(t, "execute", map[string]any{"calls": []any{
|
|
128
154
|
map[string]any{"id": "example/data/list", "inputs": map[string]any{"query": "forty two"}},
|
|
129
|
-
}
|
|
155
|
+
}}, 14)
|
|
130
156
|
final := result["structuredContent"].(map[string]any)
|
|
131
157
|
if result["isError"] == true || final["credits_used"] != float64(2) || paidCount != 1 {
|
|
132
158
|
t.Fatalf("paid query failed: %#v calls=%d", final, paidCount)
|