webctx 0.2.1 → 0.2.3
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 +9 -4
- package/CONTRIBUTORS.md +17 -16
- package/README.md +17 -2
- package/go.mod +13 -0
- package/go.sum +24 -0
- package/internal/app/alexandria.go +102 -0
- package/internal/app/app.go +4 -3
- package/internal/app/firecrawl_fallback.go +176 -0
- package/internal/app/firecrawl_fallback_test.go +83 -0
- package/internal/app/github_test.go +4 -5
- package/internal/app/research.go +526 -0
- package/internal/app/research_excerpt.go +149 -0
- package/internal/app/research_excerpt_test.go +47 -0
- package/internal/app/research_openapi.go +289 -0
- package/internal/app/research_openapi_test.go +140 -0
- package/internal/app/research_test.go +150 -0
- package/internal/app/source_url.go +25 -0
- package/internal/app/structured_source.go +249 -0
- package/internal/app/structured_source_test.go +70 -0
- package/internal/app/tools.go +46 -42
- package/package.json +3 -1
- package/pkg/origo/server.go +86 -0
- package/pkg/origo/server_test.go +78 -0
- package/pkg/retrieval/retrieval.go +17 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
func TestFocusedExcerptPreservesWholeCurlExamples(t *testing.T) {
|
|
9
|
+
code := backtickFence + "bash\ncurl -X POST https://api.example.com/v2/scrape \\\n" +
|
|
10
|
+
" -H 'Content-Type: application/json' \\\n" +
|
|
11
|
+
" -d '{\"alexandria\":{\"capability\":\"find-tools\"}}'\n" + backtickFence
|
|
12
|
+
doc := "# Documentation\n\n" + strings.Repeat("Background prose unrelated to endpoints.\n", 200) +
|
|
13
|
+
"\n## Inspect tools over REST\n" + strings.Repeat("Introductory filler.\n", 70) +
|
|
14
|
+
"\n" + code + "\n" + strings.Repeat("Appendix filler.\n", 200)
|
|
15
|
+
got := focusExcerpt(doc, "How do I inspect Alexandria tools with a REST request example?", 1600)
|
|
16
|
+
if !strings.Contains(got, code) {
|
|
17
|
+
t.Fatalf("the complete REST example must survive extraction:\n%s", got)
|
|
18
|
+
}
|
|
19
|
+
if strings.Count(got, backtickFence) != 2 {
|
|
20
|
+
t.Fatalf("expected exactly one balanced code fence: %s", got)
|
|
21
|
+
}
|
|
22
|
+
if len(got) > 1800 {
|
|
23
|
+
t.Fatalf("excerpt exceeded budget: %d", len(got))
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func TestOversizedCodeBlocksAreNeverPartiallyReturned(t *testing.T) {
|
|
28
|
+
huge := backtickFence + "json\n{\"payload\":\"" + strings.Repeat("a", 3000) + "\"}\n" + backtickFence
|
|
29
|
+
doc := "# API\n\n" + huge + "\n\n" + strings.Repeat("Further explanation.\n", 200)
|
|
30
|
+
got := atomicFocusedSection(doc, "What is the API payload?", 700)
|
|
31
|
+
if strings.Contains(got, "\"payload\"") || strings.Contains(got, backtickFence) {
|
|
32
|
+
t.Fatalf("oversized example should be omitted, not truncated:\n%s", got)
|
|
33
|
+
}
|
|
34
|
+
if !strings.Contains(got, "omitted") && !strings.Contains(got, "source URL") {
|
|
35
|
+
t.Fatalf("omission should be explicitly marked: %s", got)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func TestPlainCodeFenceOpeningNotTreatedAsClosing(t *testing.T) {
|
|
40
|
+
doc := "# API\n" + strings.Repeat("Preliminary text.\n", 80) + "\n" +
|
|
41
|
+
backtickFence + "\nPOST /v2/scrape\n{}\n" + backtickFence + "\n" +
|
|
42
|
+
strings.Repeat("End notes.\n", 150)
|
|
43
|
+
got := atomicFocusedSection(doc, "scrape request", 650)
|
|
44
|
+
if !strings.Contains(got, backtickFence+"\nPOST /v2/scrape\n{}\n"+backtickFence) {
|
|
45
|
+
t.Fatalf("plain opening fence was not handled atomically: %s", got)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"net/url"
|
|
8
|
+
"sort"
|
|
9
|
+
"strings"
|
|
10
|
+
"time"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// API contracts are public documentation, not calls to the documented API.
|
|
14
|
+
// When a question requires exact REST payloads, inspecting the site's linked
|
|
15
|
+
// OpenAPI spec is more faithful than extrapolating HTTP requests from SDK code.
|
|
16
|
+
type openAPIContext struct {
|
|
17
|
+
URL string
|
|
18
|
+
Content string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func asksForAPIContracts(question string) bool {
|
|
22
|
+
question = strings.ToLower(question)
|
|
23
|
+
return strings.Contains(question, "rest") || strings.Contains(question, "openapi") ||
|
|
24
|
+
(strings.Contains(question, "api") && (strings.Contains(question, "endpoint") || strings.Contains(question, "request") || strings.Contains(question, "schema") || strings.Contains(question, "contract"))) ||
|
|
25
|
+
(strings.Contains(question, "exact") && (strings.Contains(question, "curl") || strings.Contains(question, "request example")))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func discoverOpenAPIContract(rawURL, question string) openAPIContext {
|
|
29
|
+
if !asksForAPIContracts(question) || directSourceLanguage(rawURL) != "" {
|
|
30
|
+
return openAPIContext{}
|
|
31
|
+
}
|
|
32
|
+
u, err := url.Parse(rawURL)
|
|
33
|
+
if err != nil || validateSourceURL(rawURL) != nil || strings.EqualFold(u.Hostname(), "github.com") {
|
|
34
|
+
return openAPIContext{}
|
|
35
|
+
}
|
|
36
|
+
indexURL := u.Scheme + "://" + u.Host + "/llms.txt"
|
|
37
|
+
ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second)
|
|
38
|
+
defer cancel()
|
|
39
|
+
index, err := fetchPublicText(ctx, indexURL, 300000)
|
|
40
|
+
if err != nil {
|
|
41
|
+
return openAPIContext{}
|
|
42
|
+
}
|
|
43
|
+
var specURLs []sourceCandidate
|
|
44
|
+
seen := make(map[string]bool)
|
|
45
|
+
for _, match := range mdLinkPattern.FindAllStringSubmatch(index, 300) {
|
|
46
|
+
dest, err := url.Parse(strings.TrimSpace(match[2]))
|
|
47
|
+
if err != nil {
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
resolved := u.ResolveReference(dest)
|
|
51
|
+
name := strings.ToLower(resolved.Path)
|
|
52
|
+
if !strings.EqualFold(resolved.Hostname(), u.Hostname()) || resolved.Port() != u.Port() ||
|
|
53
|
+
!strings.HasSuffix(name, ".json") || !strings.Contains(name, "openapi") ||
|
|
54
|
+
validateSourceURL(resolved.String()) != nil || sensitiveURL(resolved) {
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
sourceURL := canonicalSourceURL(resolved)
|
|
58
|
+
if seen[sourceURL] {
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
seen[sourceURL] = true
|
|
62
|
+
score := 0
|
|
63
|
+
if strings.Contains(name, "v2-openapi") {
|
|
64
|
+
score += 10
|
|
65
|
+
}
|
|
66
|
+
if strings.HasPrefix(name, "/api-reference/") {
|
|
67
|
+
score += 5
|
|
68
|
+
}
|
|
69
|
+
if name == "/openapi.json" {
|
|
70
|
+
score += 5
|
|
71
|
+
}
|
|
72
|
+
score -= len(name) / 20 // Prefer the canonical path over translations.
|
|
73
|
+
specURLs = append(specURLs, sourceCandidate{url: sourceURL, score: score})
|
|
74
|
+
}
|
|
75
|
+
sort.SliceStable(specURLs, func(i, j int) bool { return specURLs[i].score > specURLs[j].score })
|
|
76
|
+
for i, item := range specURLs {
|
|
77
|
+
if i >= 2 { // Don't recursively fetch every language/version.
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
body, err := fetchPublicText(ctx, item.url, maxDirectSourceBytes)
|
|
81
|
+
if err != nil {
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if content := summarizeOpenAPIContract(body, question); content != "" {
|
|
85
|
+
return openAPIContext{URL: item.url, Content: content}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return openAPIContext{}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type openAPIOperation struct {
|
|
92
|
+
Method string
|
|
93
|
+
Path string
|
|
94
|
+
Summary string
|
|
95
|
+
Score int
|
|
96
|
+
Request map[string]any
|
|
97
|
+
Response map[string]any
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func summarizeOpenAPIContract(source, question string) string {
|
|
101
|
+
var spec struct {
|
|
102
|
+
OpenAPI string `json:"openapi"`
|
|
103
|
+
Swagger string `json:"swagger"`
|
|
104
|
+
Servers []struct {
|
|
105
|
+
URL string `json:"url"`
|
|
106
|
+
} `json:"servers"`
|
|
107
|
+
Paths map[string]map[string]any `json:"paths"`
|
|
108
|
+
Components struct {
|
|
109
|
+
Schemas map[string]map[string]any `json:"schemas"`
|
|
110
|
+
} `json:"components"`
|
|
111
|
+
}
|
|
112
|
+
if json.Unmarshal([]byte(source), &spec) != nil || (spec.OpenAPI == "" && spec.Swagger == "") || len(spec.Paths) == 0 {
|
|
113
|
+
return ""
|
|
114
|
+
}
|
|
115
|
+
terms := queryTerms(question)
|
|
116
|
+
terms = append(terms, relatedAPITerms(question)...)
|
|
117
|
+
var operations []openAPIOperation
|
|
118
|
+
for path, methods := range spec.Paths {
|
|
119
|
+
// A broad question about Alexandria must not bring in unrelated API
|
|
120
|
+
// routes such as research papers or team activity merely because the
|
|
121
|
+
// path contains "search" or a summary happens to contain "API".
|
|
122
|
+
if strings.Contains(strings.ToLower(question), "alexandria") &&
|
|
123
|
+
strings.Contains(strings.Trim(path, "/"), "/") &&
|
|
124
|
+
!strings.Contains(strings.ToLower(path), "alexandria") {
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
for method, rawDetail := range methods {
|
|
128
|
+
switch strings.ToUpper(method) {
|
|
129
|
+
case "GET", "POST", "PUT", "PATCH", "DELETE":
|
|
130
|
+
default:
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
detail := mapValue(rawDetail)
|
|
134
|
+
summary := stringValue(detail["summary"])
|
|
135
|
+
req := mapValue(detail["requestBody"])
|
|
136
|
+
resp := mapValue(detail["responses"])
|
|
137
|
+
score := termScore(path+" "+summary, terms)*4 + termScore(stringValue(detail["description"]), terms)
|
|
138
|
+
if strings.Contains(strings.ToLower(question), "alexandria") &&
|
|
139
|
+
termScore(strings.Trim(path, "/"), []string{"search", "scrape", "find", "tools", "alexandria"}) == 0 {
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
if examples := requestExamples(req); len(examples) > 0 {
|
|
143
|
+
for name, example := range examples {
|
|
144
|
+
score += termScore(name+" "+stringValue(example["summary"]), terms) * 3
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if score > 0 {
|
|
148
|
+
operations = append(operations, openAPIOperation{
|
|
149
|
+
Method: strings.ToUpper(method), Path: path, Summary: summary, Score: score, Request: req, Response: resp,
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
sort.SliceStable(operations, func(i, j int) bool {
|
|
155
|
+
if operations[i].Score == operations[j].Score {
|
|
156
|
+
return operations[i].Path < operations[j].Path
|
|
157
|
+
}
|
|
158
|
+
return operations[i].Score > operations[j].Score
|
|
159
|
+
})
|
|
160
|
+
if len(operations) == 0 {
|
|
161
|
+
return ""
|
|
162
|
+
}
|
|
163
|
+
apiRoot := ""
|
|
164
|
+
for _, server := range spec.Servers {
|
|
165
|
+
if validateSourceURL(server.URL) == nil {
|
|
166
|
+
apiRoot = strings.TrimRight(server.URL, "/")
|
|
167
|
+
break
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
parts := []string{"OpenAPI-defined HTTP operations and example request bodies (not executed):"}
|
|
171
|
+
for i, operation := range operations {
|
|
172
|
+
if i >= 4 {
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
endpoint := operation.Path
|
|
176
|
+
if apiRoot != "" {
|
|
177
|
+
endpoint = apiRoot + operation.Path
|
|
178
|
+
}
|
|
179
|
+
parts = append(parts, "", fmt.Sprintf("### %s %s", operation.Method, endpoint))
|
|
180
|
+
if operation.Summary != "" {
|
|
181
|
+
parts = append(parts, operation.Summary)
|
|
182
|
+
}
|
|
183
|
+
if examples := requestExamples(operation.Request); len(examples) > 0 {
|
|
184
|
+
names := make([]string, 0, len(examples))
|
|
185
|
+
for name := range examples {
|
|
186
|
+
names = append(names, name)
|
|
187
|
+
}
|
|
188
|
+
sort.SliceStable(names, func(i, j int) bool {
|
|
189
|
+
a := termScore(names[i]+" "+stringValue(examples[names[i]]["summary"]), terms)
|
|
190
|
+
b := termScore(names[j]+" "+stringValue(examples[names[j]]["summary"]), terms)
|
|
191
|
+
if a == b {
|
|
192
|
+
return names[i] < names[j]
|
|
193
|
+
}
|
|
194
|
+
return a > b
|
|
195
|
+
})
|
|
196
|
+
emitted := 0
|
|
197
|
+
for _, name := range names {
|
|
198
|
+
value := examples[name]["value"]
|
|
199
|
+
body, err := json.MarshalIndent(value, "", " ")
|
|
200
|
+
if err != nil || len(body) > 9000 || len(body) == 0 {
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
if strings.Contains(strings.ToLower(question), "alexandria") &&
|
|
204
|
+
!strings.Contains(strings.ToLower(name+" "+string(body)), "alexandria") &&
|
|
205
|
+
!strings.Contains(strings.ToLower(name+" "+string(body)), "findtools") &&
|
|
206
|
+
!strings.Contains(strings.ToLower(name+" "+string(body)), "find-tools") {
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
if emitted >= 2 {
|
|
210
|
+
break
|
|
211
|
+
}
|
|
212
|
+
emitted++
|
|
213
|
+
parts = append(parts, "", "**Published request example:** "+stringValue(examples[name]["summary"]))
|
|
214
|
+
if apiRoot != "" && !strings.Contains(endpoint, "{") {
|
|
215
|
+
// Keep the published JSON body verbatim; the API key must expand in the shell.
|
|
216
|
+
parts = append(parts, fmt.Sprintf("```bash\ncurl -X %s %q \\\n -H \"Authorization: Bearer $API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n --data-binary @- <<'JSON_REQUEST'\n%s\nJSON_REQUEST\n```", operation.Method, endpoint, string(body)))
|
|
217
|
+
} else {
|
|
218
|
+
parts = append(parts, "```json\n"+string(body)+"\n```")
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
for _, name := range []string{"FindToolsOptions", "AlexandriaCall", "FindToolsData"} {
|
|
224
|
+
if termScore(question, []string{"alexandria", "tools", "inspect", "schema", "contracts"}) == 0 {
|
|
225
|
+
break
|
|
226
|
+
}
|
|
227
|
+
contract, ok := spec.Components.Schemas[name]
|
|
228
|
+
if !ok {
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
parts = append(parts, "", "### "+name)
|
|
232
|
+
if desc := stringValue(contract["description"]); desc != "" {
|
|
233
|
+
parts = append(parts, desc)
|
|
234
|
+
}
|
|
235
|
+
if props, ok := contract["properties"].(map[string]any); ok {
|
|
236
|
+
keys := make([]string, 0, len(props))
|
|
237
|
+
for key := range props {
|
|
238
|
+
keys = append(keys, key)
|
|
239
|
+
}
|
|
240
|
+
sort.Strings(keys)
|
|
241
|
+
for _, key := range keys {
|
|
242
|
+
item := mapValue(props[key])
|
|
243
|
+
typ := stringValue(item["type"])
|
|
244
|
+
if typ == "" {
|
|
245
|
+
typ = "schema"
|
|
246
|
+
}
|
|
247
|
+
description := stringValue(item["description"])
|
|
248
|
+
if len(description) > 240 {
|
|
249
|
+
description = description[:240] + "…"
|
|
250
|
+
}
|
|
251
|
+
parts = append(parts, fmt.Sprintf("- `%s` (%s): %s", key, typ, description))
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return strings.Join(parts, "\n")
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
func mapValue(value any) map[string]any {
|
|
259
|
+
v, _ := value.(map[string]any)
|
|
260
|
+
return v
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
func requestExamples(request map[string]any) map[string]map[string]any {
|
|
264
|
+
content := mapValue(request["content"])
|
|
265
|
+
jsonContent := mapValue(content["application/json"])
|
|
266
|
+
examples := mapValue(jsonContent["examples"])
|
|
267
|
+
out := make(map[string]map[string]any, len(examples))
|
|
268
|
+
for key, example := range examples {
|
|
269
|
+
if item := mapValue(example); item != nil && item["value"] != nil {
|
|
270
|
+
out[key] = item
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return out
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
func relatedAPITerms(question string) []string {
|
|
277
|
+
question = strings.ToLower(question)
|
|
278
|
+
var terms []string
|
|
279
|
+
if strings.Contains(question, "discover") || strings.Contains(question, "locat") {
|
|
280
|
+
terms = append(terms, "search", "find", "list")
|
|
281
|
+
}
|
|
282
|
+
if strings.Contains(question, "inspect") || strings.Contains(question, "contract") {
|
|
283
|
+
terms = append(terms, "find", "tools", "schema")
|
|
284
|
+
}
|
|
285
|
+
if strings.Contains(question, "execut") || strings.Contains(question, "run ") {
|
|
286
|
+
terms = append(terms, "scrape", "call", "execute")
|
|
287
|
+
}
|
|
288
|
+
return terms
|
|
289
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"net/http"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func exampleOpenAPISpec(t *testing.T) string {
|
|
11
|
+
t.Helper()
|
|
12
|
+
source := map[string]any{
|
|
13
|
+
"openapi": "3.0.0",
|
|
14
|
+
"servers": []any{map[string]any{"url": "https://api.example.com/v2"}},
|
|
15
|
+
"paths": map[string]any{
|
|
16
|
+
"/search": map[string]any{
|
|
17
|
+
"parameters": []any{map[string]any{"name": "unused"}}, // Legal non-operation path entry.
|
|
18
|
+
"post": map[string]any{"summary": "Search the Alexandria catalogue"},
|
|
19
|
+
},
|
|
20
|
+
"/scrape": map[string]any{
|
|
21
|
+
"post": map[string]any{
|
|
22
|
+
"summary": "Execute a provider capability or find tool contracts",
|
|
23
|
+
"requestBody": map[string]any{
|
|
24
|
+
"content": map[string]any{
|
|
25
|
+
"application/json": map[string]any{
|
|
26
|
+
"examples": map[string]any{
|
|
27
|
+
"findTools": map[string]any{
|
|
28
|
+
"summary": "Find Tools (free)",
|
|
29
|
+
"value": map[string]any{
|
|
30
|
+
"alexandria": map[string]any{
|
|
31
|
+
"provider": "firecrawl",
|
|
32
|
+
"capability": "find-tools",
|
|
33
|
+
"options": map[string]any{
|
|
34
|
+
"providers": []string{"particle"},
|
|
35
|
+
"expand": []string{"options", "response"},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
"components": map[string]any{"schemas": map[string]any{
|
|
48
|
+
"FindToolsOptions": map[string]any{
|
|
49
|
+
"description": "Browse providers and inspect tool contracts.",
|
|
50
|
+
"properties": map[string]any{
|
|
51
|
+
"expand": map[string]any{"type": "array", "description": "Contract sections to return"},
|
|
52
|
+
"providers": map[string]any{"type": "array", "description": "Provider IDs"},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
}},
|
|
56
|
+
}
|
|
57
|
+
bytes, err := json.Marshal(source)
|
|
58
|
+
if err != nil {
|
|
59
|
+
t.Fatal(err)
|
|
60
|
+
}
|
|
61
|
+
return string(bytes)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func TestOpenAPIContractReturnsExactInspectionRESTExample(t *testing.T) {
|
|
65
|
+
result := summarizeOpenAPIContract(exampleOpenAPISpec(t),
|
|
66
|
+
"What are the Alexandria REST API endpoints for discovering, inspecting, and executing tools? Include exact request examples.")
|
|
67
|
+
for _, want := range []string{
|
|
68
|
+
"POST https://api.example.com/v2/search",
|
|
69
|
+
"POST https://api.example.com/v2/scrape",
|
|
70
|
+
"\"provider\": \"firecrawl\"",
|
|
71
|
+
"\"capability\": \"find-tools\"",
|
|
72
|
+
"\"expand\": [",
|
|
73
|
+
"FindToolsOptions",
|
|
74
|
+
"<<'JSON_REQUEST'",
|
|
75
|
+
"-H \"Authorization: Bearer $API_KEY\"",
|
|
76
|
+
} {
|
|
77
|
+
if !strings.Contains(result, want) {
|
|
78
|
+
t.Errorf("OpenAPI extraction lost %q:\n%s", want, result)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if count := strings.Count(result, "JSON_REQUEST\n"+strings.Repeat(string(rune(96)), 3)); count != 1 {
|
|
82
|
+
t.Fatalf("expected one complete REST example, got %d:\n%s", count, result)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func TestQuestionFocusedReadPullsOfficialOpenAPISpec(t *testing.T) {
|
|
87
|
+
oldClient := http.DefaultClient
|
|
88
|
+
t.Cleanup(func() { http.DefaultClient = oldClient })
|
|
89
|
+
spec := exampleOpenAPISpec(t)
|
|
90
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
91
|
+
switch req.URL.Path {
|
|
92
|
+
case "/llms.txt":
|
|
93
|
+
return testHTTPResponse(req, http.StatusOK,
|
|
94
|
+
"# Docs\n\n## OpenAPI Specs\n\n- [v2-openapi](/api-reference/v2-openapi.json)\n", nil), nil
|
|
95
|
+
case "/api-reference/v2-openapi.json":
|
|
96
|
+
return testHTTPResponse(req, http.StatusOK, spec, nil), nil
|
|
97
|
+
default:
|
|
98
|
+
return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
|
|
99
|
+
}
|
|
100
|
+
})}
|
|
101
|
+
primary := "# Alexandria\n\n## Discovery\n\ncurl https://api.example.com/v2/search\n" +
|
|
102
|
+
"\n## Execution\n\ncurl https://api.example.com/v2/scrape\n"
|
|
103
|
+
reader := func(url string) (string, error) { return primary, nil }
|
|
104
|
+
result, err := readLinkFocused("https://docs.example.com/features/alexandria",
|
|
105
|
+
"What are the Alexandria REST API endpoints for discovering, inspecting, and executing tools? Include exact request examples.",
|
|
106
|
+
reader)
|
|
107
|
+
if err != nil {
|
|
108
|
+
t.Fatal(err)
|
|
109
|
+
}
|
|
110
|
+
for _, want := range []string{
|
|
111
|
+
"## REST API contract",
|
|
112
|
+
"**Source:** https://docs.example.com/api-reference/v2-openapi.json",
|
|
113
|
+
"\"capability\": \"find-tools\"",
|
|
114
|
+
"curl https://api.example.com/v2/search",
|
|
115
|
+
"curl https://api.example.com/v2/scrape",
|
|
116
|
+
"**Coverage:** 2 source document(s)",
|
|
117
|
+
} {
|
|
118
|
+
if !strings.Contains(result, want) {
|
|
119
|
+
t.Errorf("focused read missing %q:\n%s", want, result)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func TestOpenAPIDiscoveryNeverCrossesToUnrelatedHost(t *testing.T) {
|
|
125
|
+
oldClient := http.DefaultClient
|
|
126
|
+
t.Cleanup(func() { http.DefaultClient = oldClient })
|
|
127
|
+
var calledOutsideIndex bool
|
|
128
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
129
|
+
if req.URL.Path != "/llms.txt" {
|
|
130
|
+
calledOutsideIndex = true
|
|
131
|
+
}
|
|
132
|
+
return testHTTPResponse(req, http.StatusOK,
|
|
133
|
+
"- [External OpenAPI](https://evil.example.com/openapi.json)\n"+
|
|
134
|
+
"- [Local OpenAPI](http://127.0.0.1/openapi.json)\n", nil), nil
|
|
135
|
+
})}
|
|
136
|
+
if found := discoverOpenAPIContract("https://docs.example.com/reference",
|
|
137
|
+
"Find exact REST API endpoint request examples"); found.Content != "" || calledOutsideIndex {
|
|
138
|
+
t.Fatalf("unsafe OpenAPI discovery: %+v, off-index=%v", found, calledOutsideIndex)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"fmt"
|
|
6
|
+
"net/http"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestReadLinkFocusedKeepsURLOnlyContract(t *testing.T) {
|
|
12
|
+
called := 0
|
|
13
|
+
reader := func(url string) (string, error) {
|
|
14
|
+
called++
|
|
15
|
+
if url != "https://docs.example.com/api" {
|
|
16
|
+
t.Fatalf("unexpected URL: %s", url)
|
|
17
|
+
}
|
|
18
|
+
return "# Entire document\n\nno focus needed", nil
|
|
19
|
+
}
|
|
20
|
+
got, err := readLinkFocused("https://docs.example.com/api", "", reader)
|
|
21
|
+
if err != nil || got != "# Entire document\n\nno focus needed" || called != 1 {
|
|
22
|
+
t.Fatalf("URL-only path changed: %q, %v, calls=%d", got, err, called)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func TestFocusedReadExpandsRelevantSameHostLinks(t *testing.T) {
|
|
27
|
+
old := http.DefaultClient
|
|
28
|
+
t.Cleanup(func() { http.DefaultClient = old })
|
|
29
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
30
|
+
return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
|
|
31
|
+
})}
|
|
32
|
+
urls := map[string]string{
|
|
33
|
+
"https://docs.example.com/guide": "# Guide\n[Authentication](./auth)\n[Pagination](/pagination)\n[Offsite](https://evil.example/pagination)\n[Secret](./auth?token=secret)\n[Self](#pagination)",
|
|
34
|
+
"https://docs.example.com/auth": "# Authentication\nSet the Authorization header with your token.",
|
|
35
|
+
"https://docs.example.com/pagination": "# Pagination\nPass next_cursor to read the next page.",
|
|
36
|
+
}
|
|
37
|
+
requested := make(map[string]int)
|
|
38
|
+
reader := func(url string) (string, error) {
|
|
39
|
+
requested[url]++
|
|
40
|
+
value, ok := urls[url]
|
|
41
|
+
if !ok {
|
|
42
|
+
return "", errors.New("missing URL")
|
|
43
|
+
}
|
|
44
|
+
return value, nil
|
|
45
|
+
}
|
|
46
|
+
got, err := readLinkFocused("https://docs.example.com/guide", "How do authentication and pagination work?", reader)
|
|
47
|
+
if err != nil {
|
|
48
|
+
t.Fatal(err)
|
|
49
|
+
}
|
|
50
|
+
for _, want := range []string{"next_cursor", "Authorization header", "https://docs.example.com/auth", "https://docs.example.com/pagination", "**Coverage:** 3 source document(s)"} {
|
|
51
|
+
if !strings.Contains(got, want) {
|
|
52
|
+
t.Errorf("missing %q in result:\n%s", want, got)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if len(requested) != 3 {
|
|
56
|
+
t.Fatalf("followed unsafe/irrelevant link: %+v", requested)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func TestFocusedExcerptPrioritizesRelevantHeadingAndCode(t *testing.T) {
|
|
61
|
+
large := "# Docs\n" + strings.Repeat("Large unrelated introduction.\n", 600) + "\n## Authenticated requests\nUse an Authorization header.\n```go\nheaders.Set(\"Authorization\", token)\n```\n\n## Other\n" + strings.Repeat("Unrelated text\n", 200)
|
|
62
|
+
got := focusExcerpt(large, "How to authenticate requests with Go?", 3000)
|
|
63
|
+
if !strings.Contains(got, "headers.Set") || strings.Contains(got, "Large unrelated introduction.Large") {
|
|
64
|
+
t.Fatalf("did not select API code example: %s", got)
|
|
65
|
+
}
|
|
66
|
+
if len(got) > 3500 {
|
|
67
|
+
t.Fatalf("excerpt unexpectedly large: %d", len(got))
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func TestSearchResearchReturnsEvidenceAndSupportingSource(t *testing.T) {
|
|
72
|
+
results := []SearchDoc{
|
|
73
|
+
{URL: "https://docs.example.com/guide", Title: "Guide"},
|
|
74
|
+
{URL: "https://github.com/example/project", Title: "Source"},
|
|
75
|
+
{URL: "https://other.example.com/blog", Title: "Blog"},
|
|
76
|
+
}
|
|
77
|
+
reader := func(url string) (string, error) {
|
|
78
|
+
switch url {
|
|
79
|
+
case "https://docs.example.com/guide":
|
|
80
|
+
return "# Auth\nUse OAuth.\n[Pagination](/pagination)", nil
|
|
81
|
+
case "https://docs.example.com/pagination":
|
|
82
|
+
return "# Pagination\nnext_cursor is optional.", nil
|
|
83
|
+
case "https://github.com/example/project":
|
|
84
|
+
return "# Source\nOAuth settings are configurable.", nil
|
|
85
|
+
case "https://other.example.com/blog":
|
|
86
|
+
return "# Blog\nOAuth authorization examples.", nil
|
|
87
|
+
default:
|
|
88
|
+
return "", fmt.Errorf("unexpected URL: %s", url)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
got := enrichSearchWithReader("OAuth pagination", results, reader)
|
|
92
|
+
if !strings.Contains(got, "Use OAuth.") || !strings.Contains(got, "next_cursor") || !strings.Contains(got, "Supporting reference") {
|
|
93
|
+
t.Fatalf("search lacked fetched source context: %s", got)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func TestFollowLinksRejectsOtherHostsSecretsAndAssets(t *testing.T) {
|
|
98
|
+
markdown := "[API auth](https://other.example.com/auth) [API auth](/auth?api_key=private) [API auth](/image.png) [API auth](http://127.0.0.1/auth) [API auth](/auth)"
|
|
99
|
+
got := relatedSourceLinks(markdown, "https://docs.example.com/guide", "api auth", 5)
|
|
100
|
+
if len(got) != 1 || got[0].url != "https://docs.example.com/auth" {
|
|
101
|
+
t.Fatalf("unsafe or irrelevant candidates: %+v", got)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func TestRelevanceMatchingDoesNotConfuseAPIWithScraping(t *testing.T) {
|
|
106
|
+
if termScore("advanced scraping guide", []string{"api"}) != 0 {
|
|
107
|
+
t.Fatal("API should not match the middle of the word scraping")
|
|
108
|
+
}
|
|
109
|
+
links := relatedSourceLinks("[Advanced scraping](/advanced-scraping-guide) [API endpoint reference](/api-reference/endpoint/scrape)", "https://docs.example.com/features/alexandria", "Alexandria API endpoints", 3)
|
|
110
|
+
if len(links) != 1 || !strings.Contains(links[0].url, "api-reference") {
|
|
111
|
+
t.Fatalf("irrelevant docs were followed: %+v", links)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func TestSearchPrefersTechnicalReferenceOverCommunityPages(t *testing.T) {
|
|
116
|
+
results := []SearchDoc{
|
|
117
|
+
{URL: "https://modelcontextprotocol.io/community/interest-groups/tool-annotations", Title: "Tool annotations interest group"},
|
|
118
|
+
{URL: "https://modelcontextprotocol.io/specification/2025-11-25/server/tools", Title: "Tools specification"},
|
|
119
|
+
{URL: "https://github.com/modelcontextprotocol/go-sdk", Title: "Go SDK for MCP"},
|
|
120
|
+
{URL: "https://pkg.go.dev/golang.org/x/tools/internal/mcp", Title: "Internal Go MCP"},
|
|
121
|
+
}
|
|
122
|
+
selected := chooseSearchSources("Go MCP SDK tool annotations", results, 2)
|
|
123
|
+
if len(selected) != 2 || selected[0].url != "https://github.com/modelcontextprotocol/go-sdk" || !strings.Contains(selected[1].url, "/specification/") {
|
|
124
|
+
t.Fatalf("selected irrelevant pages over official SDK/spec: %+v", selected)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func TestFollowLinksSkipGenericMCPLinksForSpecificQuestion(t *testing.T) {
|
|
129
|
+
content := "[Skills over MCP](/community/working-groups/skills-over-mcp) [Tool annotations](/specification/server/tools)"
|
|
130
|
+
links := relatedSourceLinks(content, "https://modelcontextprotocol.io/docs/tools", "Go MCP SDK tool annotations", 2)
|
|
131
|
+
if len(links) != 1 || !strings.Contains(links[0].url, "/specification/server/tools") {
|
|
132
|
+
t.Fatalf("followed generic cross-topic link: %+v", links)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
func TestSelectJSONFieldsPreservesSyntaxAndPaths(t *testing.T) {
|
|
137
|
+
obj := map[string]any{"paths": map[string]any{"/v2/scrape": map[string]any{"post": map[string]any{"summary": "Scrape an API document"}}}, "metadata": strings.Repeat("x", 5000)}
|
|
138
|
+
got := selectedJSONFields(obj, "scrape", 1400)
|
|
139
|
+
if !strings.Contains(got, "/paths/~1v2~1scrape") || !strings.Contains(got, "```json") || !strings.Contains(got, "Scrape an API document") {
|
|
140
|
+
t.Fatalf("JSON selection missing source pointers: %s", got)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func TestLargeOpenAPISchemaUsesSourceJSONPointers(t *testing.T) {
|
|
145
|
+
source := `**URL:** https://docs.example.com/openapi.json` + "\n\n```json\n" + `{"paths":{"/v2/scrape":{"post":{"summary":"Scrape pages"}}},"large":"` + strings.Repeat("x", 12000) + `"}` + "\n```"
|
|
146
|
+
got := focusExcerpt(source, "scrape endpoint", 2500)
|
|
147
|
+
if !strings.Contains(got, "/paths/~1v2~1scrape") || !strings.Contains(got, "Scrape pages") || len(got) > 2700 {
|
|
148
|
+
t.Fatalf("large raw JSON should be selected by pointer: %s", got)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"net"
|
|
6
|
+
"net/url"
|
|
7
|
+
"strings"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// Reject non-public URL destinations before calling native readers or
|
|
11
|
+
// hosted scraping providers. Credentials embedded in URLs are never accepted.
|
|
12
|
+
func validateSourceURL(raw string) error {
|
|
13
|
+
u, err := url.Parse(raw)
|
|
14
|
+
if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil {
|
|
15
|
+
return fmt.Errorf("readable URL must be an absolute HTTP(S) URL without credentials")
|
|
16
|
+
}
|
|
17
|
+
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
|
|
18
|
+
if host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".internal") {
|
|
19
|
+
return fmt.Errorf("private/internal host is not a readable public URL")
|
|
20
|
+
}
|
|
21
|
+
if ip := net.ParseIP(host); ip != nil && (!ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback()) {
|
|
22
|
+
return fmt.Errorf("private or reserved IP address is not a readable public URL")
|
|
23
|
+
}
|
|
24
|
+
return nil
|
|
25
|
+
}
|