From 49cb06f6c535036af32a9043e1515efd1a34fcf2 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 7 Oct 2026 19:21:13 +0200 Subject: [PATCH 1/2] 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. --- README.md | 7 + merge-check.sh | 5 +- node-tools/internal/console/address.go | 105 +++------ node-tools/internal/console/search.go | 247 +++++++++++++++++++++ node-tools/internal/console/search_test.go | 151 +++++++++++++ 5 files changed, 443 insertions(+), 72 deletions(-) create mode 100644 node-tools/internal/console/search.go create mode 100644 node-tools/internal/console/search_test.go diff --git a/README.md b/README.md index b6fc533..d47558d 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,13 @@ address) and `mesh_runtimes` (which runtimes answered discovery: per runtime its answer took, its size in bytes, how many modules and tools it announced, whether it was shortened to fit the bus, when it was last heard — and who was expected and not heard). +`mesh_search` also finds a tool by the shell command it replaces (novox/hq ADR 0242): each seat verb and +module tool says what it replaces in the controller's records (`replaces`, read from the controller's +`tools` and `modules` answers), so `journalctl -u x`, `systemctl status x` or `docker ps` puts the verb +for it first, and says the command it matched as `instead_of`. A word also matches through a short table +of the words agents use for the mesh's (`logs` finds the journal). Best first: a replaced command, then +words in a name or a replaced command, then words in a description; seats before modules on a tie. + Discovery asks the bus (ADR 0197): every runtime answers the NATS services protocol's `$SRV.PING` and `$SRV.INFO` with what it serves at that moment, and the console reads where the controller's records place each module. The console waits at least 750 ms, and up to 5 s for every runtime that answered diff --git a/merge-check.sh b/merge-check.sh index cca6af2..5f91179 100644 --- a/merge-check.sh +++ b/merge-check.sh @@ -30,8 +30,9 @@ bundleless="" if ! command -v node >/dev/null 2>&1 || [ ! -d node_modules/@novox/mesh-sdk ]; then echo "NOT TESTED HERE: internal/runtime and internal/console launch bundles that need node and @novox/mesh-sdk" packages=$(printf '%s\n' "$packages" | grep -v -e '/internal/runtime$' -e '/internal/console$') - # What of the console launches no bundle runs anyway: how an address resolves (novox/hq issue 287). - bundleless='^TestASeatAndAModuleOfOneNameAreEachReached$' + # What of the console launches no bundle runs anyway: how an address resolves (novox/hq issue 287), + # and how a search ranks (novox/hq ADR 0242). + bundleless='^(TestASeatAndAModuleOfOneNameAreEachReached|TestSearch.*|TestASearchByCommandSaysWhatItReplaces)$' fi if command -v gcc >/dev/null 2>&1; then CGO_ENABLED=1 go test -race -count=1 $packages diff --git a/node-tools/internal/console/address.go b/node-tools/internal/console/address.go index f8236aa..6ec497c 100644 --- a/node-tools/internal/console/address.go +++ b/node-tools/internal/console/address.go @@ -67,9 +67,10 @@ func discovery() []map[string]any { {"name": verbMachine, "inputSchema": obj(map[string]any{"node": str("the machine, as mesh_overview names it")}, "node"), "description": "One machine: the seats it holds with their verbs, and the modules assigned to it with their tools — " + "each with the address to describe or call it by. " + grammar}, - {"name": verbSearch, "inputSchema": obj(map[string]any{"query": str("words to find in tool names and descriptions, e.g. `postgres databases`")}, "query"), + {"name": verbSearch, "inputSchema": obj(map[string]any{"query": str("words to find in tool names and descriptions, e.g. `postgres databases` — or the shell command you would have run, e.g. `journalctl -u x`, `docker ps`")}, "query"), "description": "Find tools anywhere in the mesh by words: every match's address and a line of what it does, across the " + - "mesh's seats, the machines' seats and every module on every machine. " + grammar}, + "mesh's seats, the machines' seats and every module on every machine, best first. A shell command finds the tool " + + "that replaces it — search before reaching for ssh, journalctl, systemctl or docker. " + grammar}, {"name": verbDescribe, "inputSchema": obj(map[string]any{"address": str("the tool's address")}, "address"), "description": "What one tool does and the arguments it takes, as a JSON schema. The machine is in the address, " + "never an argument. " + grammar}, @@ -130,6 +131,9 @@ type index struct { // what it serves in time, or it answered before and not now. An address it might answer is never // called missing while it is here (2026-10-05). Unheard []unheard + // Replaces is what each verb and tool replaces, from the controller's records (novox/hq ADR 0242): + // `seat:.` and `.`. + Replaces map[string][]string } // unheard is one runtime discovery did not hear in full, and why. @@ -221,6 +225,8 @@ type recordedModule struct { Module string `json:"module"` On []string `json:"on"` Tools bool `json:"tools"` + // Replaces is what each of its own tools replaces (novox/hq ADR 0242). + Replaces map[string][]string `json:"replaces,omitempty"` } // recordedMachine is a machine as the controller's records hold it (`node list --json`). @@ -234,9 +240,18 @@ type recordedMachine struct { func indexOn(conn *bus.Conn) (*index, error) { var wg sync.WaitGroup var nodesOut, modulesOut string - wg.Add(2) + var toolsAnswer json.RawMessage + wg.Add(3) go func() { defer wg.Done(); nodesOut, _ = controllerOutput(conn, "nodes") }() go func() { defer wg.Done(); modulesOut, _ = controllerOutput(conn, "modules") }() + // The seats' verbs as the records define them, for what each replaces (novox/hq ADR 0242): answered + // in-process from the records, never a command. + go func() { + defer wg.Done() + if got, err := conn.Ask("seat:mesh-controller.tools", map[string]any{}, ""); err == nil { + toolsAnswer = got.Result + } + }() d, err := announce.Gather(conn) wg.Wait() if err != nil { @@ -258,7 +273,9 @@ func indexOn(conn *bus.Conn) (*index, error) { // What should have answered: every assignment of a module that declares tools. Silence is named; // a module with no tools is never a name here. var recorded []recordedModule - if json.Unmarshal([]byte(jsonIn(modulesOut)), &recorded) == nil { + recordedOK := json.Unmarshal([]byte(jsonIn(modulesOut)), &recorded) == nil + x.Replaces = replacesOf(toolsAnswer, recorded) + if recordedOK { for _, m := range recorded { if !m.Tools { continue @@ -803,70 +820,10 @@ func (s *Surface) discover(name string, args map[string]any) map[string]any { case verbSearch: query := strings.ToLower(str("query")) if query == "" { - return failure("mesh_search needs `query`: words to find, e.g. `postgres databases`") - } - words := strings.Fields(query) - type hit struct { - Address string `json:"address"` - Does string `json:"does"` - Also string `json:"also,omitempty"` - } - var hits []hit - matches := func(parts ...string) bool { - hay := strings.ToLower(strings.Join(parts, " ")) - for _, w := range words { - if !strings.Contains(hay, w) { - return false - } - } - return true - } - for _, st := range x.Seats { - for _, v := range st.Verbs { - if !matches(st.Seat, v.Name, v.Description) { - continue - } - if st.Scope == "node" { - on := nodesOf(st.Holders) - first := "" - also := "" - if len(on) > 0 { - first = on[0] - if len(on) > 1 { - also = "also on " + strings.Join(on[1:], ", ") - } - } - hits = append(hits, hit{first + "/" + st.Seat + "." + v.Name, firstLine(v.Description), also}) - } else { - hits = append(hits, hit{st.Seat + "." + v.Name, firstLine(v.Description), ""}) - } - } - } - 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 { - if !matches(m.Module, t.Name, t.Description) { - continue - } - switch { - case m.Interchangeable: - hits = append(hits, hit{m.Module + "." + t.Name, firstLine(t.Description), "any instance; on " + orNobody(m.On)}) - case len(m.On) == 0: - hits = append(hits, hit{"/" + m.Module + "." + t.Name, firstLine(t.Description), "the mesh places it on no machine"}) - default: - also := "" - if len(m.On) > 1 { - also = "also on " + strings.Join(m.On[1:], ", ") - } - hits = append(hits, hit{m.On[0] + "/" + m.Module + "." + t.Name, firstLine(t.Description), also}) - } - } + return failure("mesh_search needs `query`: words to find, e.g. `postgres databases`, or the command you would " + + "have run, e.g. `journalctl` or `docker ps`") } + hits := x.search(query) out := map[string]any{"matches": hits} if len(hits) > searchCap { out["matches"] = hits[:searchCap] @@ -874,7 +831,8 @@ func (s *Surface) discover(name string, args map[string]any) map[string]any { } if len(hits) == 0 { out["matches"] = []hit{} - out["hint"] = "nothing matched every word; try fewer words, or mesh_overview and mesh_machine to browse" + out["hint"] = "nothing matched every word; try fewer words, or mesh_overview and mesh_machine to browse. " + + "If no tool does it, one is created in the module that owns it, on its seat — never worked around (novox/hq ADR 0242)" } if len(x.Unheard) > 0 { out["incomplete"] = "discovery did not hear in full from " + sayUnheard(x.Unheard) + @@ -895,8 +853,15 @@ func (s *Surface) discover(name string, args map[string]any) map[string]any { if description == "" { description = t.Name } - return answerText(map[string]any{"address": t.Address, "description": description, - "arguments": schemaAsPassed(t)}) + described := map[string]any{"address": t.Address, "description": description, "arguments": schemaAsPassed(t)} + key := t.Tool.Module + "." + t.Tool.Name + if t.Seat { + key = "seat:" + key + } + if r := x.Replaces[key]; len(r) > 0 { + described["instead_of"] = r + } + return answerText(described) case verbCall: t, err := resolve(x, str("address")) diff --git a/node-tools/internal/console/search.go b/node-tools/internal/console/search.go new file mode 100644 index 0000000..66b0625 --- /dev/null +++ b/node-tools/internal/console/search.go @@ -0,0 +1,247 @@ +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 := "" + 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 = "/"+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:.`, and a module's own tools from +// its `modules` answer, keyed `.`. 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 +} diff --git a/node-tools/internal/console/search_test.go b/node-tools/internal/console/search_test.go new file mode 100644 index 0000000..5683782 --- /dev/null +++ b/node-tools/internal/console/search_test.go @@ -0,0 +1,151 @@ +package console + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/novox/mesh-tools/node-tools/internal/announce" +) + +// meshToSearch is a mesh as discovery and the controller's records describe it: the service manager's +// verbs on two machines, the controller's own, a docker module with its tools, and what each replaces +// as the records say it — the seats' from `tools`, the module's from `modules`. +func meshToSearch(t *testing.T) *index { + t.Helper() + var endpoints []announce.Endpoint + verbs := map[string]string{ + "units": "The units the service manager knows in a scope, each with its load, active and sub state.", + "status": "One unit as the service manager sees it now: its states, whether it starts at boot.", + "restart": "Restart one unit.", + "journal": "The last lines of one unit's journal (at most 2000).", + } + for _, node := range []string{"anchor", "laptop"} { + for verb, does := range verbs { + endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindSeat, Module: "systemd", Seat: "node-service-manager", + Tool: verb, Scope: "node", Node: node, Description: does, + Subject: "mesh.seat.node-service-manager.tool." + verb + "." + node}) + } + } + for verb, does := range map[string]string{ + "node": "What one machine reported it can do, what it is assigned, and why.", + "nodes": "Every machine the mesh knows, with whether it is converged or adopted.", + } { + endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindSeat, Module: "mesh-controller", Seat: "mesh-controller", + Tool: verb, Scope: "mesh", Description: does, Subject: "mesh.seat.mesh-controller.tool." + verb}) + } + for tool, does := range map[string]string{ + "docker_list": "Every container on this machine: name, image, state, ports.", + "docker_logs": "The last lines a container wrote.", + "docker_secrets_in_logs": "Whether a container's logs hold a secret.", + "docker_restart": "Restart one container.", + } { + endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindTool, Module: "docker", Tool: tool, Node: "anchor", + Description: does, Subject: "mesh.mod.docker.tool." + tool + ".anchor"}) + } + x, _, _ := indexOfEndpoints(endpoints) + tools := json.RawMessage(`{"seats":[{"seat":"node-service-manager","scope":"node","tools":[ + {"name":"units","replaces":["systemctl list-units","systemctl --failed"]}, + {"name":"status","replaces":["systemctl status","systemctl is-active"]}, + {"name":"restart","replaces":["systemctl restart"]}, + {"name":"journal","replaces":["journalctl"]}]}, + {"seat":"mesh-controller","scope":"mesh","tools":[{"name":"node","replaces":["hostnamectl","uptime"]},{"name":"nodes"}]}]}`) + x.Replaces = replacesOf(tools, []recordedModule{{Module: "docker", On: []string{"anchor"}, Tools: true, + Replaces: map[string][]string{"docker_list": {"docker ps"}, "docker_logs": {"docker logs"}, "docker_restart": {"docker restart"}}}}) + return x +} + +// **The words an agent has find the verb first** (novox/hq ADR 0242): the command it would have run, or +// the word it would have used, puts the mesh's tool for it at the top. +func TestSearchFindsTheToolThatReplacesACommandFirst(t *testing.T) { + x := meshToSearch(t) + for query, want := range map[string]string{ + "journalctl": "anchor/node-service-manager.journal", + "journalctl -u mesh-controller -n 50": "anchor/node-service-manager.journal", + "sudo journalctl -fu sshd": "anchor/node-service-manager.journal", + "/usr/bin/journalctl": "anchor/node-service-manager.journal", + "logs": "anchor/node-service-manager.journal", + "systemctl status": "anchor/node-service-manager.status", + "systemctl status nats.service": "anchor/node-service-manager.status", + "systemctl restart mesh-controller": "anchor/node-service-manager.restart", + "systemctl --failed": "anchor/node-service-manager.units", + "docker ps": "anchor/docker.docker_list", + "docker ps -a": "anchor/docker.docker_list", + "docker logs": "anchor/docker.docker_logs", + "docker logs --tail 100 nats": "anchor/docker.docker_logs", + "docker restart nats": "anchor/docker.docker_restart", + "container logs": "anchor/docker.docker_logs", + "uptime": "mesh-controller.node", + } { + hits := x.search(query) + if len(hits) == 0 { + t.Errorf("%q found nothing; want %s first", query, want) + continue + } + if hits[0].Address != want { + var got []string + for _, h := range hits { + got = append(got, h.Address) + } + t.Errorf("%q found %s first; want %s (all: %s)", query, hits[0].Address, want, strings.Join(got, ", ")) + } + } +} + +// A match on a replaced command says which command it replaced, and the most specific one wins. +func TestASearchByCommandSaysWhatItReplaces(t *testing.T) { + x := meshToSearch(t) + hits := x.search("systemctl status sshd") + if hits[0].InsteadOf != "systemctl status" { + t.Errorf("the match does not say what it replaces: %+v", hits[0]) + } + if hits[0].Also != "also on laptop" { + t.Errorf("a node seat's verb does not say its other machines: %+v", hits[0]) + } + // `logs` also finds the container's logs, after the journal — both are answers. + var docker bool + for _, h := range x.search("logs")[:3] { + docker = docker || h.Address == "anchor/docker.docker_logs" + } + if !docker { + t.Errorf("`logs` does not find docker_logs among the first: %+v", x.search("logs")) + } +} + +// What search did before stays: every word in a name or description, and nothing for a word nothing has. +// A synonym is a word, never part of one: `logs` matches through `log`, and finds no `login`. +func TestSearchStillMatchesEveryWord(t *testing.T) { + x := meshToSearch(t) + if hits := x.search("machine converged"); len(hits) != 1 || hits[0].Address != "mesh-controller.nodes" { + t.Errorf("words in a description: %+v", hits) + } + if hits := x.search("journalctl kubernetes"); len(hits) != 1 || hits[0].Address != "anchor/node-service-manager.journal" { + t.Errorf("a command with arguments nothing has is still that command: %+v", hits) + } + if hits := x.search("kubernetes"); len(hits) != 0 { + t.Errorf("a word nothing has matched: %+v", hits) + } + x.Modules["shell"] = &moduleInfo{Module: "shell", On: []string{"anchor"}, Tools: []Tool{{Module: "shell", Name: "shell_login", + Description: "The account's login shell."}}} + for _, h := range x.search("logs") { + if h.Address == "anchor/shell.shell_login" { + t.Errorf("`logs` found `login` through its synonym `log`: %+v", h) + } + } +} + +// A controller older than `replaces` answers without it: search matches names and descriptions as before. +func TestSearchWithoutReplacesMatchesAsBefore(t *testing.T) { + x := meshToSearch(t) + x.Replaces = replacesOf(json.RawMessage(`{"seats":[{"seat":"node-service-manager","tools":[{"name":"journal"}]}]}`), + []recordedModule{{Module: "docker"}}) + if len(x.Replaces) != 0 { + t.Fatalf("replaces from records that have none: %v", x.Replaces) + } + if hits := x.search("journal"); len(hits) == 0 || hits[0].Address != "anchor/node-service-manager.journal" { + t.Errorf("a verb's own name: %+v", hits) + } + if hits := x.search("docker logs"); len(hits) == 0 || hits[0].Address != "anchor/docker.docker_logs" { + t.Errorf("a tool's own name: %+v", hits) + } +} From aa209313c27a018f8bf214e4cfc5e10173fd9311 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 7 Oct 2026 19:38:01 +0200 Subject: [PATCH 2/2] 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. --- README.md | 2 +- merge-check.sh | 2 +- node-tools/internal/console/address.go | 8 ++--- node-tools/internal/console/search.go | 35 ++++++++++++++++++---- node-tools/internal/console/search_test.go | 2 +- 5 files changed, 37 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index d47558d..21e1f68 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ address) and `mesh_runtimes` (which runtimes answered discovery: per runtime its answer took, its size in bytes, how many modules and tools it announced, whether it was shortened to fit the bus, when it was last heard — and who was expected and not heard). -`mesh_search` also finds a tool by the shell command it replaces (novox/hq ADR 0242): each seat verb and +`mesh_search` also finds a tool by the shell command it replaces (novox/hq ADR 0245): each seat verb and module tool says what it replaces in the controller's records (`replaces`, read from the controller's `tools` and `modules` answers), so `journalctl -u x`, `systemctl status x` or `docker ps` puts the verb for it first, and says the command it matched as `instead_of`. A word also matches through a short table diff --git a/merge-check.sh b/merge-check.sh index 5f91179..d04b8a5 100644 --- a/merge-check.sh +++ b/merge-check.sh @@ -31,7 +31,7 @@ if ! command -v node >/dev/null 2>&1 || [ ! -d node_modules/@novox/mesh-sdk ]; t echo "NOT TESTED HERE: internal/runtime and internal/console launch bundles that need node and @novox/mesh-sdk" packages=$(printf '%s\n' "$packages" | grep -v -e '/internal/runtime$' -e '/internal/console$') # What of the console launches no bundle runs anyway: how an address resolves (novox/hq issue 287), - # and how a search ranks (novox/hq ADR 0242). + # and how a search ranks (novox/hq ADR 0245). bundleless='^(TestASeatAndAModuleOfOneNameAreEachReached|TestSearch.*|TestASearchByCommandSaysWhatItReplaces)$' fi if command -v gcc >/dev/null 2>&1; then diff --git a/node-tools/internal/console/address.go b/node-tools/internal/console/address.go index 6ec497c..f216ddc 100644 --- a/node-tools/internal/console/address.go +++ b/node-tools/internal/console/address.go @@ -131,7 +131,7 @@ type index struct { // what it serves in time, or it answered before and not now. An address it might answer is never // called missing while it is here (2026-10-05). Unheard []unheard - // Replaces is what each verb and tool replaces, from the controller's records (novox/hq ADR 0242): + // Replaces is what each verb and tool replaces, from the controller's records (novox/hq ADR 0245): // `seat:.` and `.`. Replaces map[string][]string } @@ -225,7 +225,7 @@ type recordedModule struct { Module string `json:"module"` On []string `json:"on"` Tools bool `json:"tools"` - // Replaces is what each of its own tools replaces (novox/hq ADR 0242). + // Replaces is what each of its own tools replaces (novox/hq ADR 0245). Replaces map[string][]string `json:"replaces,omitempty"` } @@ -244,7 +244,7 @@ func indexOn(conn *bus.Conn) (*index, error) { wg.Add(3) go func() { defer wg.Done(); nodesOut, _ = controllerOutput(conn, "nodes") }() go func() { defer wg.Done(); modulesOut, _ = controllerOutput(conn, "modules") }() - // The seats' verbs as the records define them, for what each replaces (novox/hq ADR 0242): answered + // The seats' verbs as the records define them, for what each replaces (novox/hq ADR 0245): answered // in-process from the records, never a command. go func() { defer wg.Done() @@ -832,7 +832,7 @@ func (s *Surface) discover(name string, args map[string]any) map[string]any { if len(hits) == 0 { out["matches"] = []hit{} out["hint"] = "nothing matched every word; try fewer words, or mesh_overview and mesh_machine to browse. " + - "If no tool does it, one is created in the module that owns it, on its seat — never worked around (novox/hq ADR 0242)" + "If no tool does it, one is created in the module that owns it, on its seat — never worked around (novox/hq ADR 0245)" } if len(x.Unheard) > 0 { out["incomplete"] = "discovery did not hear in full from " + sayUnheard(x.Unheard) + diff --git a/node-tools/internal/console/search.go b/node-tools/internal/console/search.go index 66b0625..eaeaf1d 100644 --- a/node-tools/internal/console/search.go +++ b/node-tools/internal/console/search.go @@ -1,6 +1,6 @@ package console -// Finding a tool by the words an agent already has (novox/hq ADR 0242). +// 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 @@ -12,10 +12,11 @@ package console // 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. +// 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 keep the listing's order — seats first, the place tools belong — then the modules by name. +// Ties go to a seat — the place tools belong — then to the shorter name, then by address. import ( "encoding/json" @@ -149,6 +150,19 @@ func scoreOf(query string, qwords []string, name, description string, replaces [ 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, "" } @@ -208,11 +222,22 @@ func (x *index) search(query string) []hit { hits = append(hits, h) } } - sort.SliceStable(hits, func(i, j int) bool { return hits[i].score > hits[j].score }) + // 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 0242): +// 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:.`, and a module's own tools from // its `modules` answer, keyed `.`. A controller older than the field answers neither, // and search matches names and descriptions as before. diff --git a/node-tools/internal/console/search_test.go b/node-tools/internal/console/search_test.go index 5683782..41ba59c 100644 --- a/node-tools/internal/console/search_test.go +++ b/node-tools/internal/console/search_test.go @@ -55,7 +55,7 @@ func meshToSearch(t *testing.T) *index { return x } -// **The words an agent has find the verb first** (novox/hq ADR 0242): the command it would have run, or +// **The words an agent has find the verb first** (novox/hq ADR 0245): the command it would have run, or // the word it would have used, puts the mesh's tool for it at the top. func TestSearchFindsTheToolThatReplacesACommandFirst(t *testing.T) { x := meshToSearch(t)