mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-22 03:14:06 -05:00
344 lines
11 KiB
Go
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
|
|
}
|