package mcpsrv import ( "context" "errors" "html" "strings" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/search" ) type searchInput struct { Query string `json:"query" jsonschema:"what to search for; matched over document titles and body text in both English and Russian"` // Spaces is the project filter. The design's "a project is a saved filter // over one global index, not a container" is this argument and nothing // else, which is why it is a space list rather than a project name: a // project's membership is resolved by whoever holds the project row, and // what search takes is the set it resolves to. Spaces []string `json:"spaces,omitempty" jsonschema:"restrict the search to these spaces, each written \"~owner/name\" as spec_list reports them. This is the project filter: a project on this service is a named set of spaces. Omit to search every space."` Sections []string `json:"sections,omitempty" jsonschema:"restrict the search to these top-level sections (\"specs\", \"notes\", \"reports\"). Omit to search every section except the dated activity log; name \"log\" to search that."` Limit int `json:"limit,omitempty" jsonschema:"maximum number of hits to return, clamped to 1..100"` Offset int `json:"offset,omitempty" jsonschema:"how many of the top-ranked hits to skip, for paging through a large result set"` } // searchHit is one result as an agent sees it. // // It is search.Hit reshaped for a tool caller rather than the type itself: // Snippet becomes plain text, and ID is split from Anchor so that every id // reported here is one spec_read accepts. type searchHit struct { Space string `json:"space"` // ID addresses the document in spec_read. ID string `json:"id"` // Path is the document's path in the space's git tree. Path string `json:"path,omitempty"` // Rev is the revision the document was indexed at — the approved head of // its space at index time. Pass it to spec_read to pin the read to exactly // what was searched. Rev string `json:"rev,omitempty"` // Anchor is the heading fragment a hit inside a dated activity log lands // on. Empty for an ordinary document. Anchor string `json:"anchor,omitempty"` Title string `json:"title,omitempty"` Section string `json:"section,omitempty"` Score float64 `json:"score"` // Snippet is the matching fragment as plain text. Snippet string `json:"snippet,omitempty"` } type searchOutput struct { Hits []searchHit `json:"hits"` // Total is how many documents matched, not how many are in Hits. Total uint64 `json:"total"` } func searchHandler(ctx context.Context, b Backend, in searchInput) (searchOutput, error) { text := strings.TrimSpace(in.Query) if text == "" { return searchOutput{}, errors.New("query must not be empty") } spaces, err := parseSpaceFilter(in.Spaces) if err != nil { return searchOutput{}, err } sections, err := trimAll("section", in.Sections) if err != nil { return searchOutput{}, err } res, err := b.Index.Search(ctx, search.Query{ Text: text, Spaces: spaces, Sections: sections, Limit: clampLimit(in.Limit), Offset: in.Offset, }) if err != nil { return searchOutput{}, err } out := searchOutput{Hits: make([]searchHit, 0, len(res.Hits)), Total: res.Total} for _, h := range res.Hits { out.Hits = append(out.Hits, searchHit{ Space: h.Space.String(), ID: documentID(h.ID), Path: h.Path, Rev: h.Rev, Anchor: h.Anchor, Title: h.Title, Section: h.Section, Score: h.Score, Snippet: plainSnippet(h.Snippet), }) } return out, nil } // parseSpaceFilter validates the project filter. An unparseable space is an // error rather than a dropped filter term: dropping one would silently widen // the search past the set the caller asked for, and a wider answer than // requested is indistinguishable from a correct one. // // An omitted argument is every space — which the tool schema promises — and it // is returned as the filter that says so. The distinction matters one layer // down: "the agent named no spaces" is not the same as "the project the agent // named holds no spaces", and only a filter can tell them apart. func parseSpaceFilter(in []string) (core.SpaceFilter, error) { if len(in) == 0 { return core.EverythingFilter(), nil } refs := make([]core.SpaceRef, 0, len(in)) for _, s := range in { ref, err := parseSpace(s) if err != nil { return core.SpaceFilter{}, err } refs = append(refs, ref) } return core.SpacesFilter(refs, nil), nil } // trimAll trims each element and refuses an empty one. search/ rejects an empty // filter term outright; catching it here names the argument that carried it. func trimAll(what string, in []string) ([]string, error) { if len(in) == 0 { return nil, nil } out := make([]string, 0, len(in)) for _, s := range in { t := strings.TrimSpace(s) if t == "" { return nil, errors.New(what + " must not be empty") } out = append(out, t) } return out, nil } // documentID strips the entry suffix an activity-log hit carries. // // search/ indexes each dated entry of a log as its own document under // "#-", so that a hit lands on the entry rather than on the // whole log. That id is not a document id: spec_read would not resolve it. The // entry's position is already reported separately as Anchor, so the split loses // nothing and makes every id in a search result one an agent can hand straight // to spec_read. func documentID(id string) string { if i := strings.IndexByte(id, '#'); i >= 0 { return id[:i] } return id } // plainSnippet converts bleve's highlighted fragment to plain text. // // search.Hit.Snippet is HTML: the matched terms are wrapped in and // everything around them is HTML-escaped, because the web UI renders it. A tool // result is not rendered, so leaving it would show an agent literal "&" and // "" and invite it to copy them into prose. Both are undone exactly // rather than by a general tag stripper: the only markup bleve's formatter // emits is that one tag pair, so removing it and unescaping restores the // document's own text byte for byte. // // This is coupled to search/'s choice of highlighter. If that ever stops being // bleve's default HTML formatter, this must change with it. func plainSnippet(s string) string { if s == "" { return "" } s = strings.ReplaceAll(s, "", "") s = strings.ReplaceAll(s, "", "") return html.UnescapeString(s) }