Files
onyx/cli/cmd/search.go
T

344 lines
11 KiB
Go

package cmd
import (
"encoding/json"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/onyx-dot-app/onyx/cli/internal/iostreams"
"github.com/onyx-dot-app/onyx/cli/internal/models"
"github.com/onyx-dot-app/onyx/cli/internal/overflow"
"github.com/spf13/cobra"
)
// searchOutputResult is the per-document JSON shape `onyx-cli search` prints
// (without --raw). One `content` field per result, no Onyx-internal jargon.
type searchOutputResult struct {
Title string `json:"title"`
URL *string `json:"url"`
SourceType string `json:"source_type"`
Content string `json:"content"`
UpdatedAt *string `json:"updated_at"`
}
// searchOutput is the top-level wrapper for `onyx-cli search` default stdout.
type searchOutput struct {
Results []searchOutputResult `json:"results"`
Truncation *searchTruncation `json:"truncation,omitempty"`
}
// searchTruncation is attached to searchOutput when results were dropped or
// trimmed to keep stdout under the output limit. The full pretty-printed
// response is saved to FullResponsePath.
type searchTruncation struct {
Truncated bool `json:"truncated"`
TotalResults int `json:"total_results"`
ShownResults int `json:"shown_results"`
TotalBytes int `json:"total_bytes"`
ContentTruncated bool `json:"content_truncated"`
FullResponsePath string `json:"full_response_path"`
Hint string `json:"hint"`
}
// maxSearchDays caps --days at ~100 years. The cap mostly exists to keep
// `time.Duration(days) * 24h` from wrapping; nobody legitimately searches
// further back than this.
const maxSearchDays = 36500
// toSearchOutput converts the API response into the default stdout shape.
// `CitationID` is kept on `models.SearchResult` and only surfaced via --raw;
// see `models.SearchResult` for the `Content` invariant.
func toSearchOutput(resp models.SearchResponse) searchOutput {
out := searchOutput{Results: make([]searchOutputResult, 0, len(resp.Results))}
for _, r := range resp.Results {
out.Results = append(out.Results, searchOutputResult{
Title: r.Title,
URL: r.Link,
SourceType: r.SourceType,
Content: r.Content,
UpdatedAt: r.UpdatedAt,
})
}
return out
}
// writeSearchJSON prints the search output as pretty JSON. When the payload
// exceeds truncateAt bytes (> 0), the full response is saved to a temp file
// and a smaller — but always valid — JSON envelope with truncation metadata
// is printed instead. Human-oriented notes go to stderr only.
func writeSearchJSON(ios *iostreams.IOStreams, output searchOutput, truncateAt int) error {
data, err := json.MarshalIndent(output, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal response: %w", err)
}
if truncateAt <= 0 || len(data) <= truncateAt {
fmt.Fprintln(ios.Out, string(data))
return nil
}
fullPath, err := overflow.SaveFull("onyx-search-*.json", string(data))
if err != nil {
// Without the temp copy, dropped results would be unrecoverable —
// emit the full response instead (valid JSON beats the byte bound).
fmt.Fprintf(
ios.ErrOut, "warning: could not save full response, emitting it whole: %v\n", err,
)
fmt.Fprintln(ios.Out, string(data))
return nil
}
envelope, err := buildTruncatedSearchOutput(output, truncateAt, len(data), fullPath)
if err != nil {
return fmt.Errorf("failed to marshal response: %w", err)
}
fmt.Fprintln(ios.Out, string(envelope))
note := fmt.Sprintf("response truncated (%d bytes total)", len(data))
if fullPath != "" {
note += "; full response: " + fullPath
}
fmt.Fprintln(ios.ErrOut, note)
return nil
}
// buildTruncatedSearchOutput marshals a valid-JSON envelope that fits under
// limit by dropping whole results (relevance-ordered, so a prefix is kept).
// If the first result alone exceeds the limit, its content is trimmed at a
// rune boundary. The envelope may exceed limit only when the truncation
// metadata alone does: valid JSON always wins over the byte bound.
func buildTruncatedSearchOutput(
full searchOutput, limit int, totalBytes int, fullPath string,
) ([]byte, error) {
trunc := &searchTruncation{
Truncated: true,
TotalResults: len(full.Results),
TotalBytes: totalBytes,
FullResponsePath: fullPath,
Hint: "output was reduced to fit the output limit; the complete response is at full_response_path",
}
marshal := func(results []searchOutputResult) ([]byte, error) {
trunc.ShownResults = len(results)
return json.MarshalIndent(searchOutput{Results: results, Truncation: trunc}, "", " ")
}
fit, data, err := largestFit(len(full.Results), limit, func(n int) ([]byte, error) {
return marshal(full.Results[:n])
})
if err != nil {
return nil, err
}
// Trim content only when the metadata fits but the first whole result
// doesn't; otherwise nothing can fit and the n=0 envelope is best effort.
if fit >= 1 || len(full.Results) == 0 || len(data) > limit {
return data, nil
}
zeroEnvelope := data
trunc.ContentTruncated = true
trimmed := full.Results[0]
runes := []rune(trimmed.Content)
_, data, err = largestFit(len(runes), limit, func(k int) ([]byte, error) {
trimmed.Content = string(runes[:k])
return marshal([]searchOutputResult{trimmed})
})
if err != nil {
return nil, err
}
// Even an empty-content result overflows (oversized title/url): fall back
// to the zero-results envelope, which is known to fit.
if len(data) > limit {
return zeroEnvelope, nil
}
return data, nil
}
// largestFit binary-searches for the largest n in [0, maxN] whose rendering is
// at most limit bytes, returning n and its rendering. render must produce
// output whose size is non-decreasing in n. Falls back to render(0) when
// nothing fits.
func largestFit(
maxN int, limit int, render func(n int) ([]byte, error),
) (int, []byte, error) {
best := 0
bestData, err := render(0)
if err != nil {
return 0, nil, err
}
lo, hi := 1, maxN
for lo <= hi {
mid := (lo + hi) / 2
data, err := render(mid)
if err != nil {
return 0, nil, err
}
if len(data) <= limit {
best, bestData = mid, data
lo = mid + 1
} else {
hi = mid - 1
}
}
return best, bestData, nil
}
// searchFlags bundles the resolved CLI flag inputs for buildSearchRequest.
// `daysSet` / `agentIDSet` track whether the corresponding flag was passed
// explicitly (so unset flags don't end up in the JSON body).
type searchFlags struct {
query string
sources []string
days int
daysSet bool
agentID int
agentIDSet bool
defaultAgentID int
noQueryExpansion bool
}
// buildSearchRequest maps resolved CLI flags into the search API request body.
func buildSearchRequest(flags searchFlags) models.SearchRequest {
req := models.SearchRequest{Query: flags.query}
for _, source := range flags.sources {
source = strings.TrimSpace(source)
if source != "" {
req.Sources = append(req.Sources, source)
}
}
if flags.daysSet {
cutoff := time.Now().UTC().Add(-time.Duration(flags.days) * 24 * time.Hour).Format(time.RFC3339)
req.TimeCutoff = &cutoff
}
if flags.agentIDSet {
req.PersonaID = &flags.agentID
} else if flags.defaultAgentID != 0 {
req.PersonaID = &flags.defaultAgentID
}
if flags.noQueryExpansion {
req.SkipQueryExpansion = true
}
return req
}
func newSearchCmd(ios *iostreams.IOStreams) *cobra.Command {
var (
searchSources string
searchDays int
searchAgentID int
searchRaw bool
searchNoQueryExpansion bool
maxOutput int
)
cmd := &cobra.Command{
Use: "search [query]",
Short: "Search company knowledge and return ranked documents",
Long: `Search the Onyx knowledge base and return ranked, cited documents.
Results are retrieved using the full search pipeline: LLM query expansion,
hybrid retrieval, document selection, and context expansion — the same
search quality as the Onyx chat interface.
By default, output is a lean JSON shape tuned for LLM consumers:
{"results": [{title, url, source_type, content, updated_at}, ...]}.
Results contain only documents the LLM judged relevant, ordered by relevance;
content is the full chunk text of each. Use --raw for the full API response
(adds per-result citation_id).
When stdout is not a TTY and the response exceeds --max-output bytes, whole
results are dropped so stdout stays valid JSON; a "truncation" object carries
metadata (total_results, shown_results, full_response_path, ...) and the full
response is saved to a temp file.`,
Args: cobra.MaximumNArgs(1),
Example: ` onyx-cli search "What is our deployment process?"
onyx-cli search --source slack "auth migration status"
onyx-cli search --days 30 "recent production incidents"
onyx-cli search --agent-id 5 "engineering roadmap"
onyx-cli search --raw "API documentation" | jq '.results[].title'
onyx-cli search --no-query-expansion "exact error message text"`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, client, err := requireClient()
if err != nil {
return err
}
if len(args) == 0 {
return exitcodes.New(exitcodes.BadRequest,
"no query provided\n Usage: onyx-cli search \"your query\"")
}
if cmd.Flags().Changed("days") {
if searchDays <= 0 {
return exitcodes.New(exitcodes.BadRequest,
"--days must be a positive integer")
}
if searchDays > maxSearchDays {
return exitcodes.New(exitcodes.BadRequest,
fmt.Sprintf("--days cannot exceed %d (~100 years)", maxSearchDays))
}
}
var sources []string
if cmd.Flags().Changed("source") {
sources = strings.Split(searchSources, ",")
}
req := buildSearchRequest(searchFlags{
query: args[0],
sources: sources,
days: searchDays,
daysSet: cmd.Flags().Changed("days"),
agentID: searchAgentID,
agentIDSet: cmd.Flags().Changed("agent-id"),
defaultAgentID: cfg.DefaultAgentID,
noQueryExpansion: searchNoQueryExpansion,
})
ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
defer stop()
isTTY := ios.IsStdoutTTY
if isTTY {
fmt.Fprintf(ios.ErrOut, "\033[2mSearching...\033[0m\n")
}
resp, err := client.Search(ctx, req)
if err != nil {
return apiErrorToExit(err, "search failed")
}
if searchRaw {
data, err := json.MarshalIndent(resp, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal response: %w", err)
}
fmt.Fprintln(ios.Out, string(data))
return nil
}
truncateAt := 0
if cmd.Flags().Changed("max-output") {
truncateAt = maxOutput
} else if !isTTY {
truncateAt = defaultMaxOutputBytes
}
return writeSearchJSON(ios, toSearchOutput(*resp), truncateAt)
},
}
cmd.Flags().StringVar(&searchSources, "source", "", "Filter by source type (comma-separated: slack,google_drive)")
cmd.Flags().IntVar(&searchDays, "days", 0, "Only return results from the last N days")
cmd.Flags().IntVar(&searchAgentID, "agent-id", 0, "Agent ID for scoped search")
cmd.Flags().BoolVar(&searchRaw, "raw", false, "Output full API response (adds per-result citation_id)")
cmd.Flags().BoolVar(&searchNoQueryExpansion, "no-query-expansion", false, "Skip LLM query expansion (faster, less comprehensive)")
cmd.Flags().IntVar(&maxOutput, "max-output", defaultMaxOutputBytes,
"Max bytes to print before truncating (0 to disable, auto-enabled for non-TTY, ignored with --raw)")
return cmd
}