Files
mesh-tools/node-tools/internal/console/search.go
T
jochen aa209313c2
mesh/merge-gate pass: builds mesh-tools, node-tools → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of …
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Rank a search the same whatever order discovery heard things in
A tie went to whichever tool discovery listed first, so docker logs found docker_secrets_in_logs
before docker_logs on some runs. A name the query says whole wins, then the shorter name.
2026-10-07 20:27:26 +02:00

273 lines
8.5 KiB
Go

package console
// Finding a tool by the words an agent already has (novox/hq ADR 0245).
//
// An agent that wants a unit's log reaches for `journalctl`, and one that wants a container's for `docker
// logs`; it searches with those words, or with `logs`. Matched against names and descriptions alone, none
// of them found the service manager's `journal` — whose description says "journal", never "logs" or
// "journalctl" — and the agent went round the mesh over ssh. So each verb and tool says what it replaces,
// and search matches three ways, best first:
//
// 1. **The query is a command a tool replaces** — `journalctl -u x`, `systemctl status x`, `docker ps`:
// every word of the replaced command, in order, among the query's words. The most specific command wins.
// 2. **Every word is in the tool's name, its replaced commands or its description**, a word also matching
// through a short table of the words agents use for the mesh's (logs ⇄ journal, ps ⇄ list). A word in
// the name or a replaced command weighs more than one in the description, and a name the query says
// whole more than one it says in part.
// 3. Nothing else: a search that matches nothing says so.
//
// Ties go to a seat — the place tools belong — then to the shorter name, then by address.
import (
"encoding/json"
"path"
"sort"
"strings"
"unicode"
)
// synonyms are the words an agent uses for the mesh's own, each matching the other's (a word, not part
// of one: `log` must not match `login`). Kept short: a command is said by the tool that replaces it.
var synonyms = map[string][]string{
"logs": {"log", "journal"},
"log": {"logs", "journal"},
"journal": {"logs", "log"},
"ps": {"list", "running"},
"ls": {"list"},
"list": {"ps", "ls"},
"restart": {"restarts"},
"service": {"unit", "units"},
"services": {"unit", "units"},
"unit": {"service"},
"container": {"containers", "docker"},
"firewall": {"filter", "nftables"},
"hosts": {"hostname"},
}
// hit is one match as mesh_search answers it.
type hit struct {
Address string `json:"address"`
Does string `json:"does"`
// InsteadOf is the command the query matched, when it matched one the tool replaces.
InsteadOf string `json:"instead_of,omitempty"`
Also string `json:"also,omitempty"`
score int
}
// words are a text's words, lower-cased, split at anything that is not a letter, a digit, `-` or `_`
// within a word — so `node-service-manager.journal` is its three names' parts and the whole.
func words(s string) []string {
var out []string
for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == '/')
}) {
out = append(out, w)
for _, part := range strings.FieldsFunc(w, func(r rune) bool { return r == '-' || r == '_' || r == '/' }) {
if part != w {
out = append(out, part)
}
}
}
return out
}
// commandWords are a command line's words as matched against a replaced command: lower-cased, a
// program named by path reduced to its name, and what only runs it (`sudo`) left out.
func commandWords(line string) []string {
var out []string
for i, w := range strings.Fields(strings.ToLower(line)) {
if i == 0 || strings.HasPrefix(w, "/") {
w = path.Base(w)
}
if w == "sudo" {
continue
}
out = append(out, w)
}
return out
}
// inOrder says whether every one of want is among have, in the same order.
func inOrder(want, have []string) bool {
i := 0
for _, h := range have {
if i < len(want) && h == want[i] {
i++
}
}
return len(want) > 0 && i == len(want)
}
// replacedBy is the replaced command a query names, the most specific of those it matches, or "".
func replacedBy(query string, replaces []string) string {
q := commandWords(query)
best, bestLen := "", 0
for _, r := range replaces {
w := commandWords(r)
if inOrder(w, q) && len(w) > bestLen {
best, bestLen = r, len(w)
}
}
return best
}
// scoreOf is how well a tool matches the query: 0 for not at all.
func scoreOf(query string, qwords []string, name, description string, replaces []string) (int, string) {
if cmd := replacedBy(query, replaces); cmd != "" {
return 100 + 10*len(commandWords(cmd)), cmd
}
strong := strings.ToLower(name + " " + strings.Join(replaces, " "))
weak := strings.ToLower(description)
strongWords := map[string]bool{}
for _, w := range words(strong) {
strongWords[w] = true
}
weakWords := map[string]bool{}
for _, w := range words(weak) {
weakWords[w] = true
}
score := 10
for _, w := range qwords {
switch {
case strings.Contains(strong, w):
score += 5
case strings.Contains(weak, w):
score++
default:
found := 0
for _, s := range synonyms[w] {
if strongWords[s] {
found = 5
break
}
if weakWords[s] {
found = 1
}
}
if found == 0 {
return 0, ""
}
score += found
}
}
// A name the query says whole — `docker logs` for docker_logs, not docker_secrets_in_logs — first.
own := name[strings.LastIndex(name, ".")+1:]
whole := true
for _, part := range strings.FieldsFunc(strings.ToLower(own), func(r rune) bool { return r == '_' || r == '-' }) {
in := false
for _, w := range qwords {
in = in || w == part
}
whole = whole && in
}
if whole {
score += 2
}
return score, ""
}
// search is mesh_search's answer: every match, best first.
func (x *index) search(query string) []hit {
query = strings.ToLower(strings.TrimSpace(query))
qwords := strings.Fields(query)
var hits []hit
for _, st := range x.Seats {
for _, v := range st.Verbs {
replaces := x.Replaces["seat:"+st.Seat+"."+v.Name]
score, cmd := scoreOf(query, qwords, st.Seat+"."+v.Name, v.Description, replaces)
if score == 0 {
continue
}
h := hit{Does: firstLine(v.Description), InsteadOf: cmd, score: score + 1} // a seat first, on a tie
if st.Scope == "node" {
on := nodesOf(st.Holders)
first := "<node>"
if len(on) > 0 {
first = on[0]
if len(on) > 1 {
h.Also = "also on " + strings.Join(on[1:], ", ")
}
}
h.Address = first + "/" + st.Seat + "." + v.Name
} else {
h.Address = st.Seat + "." + v.Name
}
hits = append(hits, h)
}
}
names := make([]string, 0, len(x.Modules))
for n := range x.Modules {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
m := x.Modules[n]
for _, t := range m.Tools {
score, cmd := scoreOf(query, qwords, m.Module+"."+t.Name, t.Description, x.Replaces[m.Module+"."+t.Name])
if score == 0 {
continue
}
h := hit{Does: firstLine(t.Description), InsteadOf: cmd, score: score}
switch {
case m.Interchangeable:
h.Address, h.Also = m.Module+"."+t.Name, "any instance; on "+orNobody(m.On)
case len(m.On) == 0:
h.Address, h.Also = "<node>/"+m.Module+"."+t.Name, "the mesh places it on no machine"
default:
h.Address = m.On[0] + "/" + m.Module + "." + t.Name
if len(m.On) > 1 {
h.Also = "also on " + strings.Join(m.On[1:], ", ")
}
}
hits = append(hits, h)
}
}
// Best first; on a tie the shorter name — the plainer tool — then the address, so an answer never
// depends on the order discovery heard things in.
sort.SliceStable(hits, func(i, j int) bool {
if hits[i].score != hits[j].score {
return hits[i].score > hits[j].score
}
a, b := hits[i].Address[strings.LastIndex(hits[i].Address, ".")+1:], hits[j].Address[strings.LastIndex(hits[j].Address, ".")+1:]
if len(a) != len(b) {
return len(a) < len(b)
}
return hits[i].Address < hits[j].Address
})
return hits
}
// replacesOf is what every verb and tool replaces, from the controller's records (novox/hq ADR 0245):
// a seat's verbs from its `tools` answer, keyed `seat:<seat>.<verb>`, and a module's own tools from
// its `modules` answer, keyed `<module>.<tool>`. A controller older than the field answers neither,
// and search matches names and descriptions as before.
func replacesOf(toolsAnswer json.RawMessage, modules []recordedModule) map[string][]string {
out := map[string][]string{}
var seats struct {
Seats []struct {
Seat string `json:"seat"`
Tools []struct {
Name string `json:"name"`
Replaces []string `json:"replaces"`
} `json:"tools"`
} `json:"seats"`
}
if json.Unmarshal(toolsAnswer, &seats) == nil {
for _, s := range seats.Seats {
for _, t := range s.Tools {
if len(t.Replaces) > 0 {
out["seat:"+s.Seat+"."+t.Name] = t.Replaces
}
}
}
}
for _, m := range modules {
for tool, r := range m.Replaces {
if len(r) > 0 {
out[m.Module+"."+tool] = r
}
}
}
return out
}