Vectrum
Sign In
458 lines · 13.5 KB · Go
// Package websearch is everything in Codey that reaches the open internet.
//
// It is one package rather than part of internal/tools so that the network
// lives behind a seam: the tools call Search and Fetch, and the rules about
// what may be dialled, how long it may take and how much may be read are in
// here where they can be read in one sitting. It imports no other internal
// package.
package websearch

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os/exec"
	"strconv"
	"strings"
	"time"
)

// DefaultCommand is the search this package runs when nothing else is
// configured: the DuckDuckGo CLI, `pipx install ddgs`.
//
// It is the default because it needs no account and no API key. A search tool
// whose first instruction is "go and register for an API key" is a search tool
// most people never turn on, and the ones who do have a key can say so.
//
// Note what is not in it: `-o json`. That flag does not print JSON, it writes
// a file — `text_my_query_20260918_205106.json`, into whatever directory the
// agent happens to be working in. A default that returns nothing at all and
// litters the user's repository once per search is worse than having no
// default, so the plain stdout format is asked for and parsed instead. `-nc`
// turns off the colour codes that would otherwise land inside every title.
const DefaultCommand = "ddgs text -q {{query}} -m {{count}} -nc"

// Placeholders the command line may contain. They are substituted into
// individual argv elements after the line has been split, never into the line
// before it — the query is written by the model, and a query interpolated into
// a string that later meets a shell is a command-injection hole with extra
// steps.
const (
	queryPlaceholder = "{{query}}"
	countPlaceholder = "{{count}}"
)

// commandTimeout is how long a search may take. A search is something the user
// is waiting through, so it is a fraction of the bash tool's minute: a provider
// that has not answered in half a minute is a provider to give up on and say so.
const commandTimeout = 30 * time.Second

// Result is one search hit, reduced to the three things a coding agent can use.
type Result struct {
	Title   string
	URL     string
	Snippet string
}

// Searcher is a source of search results. The interface exists so the tool can
// be tested against a stub and so a second provider is a new file rather than a
// change to the tool.
type Searcher interface {
	// Name is what the provider is called, for the line the model reads under
	// its results.
	Name() string
	Search(ctx context.Context, query string, count int) ([]Result, error)
}

// command is a Searcher that runs an external program and reads JSON off its
// stdout.
type command struct {
	argv []string
	name string
}

// NewCommand builds a Searcher from a command line containing {{query}} and,
// optionally, {{count}}. An empty line means DefaultCommand.
//
// The line is split here, once, so a template that cannot be parsed is an error
// at startup — where the user is looking — rather than on the first search,
// where only the model would see it.
func NewCommand(template string) (Searcher, error) {
	line := strings.TrimSpace(template)
	if line == "" {
		line = DefaultCommand
	}
	argv, err := splitArgs(line)
	if err != nil {
		return nil, fmt.Errorf("search command %q: %w", line, err)
	}
	if len(argv) == 0 {
		return nil, fmt.Errorf("search command is empty")
	}
	if !strings.Contains(line, queryPlaceholder) {
		// Without it the command would run the same search every time, which
		// looks like a broken provider rather than a broken config.
		return nil, fmt.Errorf("search command %q does not contain %s", line, queryPlaceholder)
	}
	return &command{argv: argv, name: baseName(argv[0])}, nil
}

// Available reports whether the command's program is on PATH. The caller uses
// it to decide whether to offer the tool at all: an agent told it can search,
// which then discovers on its first attempt that nothing is installed, has
// spent a turn to learn what startup already knew.
func Available(template string) error {
	s, err := NewCommand(template)
	if err != nil {
		return err
	}
	bin := s.(*command).argv[0]
	if _, err := exec.LookPath(bin); err != nil {
		if bin == "ddgs" {
			return fmt.Errorf("%s is not on PATH — install it with `pipx install ddgs`, or set another search command", bin)
		}
		return fmt.Errorf("%s is not on PATH", bin)
	}
	return nil
}

func (c *command) Name() string { return c.name }

func (c *command) Search(ctx context.Context, query string, count int) ([]Result, error) {
	query = strings.TrimSpace(query)
	if query == "" {
		return nil, errors.New("query is required")
	}
	if count <= 0 {
		count = 5
	}

	argv := make([]string, len(c.argv))
	for i, a := range c.argv {
		a = strings.ReplaceAll(a, queryPlaceholder, query)
		argv[i] = strings.ReplaceAll(a, countPlaceholder, strconv.Itoa(count))
	}

	ctx, cancel := context.WithTimeout(ctx, commandTimeout)
	defer cancel()

	// No shell. argv goes to execve as it stands, so a query containing a
	// semicolon, a backtick or $(…) is a query and not a second command.
	cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
	var stdout, stderr bytes.Buffer
	cmd.Stdout, cmd.Stderr = &stdout, &stderr

	runErr := cmd.Run()
	if runErr != nil {
		if errors.Is(runErr, exec.ErrNotFound) {
			return nil, notInstalled(argv[0])
		}
		if ctx.Err() == context.DeadlineExceeded {
			return nil, fmt.Errorf("%s did not answer within %s", c.name, commandTimeout)
		}
		// Exit 127 is what a shell wrapper reports for "not found", which is
		// what the user actually needs to hear.
		var exitErr *exec.ExitError
		if errors.As(runErr, &exitErr) && exitErr.ExitCode() == 127 {
			return nil, notInstalled(argv[0])
		}
		return nil, fmt.Errorf("%s failed (%v): %s", c.name, runErr, preview(stderr.Bytes(), stdout.Bytes()))
	}

	results, err := parseResults(stdout.Bytes())
	if err != nil {
		return nil, fmt.Errorf("could not read %s output: %v — it printed: %s",
			c.name, err, preview(stdout.Bytes(), stderr.Bytes()))
	}
	if len(results) > count {
		results = results[:count]
	}
	return results, nil
}

func notInstalled(bin string) error {
	if bin == "ddgs" {
		return fmt.Errorf("ddgs is not installed — `pipx install ddgs`, or configure another search command")
	}
	return fmt.Errorf("%s is not installed or not on PATH", bin)
}

// preview is the first part of whatever the command actually printed. It goes
// into the error because "could not parse the output" without the output is a
// message that sends someone to run the command by hand to learn what this
// process already knew.
func preview(streams ...[]byte) string {
	const max = 200
	for _, s := range streams {
		t := strings.TrimSpace(string(s))
		if t == "" {
			continue
		}
		t = strings.Join(strings.Fields(t), " ")
		if len(t) > max {
			t = t[:max] + "…"
		}
		return t
	}
	return "(nothing)"
}

// parseResults reads the shapes search tools print: a JSON array, a JSON
// object wrapping one, newline-delimited JSON objects, and ddgs's own aligned
// key/value text. Which one you get depends on the tool and, for ddgs, on its
// version and flags — so all of them are read rather than pinning users to one.
func parseResults(out []byte) ([]Result, error) {
	trimmed := bytes.TrimSpace(out)
	if len(trimmed) == 0 {
		return nil, errors.New("no output")
	}

	var rows []map[string]any
	switch trimmed[0] {
	case '[':
		if err := json.Unmarshal(trimmed, &rows); err != nil {
			return nil, err
		}
	case '{':
		// Either one wrapper object with an array inside, or the first line of
		// newline-delimited JSON.
		var obj map[string]json.RawMessage
		if err := json.Unmarshal(trimmed, &obj); err == nil {
			for _, key := range []string{"results", "data", "items", "hits"} {
				if raw, ok := obj[key]; ok {
					if err := json.Unmarshal(raw, &rows); err != nil {
						return nil, err
					}
					return rowsToResults(rows), nil
				}
			}
		}
		var err error
		rows, err = parseNDJSON(trimmed)
		if err != nil {
			return nil, err
		}
	default:
		// Not JSON at all. Before giving up, try the aligned text format —
		// this is the branch the shipped default lands in.
		if results := parseKeyValueText(trimmed); len(results) > 0 {
			return results, nil
		}
		return nil, fmt.Errorf("not JSON, and not a result listing either")
	}
	return rowsToResults(rows), nil
}

// parseKeyValueText reads what ddgs prints when it is not asked for a file:
//
//	1.
//	title       errors package - Go Packages
//	href        https://pkg.go.dev/errors
//	body        The error formats as the concatenation
//	            of the strings obtained by calling…
//	2.
//	…
//
// A record opens with a number and a dot. A field is a known key followed by
// two or more spaces; anything else indented is a continuation of the field
// before it, because the tool wraps long values to the terminal width. Only
// the three keys this package has a use for are recognised — an unknown key
// ends the field it follows rather than being folded into it.
func parseKeyValueText(out []byte) []Result {
	var (
		results []Result
		cur     Result
		open    bool
		field   *string
	)
	flush := func() {
		if open && cur.URL != "" {
			if cur.Title == "" {
				cur.Title = cur.URL
			}
			results = append(results, cur)
		}
		cur, open, field = Result{}, false, nil
	}

	for _, raw := range strings.Split(string(out), "\n") {
		line := strings.TrimRight(raw, " \t\r")
		if strings.TrimSpace(line) == "" {
			continue
		}
		// "12." on its own opens the next record.
		if t := strings.TrimSpace(line); strings.HasSuffix(t, ".") && isDigits(t[:len(t)-1]) {
			flush()
			open = true
			continue
		}
		// An indented line with no key of its own continues the last field.
		if strings.HasPrefix(line, "  ") && field != nil {
			if key, _, isField := splitField(strings.TrimSpace(line)); !isField || fieldFor(&cur, key) == nil {
				*field = strings.TrimSpace(*field + " " + strings.TrimSpace(line))
				continue
			}
		}
		key, value, isField := splitField(strings.TrimSpace(line))
		if !isField {
			field = nil
			continue
		}
		target := fieldFor(&cur, key)
		if target == nil {
			field = nil
			continue
		}
		open = true
		*target = value
		field = target
	}
	flush()
	return results
}

// splitField cuts "title       some value" at the run of spaces that aligns
// the column.
func splitField(line string) (key, value string, ok bool) {
	i := strings.Index(line, "  ")
	if i <= 0 {
		return "", "", false
	}
	return strings.ToLower(line[:i]), strings.TrimSpace(line[i:]), true
}

// fieldFor maps one of ddgs's keys onto the Result field it fills, or nil for
// a key this package does not use.
func fieldFor(r *Result, key string) *string {
	switch key {
	case "title":
		return &r.Title
	case "href", "url", "link":
		return &r.URL
	case "body", "snippet", "description":
		return &r.Snippet
	}
	return nil
}

func isDigits(s string) bool {
	if s == "" {
		return false
	}
	for _, r := range s {
		if r < '0' || r > '9' {
			return false
		}
	}
	return true
}

func parseNDJSON(out []byte) ([]map[string]any, error) {
	var rows []map[string]any
	for _, line := range bytes.Split(out, []byte("\n")) {
		line = bytes.TrimSpace(line)
		if len(line) == 0 {
			continue
		}
		var row map[string]any
		if err := json.Unmarshal(line, &row); err != nil {
			return nil, err
		}
		rows = append(rows, row)
	}
	if len(rows) == 0 {
		return nil, errors.New("no JSON objects")
	}
	return rows, nil
}

// rowsToResults maps whatever the tool called its fields onto the three this
// package has a use for. The alternatives are not a wish list: ddgs says
// title/href/body, most APIs say title/url/snippet, and a few say link and
// description.
func rowsToResults(rows []map[string]any) []Result {
	out := make([]Result, 0, len(rows))
	for _, row := range rows {
		lower := make(map[string]any, len(row))
		for k, v := range row {
			lower[strings.ToLower(k)] = v
		}
		r := Result{
			Title:   firstString(lower, "title", "heading", "name"),
			URL:     firstString(lower, "href", "url", "link"),
			Snippet: firstString(lower, "body", "snippet", "description", "abstract", "content", "excerpt"),
		}
		// A hit with no URL is not a search result; it is a row of something
		// else that happened to be on stdout.
		if r.URL == "" {
			continue
		}
		if r.Title == "" {
			r.Title = r.URL
		}
		out = append(out, r)
	}
	return out
}

func firstString(row map[string]any, keys ...string) string {
	for _, k := range keys {
		if v, ok := row[k].(string); ok && strings.TrimSpace(v) != "" {
			return strings.TrimSpace(v)
		}
	}
	return ""
}

// splitArgs splits a command line into argv the way a shell would for the one
// case that matters here — quoted arguments — and in no other way. There is no
// expansion, no globbing, no substitution and no operators: this is a list of
// arguments written in a config file, not a program.
func splitArgs(line string) ([]string, error) {
	var (
		args []string
		cur  strings.Builder
		open bool
		quot rune
	)
	flush := func() {
		if open || cur.Len() > 0 {
			args = append(args, cur.String())
			cur.Reset()
		}
	}
	for _, r := range line {
		switch {
		case quot != 0:
			if r == quot {
				quot = 0
				continue
			}
			cur.WriteRune(r)
		case r == '\'' || r == '"':
			quot = r
			open = true
		case r == ' ' || r == '\t':
			flush()
			open = false
		default:
			cur.WriteRune(r)
		}
	}
	if quot != 0 {
		return nil, errors.New("unbalanced quote")
	}
	flush()
	return args, nil
}

// baseName is the program's name without its directory, for the line under the
// results. A user who configured /opt/homebrew/bin/ddgs wants to read "ddgs".
func baseName(p string) string {
	if i := strings.LastIndexAny(p, `/\`); i >= 0 && i+1 < len(p) {
		return p[i+1:]
	}
	return p
}