mesh_overview, mesh_machine, mesh_search, mesh_describe and mesh_call walk the mesh's structure; every tool has one address per layer: <seat>.<verb>, <node>/<seat>.<verb>, <node>/<module>.<tool>, and <module>.<tool> for a module the mesh issued a plain subject. A stateful module called without its machine, a node seat without one, a mesh seat with one, or a module on the wrong machine is refused naming what would work. Answers come from the mesh when asked, kept five seconds, so a tool that arrives mid-session is found. The flat catalogue stays behind MESH_CONSOLE_FLAT=1 and old <module>.<tool> names still answer.
736 lines
22 KiB
Go
736 lines
22 KiB
Go
package console
|
|
|
|
// The mesh's tools found by address, not announced whole (novox/hq ADR 0195, to-be 34 §3a).
|
|
//
|
|
// The console announces five tools. Everything the mesh answers is reached through them by one
|
|
// address per layer:
|
|
//
|
|
// <seat>.<verb> a seat held once for the mesh — its holder answers
|
|
// <node>/<seat>.<verb> a seat held once per machine — that machine's holder answers
|
|
// <node>/<module>.<tool> a module assigned to a machine — that assignment answers
|
|
// <module>.<tool> also, for a module whose instances are interchangeable (ADR 0160)
|
|
//
|
|
// A module that is not interchangeable is called with its machine or refused, naming the machines
|
|
// it runs on: "whichever answers" is no answer for state a machine holds.
|
|
//
|
|
// Every discovery verb asks the mesh when it is called — kept a few seconds at most, never for a
|
|
// session — so a tool that arrived a minute ago is found without the client reconnecting.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
|
)
|
|
|
|
// IndexKept is how long what the mesh answered is kept before it is asked again: long enough that
|
|
// one agent turn's search, describe and call ask once, short enough that nothing goes stale.
|
|
var IndexKept = 5 * time.Second
|
|
|
|
// The five tools the console announces. Names of the API's kind — letters, digits, `_`, `-`.
|
|
const (
|
|
verbOverview = "mesh_overview"
|
|
verbMachine = "mesh_machine"
|
|
verbSearch = "mesh_search"
|
|
verbDescribe = "mesh_describe"
|
|
verbCall = "mesh_call"
|
|
)
|
|
|
|
// searchCap is how many matches a search answers before it says how many more there were.
|
|
const searchCap = 25
|
|
|
|
const grammar = "Addresses: `<seat>.<verb>` for a seat held once for the mesh (e.g. `mesh-controller.nodes`); " +
|
|
"`<node>/<seat>.<verb>` for a seat every machine holds (e.g. `ace/node-packet-filter.rules`); " +
|
|
"`<node>/<module>.<tool>` for a module on one machine (e.g. `novox/postgres.postgres_list_databases`); " +
|
|
"and `<module>.<tool>` also for a module whose instances are interchangeable."
|
|
|
|
// discovery is the five tools as tools/list announces them.
|
|
func discovery() []map[string]any {
|
|
str := func(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
|
|
obj := func(props map[string]any, required ...string) map[string]any {
|
|
s := map[string]any{"type": "object", "properties": props}
|
|
if len(required) > 0 {
|
|
s["required"] = required
|
|
}
|
|
return s
|
|
}
|
|
return []map[string]any{
|
|
{"name": verbOverview, "inputSchema": obj(map[string]any{}),
|
|
"description": "The mesh at a glance: the seats it holds once for the whole mesh with their verbs, the seats " +
|
|
"every machine holds, and its machines. Start here, then `mesh_machine` for one machine. " + grammar},
|
|
{"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"),
|
|
"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},
|
|
{"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},
|
|
{"name": verbCall, "inputSchema": obj(map[string]any{
|
|
"address": str("the tool's address"),
|
|
"arguments": map[string]any{"type": "object", "description": "the tool's arguments, as mesh_describe gives its schema"},
|
|
}, "address"),
|
|
"description": "Call one tool by its address with its arguments; the answer says which machine gave it. " + grammar},
|
|
}
|
|
}
|
|
|
|
func isDiscovery(name string) bool {
|
|
switch name {
|
|
case verbOverview, verbMachine, verbSearch, verbDescribe, verbCall:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// seatInfo is a seat as the mesh's records define it, and who holds it where.
|
|
type seatInfo struct {
|
|
Seat string
|
|
Scope string // "mesh" or "node"
|
|
Verbs []Tool
|
|
Holders []holder
|
|
}
|
|
|
|
type holder struct {
|
|
Module string `json:"module"`
|
|
Node string `json:"node"`
|
|
}
|
|
|
|
// moduleInfo is a module that answers tools: where it runs, whether any instance will do, and its
|
|
// tools as one of its instances described them.
|
|
type moduleInfo struct {
|
|
Module string
|
|
On []string
|
|
Interchangeable bool
|
|
Tools []Tool
|
|
}
|
|
|
|
// index is what the mesh answered about itself, at one moment.
|
|
type index struct {
|
|
Seats []seatInfo
|
|
Machines []string
|
|
Modules map[string]*moduleInfo
|
|
NotAnswering []string
|
|
Listing *Listing // the flat catalogue the call path resolves subjects with
|
|
}
|
|
|
|
func (x *index) seat(name string) *seatInfo {
|
|
for i := range x.Seats {
|
|
if x.Seats[i].Seat == name {
|
|
return &x.Seats[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func findTool(tools []Tool, name string) *Tool {
|
|
for i := range tools {
|
|
if tools[i].Name == name {
|
|
return &tools[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// controllerOutput is a controller seat verb's answer: the command's printed output.
|
|
func controllerOutput(conn *bus.Conn, verb string) (string, error) {
|
|
got, err := conn.Ask("seat:mesh-controller."+verb, map[string]any{}, "")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var r struct {
|
|
Output string `json:"output"`
|
|
OK *bool `json:"ok"`
|
|
}
|
|
if json.Unmarshal(got.Result, &r) != nil {
|
|
return "", fmt.Errorf("mesh-controller.%s answered something that is not its output", verb)
|
|
}
|
|
if r.OK != nil && !*r.OK {
|
|
return "", fmt.Errorf("mesh-controller.%s: %s", verb, strings.TrimSpace(r.Output))
|
|
}
|
|
return r.Output, nil
|
|
}
|
|
|
|
// jsonIn is the JSON document a command printed, after any lines it said first: a seat verb runs
|
|
// the controller's command, and a command may warn before it answers.
|
|
func jsonIn(output string) string {
|
|
if strings.HasPrefix(strings.TrimSpace(output), "{") {
|
|
return strings.TrimSpace(output)
|
|
}
|
|
if i := strings.Index(output, "\n{"); i >= 0 {
|
|
return strings.TrimSpace(output[i+1:])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// A machine as `node list` prints it: its name, when it was last heard from, its mode — converged
|
|
// or adopted, the only two the command prints — and its id. Lines the command says around them
|
|
// (a warning about the bus's users, "no node records yet") are not machines and are skipped.
|
|
var nodeLine = regexp.MustCompile(`^([a-z0-9][a-z0-9-]*)\s+.*\s(converged|adopted)\s+\S+\s*$`)
|
|
|
|
// machinesIn reads the machines from `node list`'s output.
|
|
func machinesIn(output string) []string {
|
|
var out []string
|
|
for _, line := range strings.Split(output, "\n") {
|
|
if m := nodeLine.FindStringSubmatch(line); m != nil {
|
|
out = append(out, m[1])
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// A module as `module list` prints it: name, version, how it was built, and where it runs.
|
|
var moduleLine = regexp.MustCompile(`^([a-z0-9][a-z0-9-]*)\s+\S+\s+.*?\s+on (.+)$`)
|
|
|
|
// assignmentsIn reads, from `module list`'s output, the machines each module runs on.
|
|
func assignmentsIn(output string) map[string][]string {
|
|
out := map[string][]string{}
|
|
for _, line := range strings.Split(output, "\n") {
|
|
if line == "" || line[0] == ' ' || line[0] == '\t' {
|
|
continue
|
|
}
|
|
m := moduleLine.FindStringSubmatch(strings.TrimRight(line, " "))
|
|
if m == nil {
|
|
continue
|
|
}
|
|
on := strings.TrimSpace(m[2])
|
|
if on == "nothing" {
|
|
out[m[1]] = []string{}
|
|
continue
|
|
}
|
|
var nodes []string
|
|
for _, n := range strings.Split(on, ",") {
|
|
if n = strings.TrimSpace(n); n != "" {
|
|
nodes = append(nodes, n)
|
|
}
|
|
}
|
|
sort.Strings(nodes)
|
|
out[m[1]] = nodes
|
|
}
|
|
return out
|
|
}
|
|
|
|
// interchangeable is whether the mesh issued the module a plain subject for this tool — one any of
|
|
// its instances answers (ADR 0160): the module's own subject with no machine after it.
|
|
func interchangeable(module string, t Tool) bool {
|
|
plain := "mesh.mod." + module + ".tool." + t.Name
|
|
for _, s := range t.Subjects {
|
|
if s == plain {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// indexOn asks the mesh what it holds: the flat listing (catalogue, modules, the seats' tools),
|
|
// and from the controller the seats' holders, the machines and the assignments — at once.
|
|
func indexOn(conn *bus.Conn) (*index, error) {
|
|
var wg sync.WaitGroup
|
|
var seatsOut, nodesOut, modulesOut string
|
|
wg.Add(3)
|
|
go func() { defer wg.Done(); seatsOut, _ = controllerOutput(conn, "seats") }()
|
|
go func() { defer wg.Done(); nodesOut, _ = controllerOutput(conn, "nodes") }()
|
|
go func() { defer wg.Done(); modulesOut, _ = controllerOutput(conn, "modules") }()
|
|
l, err := toolsOn(conn)
|
|
wg.Wait()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
x := &index{Modules: map[string]*moduleInfo{}, NotAnswering: l.NotAnswering, Listing: l}
|
|
|
|
// The seats: their verbs from the mesh's records, their holders from the controller.
|
|
var held struct {
|
|
Seats []struct {
|
|
Seat string `json:"seat"`
|
|
Scope string `json:"scope"`
|
|
Holders []holder `json:"holders"`
|
|
} `json:"seats"`
|
|
}
|
|
_ = json.Unmarshal([]byte(jsonIn(seatsOut)), &held)
|
|
holders := map[string][]holder{}
|
|
scopes := map[string]string{}
|
|
for _, s := range held.Seats {
|
|
holders[s.Seat] = s.Holders
|
|
scopes[s.Seat] = s.Scope
|
|
}
|
|
bySeat := map[string]*seatInfo{}
|
|
for _, t := range l.Tools {
|
|
if !t.Seat {
|
|
continue
|
|
}
|
|
s := bySeat[t.Module]
|
|
if s == nil {
|
|
scope := t.Scope
|
|
if scope == "" {
|
|
scope = scopes[t.Module]
|
|
}
|
|
if scope != "node" {
|
|
scope = "mesh"
|
|
}
|
|
s = &seatInfo{Seat: t.Module, Scope: scope, Holders: holders[t.Module]}
|
|
bySeat[t.Module] = s
|
|
}
|
|
s.Verbs = append(s.Verbs, t)
|
|
}
|
|
for _, s := range bySeat {
|
|
x.Seats = append(x.Seats, *s)
|
|
}
|
|
sort.Slice(x.Seats, func(i, j int) bool { return x.Seats[i].Seat < x.Seats[j].Seat })
|
|
|
|
// The machines: what the controller knows, and any that hold a seat.
|
|
seen := map[string]bool{}
|
|
for _, n := range machinesIn(nodesOut) {
|
|
seen[n] = true
|
|
}
|
|
for _, s := range x.Seats {
|
|
for _, h := range s.Holders {
|
|
if h.Node != "" {
|
|
seen[h.Node] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// The modules that answer tools, where they run, and whether any instance will do.
|
|
on := assignmentsIn(modulesOut)
|
|
for _, t := range l.Tools {
|
|
if t.Seat {
|
|
continue
|
|
}
|
|
m := x.Modules[t.Module]
|
|
if m == nil {
|
|
m = &moduleInfo{Module: t.Module, On: on[t.Module]}
|
|
x.Modules[t.Module] = m
|
|
}
|
|
m.Tools = append(m.Tools, t)
|
|
if interchangeable(t.Module, t) {
|
|
m.Interchangeable = true
|
|
}
|
|
}
|
|
for _, m := range x.Modules {
|
|
// A module the controller could not place is placed where its own answer says it runs.
|
|
if len(m.On) == 0 {
|
|
nodes := map[string]bool{}
|
|
for _, t := range m.Tools {
|
|
for _, s := range t.Subjects {
|
|
base := "mesh.mod." + m.Module + ".tool." + t.Name + "."
|
|
if strings.HasPrefix(s, base) {
|
|
nodes[strings.TrimPrefix(s, base)] = true
|
|
}
|
|
}
|
|
}
|
|
for n := range nodes {
|
|
m.On = append(m.On, n)
|
|
}
|
|
sort.Strings(m.On)
|
|
}
|
|
for _, n := range m.On {
|
|
seen[n] = true
|
|
}
|
|
}
|
|
for n := range seen {
|
|
x.Machines = append(x.Machines, n)
|
|
}
|
|
sort.Strings(x.Machines)
|
|
return x, nil
|
|
}
|
|
|
|
func (s *Surface) index() (*index, error) {
|
|
s.mu.Lock()
|
|
if s.idx != nil && time.Since(s.idxAt) <= IndexKept {
|
|
x := s.idx
|
|
s.mu.Unlock()
|
|
return x, nil
|
|
}
|
|
s.mu.Unlock()
|
|
x, err := indexOn(s.conn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.mu.Lock()
|
|
s.idx, s.idxAt = x, time.Now()
|
|
s.mu.Unlock()
|
|
return x, nil
|
|
}
|
|
|
|
// target is what an address resolves to.
|
|
type target struct {
|
|
Address string
|
|
Key string // the call key the existing path takes: seat:<s>.<v>[@node] or <m>.<t>[@node]
|
|
Name string // <seat>.<verb> or <module>.<tool>, for the listing's subject lookup
|
|
Node string
|
|
Tool Tool
|
|
Seat bool
|
|
}
|
|
|
|
// resolve turns an address into exactly one target, or says why it cannot.
|
|
func resolve(x *index, address string) (target, error) {
|
|
address = strings.TrimSpace(address)
|
|
node, rest, hasNode := strings.Cut(address, "/")
|
|
if !hasNode {
|
|
rest, node = address, ""
|
|
}
|
|
dot := strings.Index(rest, ".")
|
|
if dot <= 0 || dot == len(rest)-1 || strings.Contains(rest, "/") {
|
|
return target{}, fmt.Errorf("%q is not an address. %s", address, grammar)
|
|
}
|
|
prefix, name := rest[:dot], rest[dot+1:]
|
|
|
|
if s := x.seat(prefix); s != nil {
|
|
verb := findTool(s.Verbs, name)
|
|
if verb == nil {
|
|
return target{}, fmt.Errorf("the seat %s has no verb %s; it has %s", prefix, name, toolNames(s.Verbs))
|
|
}
|
|
if s.Scope == "node" {
|
|
if node == "" {
|
|
return target{}, fmt.Errorf("%s is held once per machine: write <node>/%s — it is held on %s",
|
|
prefix, rest, orNobody(nodesOf(s.Holders)))
|
|
}
|
|
return target{Address: node + "/" + rest, Key: "seat:" + rest + "@" + node, Name: rest, Node: node, Tool: *verb, Seat: true}, nil
|
|
}
|
|
if node != "" {
|
|
return target{}, fmt.Errorf("%s is held once for the whole mesh: write %s, without a machine", prefix, rest)
|
|
}
|
|
return target{Address: rest, Key: "seat:" + rest, Name: rest, Tool: *verb, Seat: true}, nil
|
|
}
|
|
|
|
m := x.Modules[prefix]
|
|
if m == nil {
|
|
return target{}, fmt.Errorf("nothing in the mesh is called %s: no seat, and no module that answers tools. "+
|
|
"mesh_search finds a tool by words", prefix)
|
|
}
|
|
tool := findTool(m.Tools, name)
|
|
if tool == nil {
|
|
return target{}, fmt.Errorf("%s has no tool %s; it has %s", prefix, name, toolNames(m.Tools))
|
|
}
|
|
if node == "" {
|
|
if !m.Interchangeable {
|
|
return target{}, fmt.Errorf("%s keeps state on each machine it runs on, so a call names the machine: "+
|
|
"write <node>/%s — it runs on %s", prefix, rest, orNobody(m.On))
|
|
}
|
|
return target{Address: rest, Key: rest, Name: rest, Tool: *tool}, nil
|
|
}
|
|
if len(m.On) > 0 && !contains(m.On, node) {
|
|
return target{}, fmt.Errorf("%s does not run on %s; it runs on %s", prefix, node, orNobody(m.On))
|
|
}
|
|
return target{Address: node + "/" + rest, Key: rest + "@" + node, Name: rest, Node: node, Tool: *tool}, nil
|
|
}
|
|
|
|
func contains(xs []string, s string) bool {
|
|
for _, x := range xs {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func toolNames(ts []Tool) string {
|
|
names := make([]string, 0, len(ts))
|
|
for _, t := range ts {
|
|
names = append(names, t.Name)
|
|
}
|
|
sort.Strings(names)
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
func nodesOf(hs []holder) []string {
|
|
var out []string
|
|
for _, h := range hs {
|
|
if h.Node != "" && !contains(out, h.Node) {
|
|
out = append(out, h.Node)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func orNobody(nodes []string) string {
|
|
if len(nodes) == 0 {
|
|
return "no machine the mesh knows of"
|
|
}
|
|
return strings.Join(nodes, ", ")
|
|
}
|
|
|
|
// firstLine is a description's first line, for a list.
|
|
func firstLine(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if i := strings.IndexAny(s, "\n"); i >= 0 {
|
|
s = s[:i]
|
|
}
|
|
if len(s) > 160 {
|
|
s = s[:157] + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// schemaWithoutNode is a tool's schema as an agent passes it: the machine is in the address.
|
|
func schemaWithoutNode(raw json.RawMessage) map[string]any {
|
|
schema := asSchema(raw)
|
|
out := map[string]any{}
|
|
for k, v := range schema {
|
|
out[k] = v
|
|
}
|
|
if p, ok := schema["properties"].(map[string]any); ok {
|
|
props := map[string]any{}
|
|
for k, v := range p {
|
|
if k != "node" {
|
|
props[k] = v
|
|
}
|
|
}
|
|
out["properties"] = props
|
|
}
|
|
if r, ok := schema["required"].([]any); ok {
|
|
var keep []any
|
|
for _, k := range r {
|
|
if k != "node" {
|
|
keep = append(keep, k)
|
|
}
|
|
}
|
|
if len(keep) == 0 {
|
|
delete(out, "required")
|
|
} else {
|
|
out["required"] = keep
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// answerText is a discovery verb's answer as MCP content: JSON, indented.
|
|
func answerText(v any) map[string]any {
|
|
var b strings.Builder
|
|
enc := json.NewEncoder(&b)
|
|
enc.SetEscapeHTML(false) // `<node>/…` is read by an agent, not embedded in a page
|
|
enc.SetIndent("", " ")
|
|
_ = enc.Encode(v)
|
|
return map[string]any{"content": []map[string]any{{"type": "text", "text": strings.TrimRight(b.String(), "\n")}}}
|
|
}
|
|
|
|
func failure(text string) map[string]any {
|
|
return map[string]any{"content": []map[string]any{{"type": "text", "text": text}}, "isError": true}
|
|
}
|
|
|
|
// discover answers one of the five.
|
|
func (s *Surface) discover(name string, args map[string]any) map[string]any {
|
|
x, err := s.index()
|
|
if err != nil {
|
|
return failure(whyItFailed(catalogueModules, err))
|
|
}
|
|
str := func(k string) string { v, _ := args[k].(string); return strings.TrimSpace(v) }
|
|
|
|
switch name {
|
|
case verbOverview:
|
|
type verbLine struct {
|
|
Address string `json:"address"`
|
|
Description string `json:"description"`
|
|
}
|
|
var mesh, node []map[string]any
|
|
for _, st := range x.Seats {
|
|
var verbs []verbLine
|
|
for _, v := range st.Verbs {
|
|
addr := st.Seat + "." + v.Name
|
|
if st.Scope == "node" {
|
|
addr = "<node>/" + addr
|
|
}
|
|
verbs = append(verbs, verbLine{addr, firstLine(v.Description)})
|
|
}
|
|
entry := map[string]any{"seat": st.Seat, "verbs": verbs}
|
|
if st.Scope == "node" {
|
|
entry["held on"] = nodesOf(st.Holders)
|
|
node = append(node, entry)
|
|
} else {
|
|
if h := nodesOf(st.Holders); len(h) > 0 {
|
|
entry["held on"] = h
|
|
}
|
|
mesh = append(mesh, entry)
|
|
}
|
|
}
|
|
modules := 0
|
|
tools := 0
|
|
for _, m := range x.Modules {
|
|
modules++
|
|
tools += len(m.Tools)
|
|
}
|
|
return answerText(map[string]any{
|
|
"seats of the mesh": mesh,
|
|
"seats every machine": node,
|
|
"machines": x.Machines,
|
|
"modules with tools": fmt.Sprintf("%d modules, %d tools — mesh_machine lists a machine's, mesh_search finds one", modules, tools),
|
|
"not answering": x.NotAnswering,
|
|
})
|
|
|
|
case verbMachine:
|
|
node := str("node")
|
|
if node == "" {
|
|
return failure("mesh_machine needs `node`: one of " + orNobody(x.Machines))
|
|
}
|
|
if !contains(x.Machines, node) {
|
|
return failure(fmt.Sprintf("the mesh knows no machine %q; it has %s", node, orNobody(x.Machines)))
|
|
}
|
|
var seats []map[string]any
|
|
for _, st := range x.Seats {
|
|
if st.Scope != "node" || !contains(nodesOf(st.Holders), node) {
|
|
continue
|
|
}
|
|
var verbs []string
|
|
for _, v := range st.Verbs {
|
|
verbs = append(verbs, node+"/"+st.Seat+"."+v.Name)
|
|
}
|
|
var by string
|
|
for _, h := range st.Holders {
|
|
if h.Node == node {
|
|
by = h.Module
|
|
}
|
|
}
|
|
seats = append(seats, map[string]any{"seat": st.Seat, "held by": by, "verbs": verbs})
|
|
}
|
|
var modules []map[string]any
|
|
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]
|
|
if !contains(m.On, node) {
|
|
continue
|
|
}
|
|
var tools []map[string]string
|
|
for _, t := range m.Tools {
|
|
tools = append(tools, map[string]string{"address": node + "/" + m.Module + "." + t.Name, "does": firstLine(t.Description)})
|
|
}
|
|
entry := map[string]any{"module": m.Module, "tools": tools}
|
|
if m.Interchangeable {
|
|
entry["interchangeable"] = "any instance answers <module>.<tool> as well"
|
|
}
|
|
modules = append(modules, entry)
|
|
}
|
|
return answerText(map[string]any{"machine": node, "seats": seats, "modules": modules})
|
|
|
|
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 := "<node>"
|
|
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{"<node>/" + 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})
|
|
}
|
|
}
|
|
}
|
|
out := map[string]any{"matches": hits}
|
|
if len(hits) > searchCap {
|
|
out["matches"] = hits[:searchCap]
|
|
out["more"] = fmt.Sprintf("%d more; narrow the words", len(hits)-searchCap)
|
|
}
|
|
if len(hits) == 0 {
|
|
out["matches"] = []hit{}
|
|
out["hint"] = "nothing matched every word; try fewer words, or mesh_overview and mesh_machine to browse"
|
|
}
|
|
return answerText(out)
|
|
|
|
case verbDescribe:
|
|
t, err := resolve(x, str("address"))
|
|
if err != nil {
|
|
return failure(err.Error())
|
|
}
|
|
description := t.Tool.Description
|
|
if description == "" {
|
|
description = t.Name
|
|
}
|
|
return answerText(map[string]any{"address": t.Address, "description": description,
|
|
"arguments": schemaWithoutNode(t.Tool.Input)})
|
|
|
|
case verbCall:
|
|
t, err := resolve(x, str("address"))
|
|
if err != nil {
|
|
return failure(err.Error())
|
|
}
|
|
callArgs := map[string]any{}
|
|
if a, ok := args["arguments"].(map[string]any); ok {
|
|
for k, v := range a {
|
|
if k != "node" {
|
|
callArgs[k] = v
|
|
}
|
|
}
|
|
}
|
|
var got bus.Answered
|
|
if t.Seat {
|
|
got, err = s.conn.Ask(t.Key, callArgs, "")
|
|
} else {
|
|
got, err = callTool(s.conn, t.Key, callArgs, nil, x.Listing)
|
|
}
|
|
if err != nil {
|
|
return failure(whyItFailed(t.Address, err))
|
|
}
|
|
content := []map[string]any{{"type": "text", "text": pretty(got.Result)}}
|
|
if got.Node != "" {
|
|
content = append(content, map[string]any{"type": "text", "text": "answered by " + got.Node})
|
|
}
|
|
return map[string]any{"content": content}
|
|
}
|
|
return failure("no discovery verb " + name)
|
|
}
|