Files
mesh-tools/node-tools/internal/console/search.go
T
jochen 49cb06f6c5 Find a tool by the shell command it replaces, best first (hq ADR 0245)
The agent searched with journalctl, logs and docker ps and found nothing, so it went over ssh. Search
now reads what each verb and tool replaces from the controller's records, matches a command line
against it, ranks the matches, and says the command it matched.
2026-10-07 19:21:13 +02:00

248 lines
7.6 KiB
Go

package console
// Finding a tool by the words an agent already has (novox/hq ADR 0242).
//
// 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.
// 3. Nothing else: a search that matches nothing says so.
//
// Ties keep the listing's order — seats first, the place tools belong — then the modules by name.
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
}
}
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)
}
}
sort.SliceStable(hits, func(i, j int) bool { return hits[i].score > hits[j].score })
return hits
}
// replacesOf is what every verb and tool replaces, from the controller's records (novox/hq ADR 0242):
// 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
}