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.
@@ -0,0 +1,615 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/url"
7
+ "regexp"
8
+ "sort"
9
+ "strings"
10
+ "sync"
11
+ "time"
12
+ )
13
+
14
+ // Research deliberately works with source text, not generated answers. The
15
+ // coding agent gets exact excerpts and URLs it can independently inspect.
16
+ // One follow-up level and fixed budgets prevent uncontrolled crawls.
17
+ const (
18
+ // Coding agents value complete code and request contracts more than tiny
19
+ // snippets. Keep ordinary documentation pages whole when reasonably sized.
20
+ focusedPrimaryBudget = 22000
21
+ focusedRelatedBudget = 6500
22
+ searchSourceBudget = 4000
23
+ maxRelatedSources = 5
24
+ maxFollowupSources = 2
25
+ maxSearchSources = 3
26
+ )
27
+
28
+ func relatedExcerptBudget(rawURL string) int {
29
+ lower := strings.ToLower(rawURL)
30
+ if strings.Contains(lower, "/get-started/") || strings.Contains(lower, "/quickstart") {
31
+ return 18000 // Preserve end-to-end runnable setup guides.
32
+ }
33
+ return focusedRelatedBudget
34
+ }
35
+
36
+ var (
37
+ mdLinkPattern = regexp.MustCompile(`\[([^\]\n]{2,120})\]\(([^\s)]+)(?:\s+"[^"]*")?\)`)
38
+ wordPattern = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9_-]{2,}`)
39
+ sourceWordPattern = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9]{1,}`)
40
+ headingPattern = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
41
+ )
42
+
43
+ type researchSection struct {
44
+ index int
45
+ heading string
46
+ body string
47
+ score int
48
+ }
49
+
50
+ type sourceCandidate struct {
51
+ url string
52
+ label string
53
+ score int
54
+ order int
55
+ }
56
+
57
+ type sourceRead struct {
58
+ url string
59
+ content string
60
+ err error
61
+ }
62
+
63
+ // ReadLinkFocused is backwards-compatible: URL-only calls still return the
64
+ // complete original source. A question makes the engine select excerpts and
65
+ // automatically inspect related documentation on the same host.
66
+ func ReadLinkFocused(rawURL, question string) (string, error) {
67
+ return readLinkFocused(rawURL, question, ReadLink)
68
+ }
69
+
70
+ func readLinkFocused(rawURL, question string, reader func(string) (string, error)) (string, error) {
71
+ if strings.TrimSpace(question) == "" {
72
+ return reader(rawURL)
73
+ }
74
+ if err := validateSourceURL(rawURL); err != nil {
75
+ return "", err
76
+ }
77
+ question = strings.TrimSpace(question)
78
+ // REST contract discovery is independent of the page scrape. Start it
79
+ // immediately so a slow Firecrawl read and a large OpenAPI download overlap
80
+ // instead of approaching the hosted MCP function timeout sequentially.
81
+ var openAPIDone chan openAPIContext
82
+ if asksForAPIContracts(question) {
83
+ openAPIDone = make(chan openAPIContext, 1)
84
+ go func() { openAPIDone <- discoverOpenAPIContract(rawURL, question) }()
85
+ }
86
+ primary, err := reader(rawURL)
87
+ if err != nil {
88
+ return "", err
89
+ }
90
+
91
+ // Combine on-page links and authoritative Markdown index links instead of
92
+ // filling the entire budget from whichever links happen to appear first.
93
+ // In particular, an implementation question should prefer get-started,
94
+ // SQLite, webhook, and API references over incidental alarm/concept pages.
95
+ links := researchCandidates(rawURL, primary, question, maxRelatedSources)
96
+ structuredDone := make(chan string, 1)
97
+ if strings.Contains(primary, "**Markdown source:**") {
98
+ structuredDone <- ""
99
+ } else {
100
+ go func() { structuredDone <- embeddedStructuredContext(rawURL, question) }()
101
+ }
102
+ more := readSources(links, reader)
103
+ parts := []string{
104
+ "# Source-grounded context",
105
+ "",
106
+ "**Question:** " + question,
107
+ "",
108
+ "## Primary source",
109
+ "**Source:** " + rawURL,
110
+ "",
111
+ }
112
+ primaryExcerpt := focusExcerpt(primary, question, focusedPrimaryBudget)
113
+ parts = append(parts, primaryExcerpt)
114
+ successful := 1
115
+ relatedSuccessful := 0
116
+ allRetrieved := primary
117
+ outputEvidence := primaryExcerpt
118
+ seenURLs := map[string]bool{rawURL: true}
119
+ for _, source := range more {
120
+ if source.err != nil {
121
+ continue
122
+ }
123
+ successful++
124
+ relatedSuccessful++
125
+ seenURLs[source.url] = true
126
+ allRetrieved += "\n" + source.content
127
+ excerpt := focusExcerpt(source.content, question, relatedExcerptBudget(source.url))
128
+ outputEvidence += "\n" + excerpt
129
+ parts = append(parts, "", "## Related source", "**Source:** "+source.url, "", excerpt)
130
+ }
131
+ // One additional bounded expansion if required code/API evidence remains
132
+ // missing. This follows references from the implementation pages we actually
133
+ // found, not arbitrary link hops across the entire site.
134
+ missing := missingResearchEvidence(question, allRetrieved)
135
+ if len(missing) > 0 {
136
+ var next []sourceCandidate
137
+ for _, source := range more {
138
+ if source.err != nil {
139
+ continue
140
+ }
141
+ if strings.Contains(strings.ToLower(source.content), "article has multiple variants") {
142
+ for _, candidate := range relatedSourceLinks(source.content, source.url, question, 8) {
143
+ if !seenURLs[candidate.url] {
144
+ seenURLs[candidate.url] = true
145
+ candidate.score += 50 // A published page variant is the actual documentation, not an index.
146
+ if strings.Contains(strings.ToLower(question), "hosted") &&
147
+ strings.Contains(strings.ToLower(candidate.url+" "+candidate.label), "hosted") {
148
+ candidate.score += 20
149
+ }
150
+ next = append(next, candidate)
151
+ }
152
+ }
153
+ }
154
+ for _, candidate := range relatedSourceLinks(source.content, source.url, strings.Join(missing, " "), 8) {
155
+ if !seenURLs[candidate.url] {
156
+ seenURLs[candidate.url] = true
157
+ candidate.score += implementationLinkBonus(candidate, question)
158
+ next = append(next, candidate)
159
+ }
160
+ }
161
+ }
162
+ sort.SliceStable(next, func(i, j int) bool { return next[i].score > next[j].score })
163
+ if len(next) > maxFollowupSources {
164
+ next = next[:maxFollowupSources]
165
+ }
166
+ followups := readSources(next, reader)
167
+ more = append(more, followups...)
168
+ for _, source := range followups {
169
+ if source.err != nil {
170
+ continue
171
+ }
172
+ successful++
173
+ relatedSuccessful++
174
+ allRetrieved += "\n" + source.content
175
+ excerpt := focusExcerpt(source.content, question, relatedExcerptBudget(source.url))
176
+ outputEvidence += "\n" + excerpt
177
+ parts = append(parts, "", "## Supporting implementation reference", "**Source:** "+source.url, "", excerpt)
178
+ }
179
+ }
180
+ if openAPIDone != nil {
181
+ if contract := <-openAPIDone; contract.Content != "" {
182
+ successful++
183
+ outputEvidence += "\n" + contract.Content
184
+ parts = append(parts, "", "## REST API contract", "**Source:** "+contract.URL, "", contract.Content)
185
+ }
186
+ }
187
+ if structured := <-structuredDone; structured != "" {
188
+ outputEvidence += "\n" + structured
189
+ parts = append(parts, "", structured)
190
+ }
191
+ if unmet := missingResearchEvidence(question, outputEvidence); len(unmet) > 0 {
192
+ parts = append(parts, "", "**Requested details not fully evidenced in returned excerpts:** "+strings.Join(unmet, ", ")+". Follow the cited source pages rather than assuming these details.")
193
+ }
194
+ parts = append(parts, "", fmt.Sprintf("**Coverage:** %d source document(s) inspected. Extracts are source-grounded, not an independently verified synthesis. REST commands assembled from an OpenAPI operation and its published example are labeled as such.", successful))
195
+ if failed := len(more) - relatedSuccessful; failed > 0 {
196
+ parts = append(parts, fmt.Sprintf("%d related page(s) could not be retrieved; the primary source is preserved.", failed))
197
+ }
198
+ return strings.Join(parts, "\n"), nil
199
+ }
200
+
201
+ // enrichSearch gives agents real documentation content in their first search
202
+ // response, with the original ranked source list retained for navigation.
203
+ func enrichSearch(query string, ranked []SearchDoc) string {
204
+ return enrichSearchWithReader(query, ranked, ReadLink)
205
+ }
206
+
207
+ func enrichSearchWithReader(query string, ranked []SearchDoc, reader func(string) (string, error)) string {
208
+ chosen := chooseSearchSources(query, ranked, maxSearchSources)
209
+ if len(chosen) == 0 {
210
+ return ""
211
+ }
212
+ more := readSources(chosen, reader)
213
+ parts := []string{}
214
+ seen := make(map[string]bool)
215
+ for _, source := range more {
216
+ if source.err != nil {
217
+ continue
218
+ }
219
+ seen[source.url] = true
220
+ parts = append(parts, "### "+source.url, "", focusExcerpt(source.content, query, searchSourceBudget), "")
221
+ }
222
+ if len(parts) == 0 {
223
+ return ""
224
+ }
225
+ // Expand high-relevance documentation references from retrieved results,
226
+ // not arbitrary websites or unlimited recursive link trees.
227
+ var follow []sourceCandidate
228
+ for _, source := range more {
229
+ if source.err != nil || len(follow) >= 2 {
230
+ continue
231
+ }
232
+ for _, candidate := range relatedSourceLinks(source.content, source.url, query, 2) {
233
+ if seen[candidate.url] {
234
+ continue
235
+ }
236
+ seen[candidate.url] = true
237
+ follow = append(follow, candidate)
238
+ if len(follow) >= 2 {
239
+ break
240
+ }
241
+ }
242
+ }
243
+ for _, source := range readSources(follow, reader) {
244
+ if source.err == nil {
245
+ parts = append(parts, "### Supporting reference: "+source.url, "", focusExcerpt(source.content, query, focusedRelatedBudget), "")
246
+ }
247
+ }
248
+ return "## Retrieved source context\n\nThe following are excerpts from source pages, not an AI-generated answer. Compare versions and follow the URLs for full documents.\n\n" + strings.Join(parts, "\n")
249
+ }
250
+
251
+ func linksFromDocsIndex(rawURL, question string, limit int) []sourceCandidate {
252
+ return linksFromDocsIndexWithPrimary(rawURL, "", question, limit)
253
+ }
254
+
255
+ func linksFromDocsIndexWithPrimary(rawURL, primary, question string, limit int) []sourceCandidate {
256
+ u, err := url.Parse(rawURL)
257
+ if err != nil || u.Hostname() == "github.com" || directSourceLanguage(rawURL) != "" {
258
+ return nil
259
+ }
260
+ if !strings.HasPrefix(u.Hostname(), "docs.") && !strings.Contains(u.Path, "/docs/") &&
261
+ !strings.Contains(u.Path, "/reference/") && !strings.Contains(primary, "llms.txt") {
262
+ return nil
263
+ }
264
+ indexURLs := documentIndexURLs(u, primary)
265
+ var candidates []sourceCandidate
266
+ seen := map[string]bool{}
267
+ for _, indexURL := range indexURLs {
268
+ if rawURL == indexURL {
269
+ continue
270
+ }
271
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
272
+ index, err := fetchPublicText(ctx, indexURL, 300000)
273
+ cancel()
274
+ if err != nil || !strings.Contains(index, "](") {
275
+ continue
276
+ }
277
+ for _, item := range relatedSourceLinks(index, indexURL, question, 300) {
278
+ item.score += implementationLinkBonus(item, question)
279
+ item.score += sourceScopeBonus(item, rawURL, question)
280
+ if !seen[item.url] {
281
+ seen[item.url] = true
282
+ candidates = append(candidates, item)
283
+ }
284
+ }
285
+ if len(candidates) >= limit {
286
+ break
287
+ }
288
+ }
289
+ sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score })
290
+ if len(candidates) > limit {
291
+ candidates = candidates[:limit]
292
+ }
293
+ return candidates
294
+ }
295
+
296
+ func readSources(candidates []sourceCandidate, reader func(string) (string, error)) []sourceRead {
297
+ result := make([]sourceRead, len(candidates))
298
+ var wg sync.WaitGroup
299
+ for i, candidate := range candidates {
300
+ wg.Add(1)
301
+ go func(i int, candidate sourceCandidate) {
302
+ defer wg.Done()
303
+ content, err := reader(candidate.url)
304
+ result[i] = sourceRead{url: candidate.url, content: content, err: err}
305
+ }(i, candidate)
306
+ }
307
+ wg.Wait()
308
+ return result
309
+ }
310
+
311
+ func chooseSearchSources(query string, ranked []SearchDoc, limit int) []sourceCandidate {
312
+ items := make([]sourceCandidate, 0, limit)
313
+ terms := queryTerms(query)
314
+ for i, source := range ranked {
315
+ if i >= 18 {
316
+ break
317
+ }
318
+ if err := validateSourceURL(source.URL); err != nil || disallowedSourcePath(source.URL) {
319
+ continue
320
+ }
321
+ u, _ := url.Parse(source.URL)
322
+ score := 18 - i + termScore(source.Title, terms)*2 + termScore(u.Hostname()+" "+u.Path, terms)
323
+ if strings.HasPrefix(u.Hostname(), "docs.") || strings.Contains(u.Path, "/docs/") || strings.Contains(u.Path, "/reference/") {
324
+ score += 7
325
+ }
326
+ if strings.Contains(u.Path, "/specification/") || strings.Contains(u.Path, "/api-reference/") || strings.Contains(u.Path, "/sdk/") {
327
+ score += 7
328
+ }
329
+ if u.Hostname() == "github.com" || strings.Contains(u.Path, "openapi") {
330
+ score += 5
331
+ }
332
+ if strings.Contains(u.Path, "/community/") || strings.Contains(u.Path, "/interest-groups/") {
333
+ score -= 16 // governance pages rarely contain usable API contracts
334
+ }
335
+ if strings.Contains(u.Path, "/blog/") || strings.HasPrefix(u.Hostname(), "blog.") {
336
+ score -= 5
337
+ }
338
+ if strings.Contains(u.Path, "/internal/") && u.Hostname() == "pkg.go.dev" {
339
+ score -= 12 // internal Go packages aren't public SDK contracts
340
+ }
341
+ if strings.Contains(strings.ToLower(query), "go sdk") && strings.Contains(strings.ToLower(source.Title+" "+u.Path), "go-sdk") {
342
+ score += 7
343
+ }
344
+ items = append(items, sourceCandidate{url: source.URL, label: source.Title, score: score, order: i})
345
+ }
346
+ sort.SliceStable(items, func(i, j int) bool { return items[i].score > items[j].score })
347
+ selected := make([]sourceCandidate, 0, limit)
348
+ seenHosts := make(map[string]int)
349
+ for _, item := range items {
350
+ if len(selected) == limit {
351
+ break
352
+ }
353
+ u, _ := url.Parse(item.url)
354
+ if seenHosts[u.Hostname()] >= 2 { // diversify while allowing two relevant docs pages
355
+ continue
356
+ }
357
+ seenHosts[u.Hostname()]++
358
+ selected = append(selected, item)
359
+ }
360
+ return selected
361
+ }
362
+
363
+ func relatedSourceLinks(markdown, baseURL, question string, limit int) []sourceCandidate {
364
+ base, err := url.Parse(baseURL)
365
+ if err != nil {
366
+ return nil
367
+ }
368
+ terms := queryTerms(question)
369
+ seen := map[string]bool{canonicalSourceURL(base): true}
370
+ var links []sourceCandidate
371
+ for i, match := range mdLinkPattern.FindAllStringSubmatch(markdown, 1000) {
372
+ destination := strings.TrimSpace(match[2])
373
+ if strings.Contains(destination, "\\") { // malformed Markdown-escaped URLs
374
+ continue
375
+ }
376
+ ref, err := url.Parse(destination)
377
+ if err != nil {
378
+ continue
379
+ }
380
+ u := base.ResolveReference(ref)
381
+ if !strings.EqualFold(base.Hostname(), u.Hostname()) || base.Port() != u.Port() || u.User != nil {
382
+ continue
383
+ }
384
+ if err := validateSourceURL(u.String()); err != nil || disallowedSourcePath(u.String()) || sensitiveURL(u) {
385
+ continue
386
+ }
387
+ key := canonicalSourceURL(u)
388
+ if seen[key] {
389
+ continue
390
+ }
391
+ seen[key] = true
392
+ label := strings.TrimSpace(match[1])
393
+ score := termScore(label, terms)*4 + termScore(u.Path, terms)*3
394
+ if score == 0 { // no relevant relationship to the user's question
395
+ continue
396
+ }
397
+ if len(terms) >= 4 && len(matchedLinkTerms(label+" "+u.Path, terms)) == 1 {
398
+ term := matchedLinkTerms(label+" "+u.Path, terms)[0]
399
+ if len(term) <= 5 { // don't expand generic links matching only 'MCP' or 'tools'
400
+ continue
401
+ }
402
+ }
403
+ links = append(links, sourceCandidate{url: key, label: label, score: score, order: i})
404
+ }
405
+ sort.SliceStable(links, func(i, j int) bool {
406
+ if links[i].score == links[j].score {
407
+ return links[i].order < links[j].order
408
+ }
409
+ return links[i].score > links[j].score
410
+ })
411
+ if len(links) > limit {
412
+ links = links[:limit]
413
+ }
414
+ return links
415
+ }
416
+
417
+ func matchedLinkTerms(text string, terms []string) []string {
418
+ var matched []string
419
+ for _, term := range terms {
420
+ if termScore(text, []string{term}) > 0 {
421
+ matched = append(matched, term)
422
+ }
423
+ }
424
+ return matched
425
+ }
426
+
427
+ func canonicalSourceURL(u *url.URL) string {
428
+ copy := *u
429
+ copy.Fragment = ""
430
+ copy.RawFragment = ""
431
+ return copy.String()
432
+ }
433
+
434
+ func sensitiveURL(u *url.URL) bool {
435
+ for name := range u.Query() {
436
+ name = strings.ToLower(name)
437
+ if strings.Contains(name, "token") || strings.Contains(name, "secret") || strings.Contains(name, "key") || strings.Contains(name, "auth") || strings.Contains(name, "signature") {
438
+ return true
439
+ }
440
+ }
441
+ return false
442
+ }
443
+
444
+ func disallowedSourcePath(raw string) bool {
445
+ u, err := url.Parse(raw)
446
+ if err != nil {
447
+ return true
448
+ }
449
+ path := strings.ToLower(u.Path)
450
+ for _, suffix := range []string{".png", ".jpg", ".jpeg", ".svg", ".gif", ".webp", ".zip", ".mp4", ".mov", ".mp3", ".woff", ".woff2", ".pdf", ".dmg", ".exe"} {
451
+ if strings.HasSuffix(path, suffix) {
452
+ return true
453
+ }
454
+ }
455
+ return false
456
+ }
457
+
458
+ func queryTerms(query string) []string {
459
+ stop := map[string]bool{"the": true, "and": true, "for": true, "from": true, "with": true, "that": true, "this": true, "what": true, "how": true, "when": true, "where": true, "does": true, "which": true, "using": true, "about": true, "into": true, "can": true, "are": true, "get": true, "use": true, "you": true, "your": true, "its": true, "have": true}
460
+ seen := make(map[string]bool)
461
+ var terms []string
462
+ for _, term := range sourceWordPattern.FindAllString(strings.ToLower(query), 40) {
463
+ if len(term) < 3 {
464
+ continue
465
+ }
466
+ if stop[term] || seen[term] {
467
+ continue
468
+ }
469
+ seen[term] = true
470
+ terms = append(terms, term)
471
+ }
472
+ return terms
473
+ }
474
+
475
+ func termScore(text string, terms []string) int {
476
+ // Compare words instead of substrings. Without this, an "api" search
477
+ // incorrectly matches "scraping" and chooses unrelated pages.
478
+ words := sourceWordPattern.FindAllString(strings.ToLower(text), -1)
479
+ count := 0
480
+ for _, term := range terms {
481
+ // Approximate inflection matching: pagination / paginate, authenticated /
482
+ // authentication, etc. Exact matches are weighted slightly higher.
483
+ root := term
484
+ if len(root) > 5 {
485
+ root = root[:5]
486
+ }
487
+ for _, word := range words {
488
+ if word == term {
489
+ count += 2
490
+ break
491
+ }
492
+ if len(root) >= 4 && strings.HasPrefix(word, root) {
493
+ count++
494
+ break
495
+ }
496
+ }
497
+ }
498
+ return count
499
+ }
500
+
501
+ func splitResearchSections(markdown string, terms []string) []researchSection {
502
+ lines := strings.Split(markdown, "\n")
503
+ sections := make([]researchSection, 0, 20)
504
+ current := researchSection{heading: "Introduction"}
505
+ var body strings.Builder
506
+ inFence := false
507
+ flush := func() {
508
+ current.body = strings.TrimSpace(body.String())
509
+ if current.body != "" {
510
+ current.index = len(sections)
511
+ current.score = 5*termScore(current.heading, terms) + termScore(current.body, terms)
512
+ if strings.Contains(current.body, "```") && current.score > 0 {
513
+ current.score += 2
514
+ }
515
+ sections = append(sections, current)
516
+ }
517
+ body.Reset()
518
+ }
519
+ for _, line := range lines {
520
+ trimmed := strings.TrimSpace(line)
521
+ if !inFence {
522
+ if heading := headingPattern.FindStringSubmatch(trimmed); len(heading) == 2 {
523
+ flush()
524
+ current = researchSection{heading: heading[1]}
525
+ }
526
+ }
527
+ body.WriteString(line)
528
+ body.WriteByte('\n')
529
+ if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
530
+ inFence = !inFence
531
+ }
532
+ }
533
+ flush()
534
+ return sections
535
+ }
536
+
537
+ func focusExcerpt(markdown, question string, budget int) string {
538
+ markdown = strings.TrimSpace(markdown)
539
+ if len(markdown) <= budget {
540
+ return markdown
541
+ }
542
+ if focused := focusedRawJSON(markdown, question, budget); focused != "" {
543
+ return focused
544
+ }
545
+ sections := splitResearchSections(markdown, queryTerms(question))
546
+ if len(sections) == 0 {
547
+ return boundedMarkdown(markdown, budget)
548
+ }
549
+ sorted := append([]researchSection(nil), sections...)
550
+ sort.SliceStable(sorted, func(i, j int) bool {
551
+ if sorted[i].score == sorted[j].score {
552
+ return sorted[i].index < sorted[j].index
553
+ }
554
+ return sorted[i].score > sorted[j].score
555
+ })
556
+ selected := make([]researchSection, 0, 5)
557
+ for _, section := range sorted {
558
+ if len(selected) >= 5 || (section.score == 0 && len(selected) > 0) {
559
+ break
560
+ }
561
+ selected = append(selected, section)
562
+ }
563
+ sort.Slice(selected, func(i, j int) bool { return selected[i].index < selected[j].index })
564
+ var parts []string
565
+ remaining := budget
566
+ for i, section := range selected {
567
+ if remaining < 160 {
568
+ break
569
+ }
570
+ // Allocate budget across selected sections so one huge section doesn't
571
+ // crowd out exact schemas or examples from the other sections.
572
+ allocation := remaining / (len(selected) - i)
573
+ if allocation < 500 && remaining >= 500 {
574
+ allocation = 500
575
+ }
576
+ text := atomicFocusedSection(section.body, question, allocation)
577
+ parts = append(parts, text)
578
+ remaining -= len(text)
579
+ }
580
+ result := strings.Join(parts, "\n\n")
581
+ return result + "\n\n*[Selected excerpts; open the source URL to inspect omitted sections.]*"
582
+ }
583
+
584
+ func boundedMarkdown(markdown string, budget int) string {
585
+ if len(markdown) <= budget {
586
+ return markdown
587
+ }
588
+ if budget <= 80 {
589
+ return "*[Section too large; consult source.]*"
590
+ }
591
+ lines := strings.Split(markdown, "\n")
592
+ var out strings.Builder
593
+ inFence := false
594
+ fence := "```"
595
+ for _, line := range lines {
596
+ if out.Len()+len(line)+len("\n…\n```") > budget {
597
+ break
598
+ }
599
+ trimmed := strings.TrimSpace(line)
600
+ if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
601
+ if !inFence {
602
+ fence = trimmed[:3]
603
+ }
604
+ inFence = !inFence
605
+ }
606
+ out.WriteString(line)
607
+ out.WriteByte('\n')
608
+ }
609
+ if inFence {
610
+ out.WriteString(fence)
611
+ out.WriteByte('\n')
612
+ }
613
+ out.WriteString("*[Excerpt shortened; see source for full content.]*")
614
+ return out.String()
615
+ }