feat: import agents_and_robots platform as unibots (Matrix-out, unibus transport)

Reemplaza el scaffold del echobot por la plataforma completa de bots traida
desde ~/DataProyects/Github/agents_and_robots tras la operacion Matrix-out:
los bots ya no hablan por Matrix sino por el bus unibus (modelo todo-rooms +
E2E via shell/transportunibus sobre github.com/enmanuel/unibus/pkg/client).

- go.mod: replace de unibus -> ../unibus y de fn-registry -> ../../../.. (paths
  relativos reajustados a la nueva ubicacion dentro de fn_registry).
- app.md: bump a 0.2.0, descripcion + arquitectura + comandos + gotchas reales.
- modulo Go conservado como github.com/enmanuel/agents (sin reescribir imports).

agents_and_robots queda archivado como museo de la era Matrix.
This commit is contained in:
agent
2026-06-07 11:50:13 +02:00
parent bb5b0e09b1
commit fc644ecd6e
308 changed files with 38829 additions and 474 deletions
+66
View File
@@ -0,0 +1,66 @@
package command
// Builtins returns the specs of all built-in commands. Pure.
func Builtins() []Spec {
return []Spec{
{
Name: "help",
Aliases: []string{"h"},
Description: "Lista comandos disponibles",
Usage: "!help",
},
{
Name: "tools",
Description: "Lista tools registradas con descripcion",
Usage: "!tools",
},
{
Name: "tool",
Description: "Ejecutar una tool directamente",
Usage: "!tool <nombre> [key=value ...]",
},
{
Name: "ping",
Description: "Alive check",
Usage: "!ping",
},
{
Name: "status",
Description: "Info del agente: uptime, rooms activos",
Usage: "!status",
},
{
Name: "info",
Description: "Nombre, version y descripcion del agente",
Usage: "!info",
},
{
Name: "clear",
Description: "Limpia ventana de conversacion del room actual",
Usage: "!clear",
},
{
Name: "prompts",
Description: "Lista prompt-commands disponibles (archivos .md en prompts/)",
Usage: "!prompts",
},
{
Name: "version",
Aliases: []string{"v"},
Description: "Version del agente",
Usage: "!version",
},
}
}
// BuiltinNames returns just the command names (including aliases) for lookup. Pure.
func BuiltinNames() map[string]string {
m := make(map[string]string)
for _, spec := range Builtins() {
m[spec.Name] = spec.Name
for _, alias := range spec.Aliases {
m[alias] = spec.Name
}
}
return m
}
+91
View File
@@ -0,0 +1,91 @@
package command
import (
"encoding/json"
"strings"
)
// ParseArgs converts a slice of raw arguments into structured ParsedArgs.
// Supports: positional args, key=value pairs, and quoted values like key="hello world".
// Pure function — no side effects.
func ParseArgs(args []string) ParsedArgs {
p := ParsedArgs{
Named: make(map[string]string),
Raw: args,
}
// First, rejoin args to handle quoted values that were split by Fields().
joined := strings.Join(args, " ")
tokens := tokenize(joined)
for _, tok := range tokens {
if idx := strings.IndexByte(tok, '='); idx > 0 {
key := tok[:idx]
val := tok[idx+1:]
// Strip surrounding quotes from value
val = stripQuotes(val)
p.Named[key] = val
} else {
p.Positional = append(p.Positional, tok)
}
}
return p
}
// ArgsToJSON converts a named args map to a JSON string for tools.Registry.Execute.
// Pure function.
func ArgsToJSON(named map[string]string) string {
if len(named) == 0 {
return ""
}
m := make(map[string]any, len(named))
for k, v := range named {
m[k] = v
}
b, _ := json.Marshal(m)
return string(b)
}
// tokenize splits a string respecting quoted values.
// e.g. `host=server1 command="uptime -a"` → ["host=server1", `command="uptime -a"`]
func tokenize(s string) []string {
var tokens []string
var current strings.Builder
inQuote := false
quoteChar := byte(0)
for i := 0; i < len(s); i++ {
ch := s[i]
switch {
case !inQuote && (ch == '"' || ch == '\''):
inQuote = true
quoteChar = ch
current.WriteByte(ch)
case inQuote && ch == quoteChar:
inQuote = false
current.WriteByte(ch)
case !inQuote && ch == ' ':
if current.Len() > 0 {
tokens = append(tokens, current.String())
current.Reset()
}
default:
current.WriteByte(ch)
}
}
if current.Len() > 0 {
tokens = append(tokens, current.String())
}
return tokens
}
// stripQuotes removes surrounding double or single quotes from a string.
func stripQuotes(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
return s[1 : len(s)-1]
}
}
return s
}
+90
View File
@@ -0,0 +1,90 @@
package command
import (
"testing"
)
func TestParseArgs_Empty(t *testing.T) {
p := ParseArgs(nil)
if len(p.Positional) != 0 {
t.Errorf("expected 0 positional, got %d", len(p.Positional))
}
if len(p.Named) != 0 {
t.Errorf("expected 0 named, got %d", len(p.Named))
}
}
func TestParseArgs_Positional(t *testing.T) {
p := ParseArgs([]string{"ssh_command"})
if len(p.Positional) != 1 || p.Positional[0] != "ssh_command" {
t.Errorf("expected [ssh_command], got %v", p.Positional)
}
}
func TestParseArgs_Named(t *testing.T) {
p := ParseArgs([]string{"host=server1", "command=uptime"})
if p.Named["host"] != "server1" {
t.Errorf("expected host=server1, got %q", p.Named["host"])
}
if p.Named["command"] != "uptime" {
t.Errorf("expected command=uptime, got %q", p.Named["command"])
}
}
func TestParseArgs_QuotedValue(t *testing.T) {
p := ParseArgs([]string{`host=server1`, `command="uptime`, `-a"`})
if p.Named["host"] != "server1" {
t.Errorf("expected host=server1, got %q", p.Named["host"])
}
if p.Named["command"] != "uptime -a" {
t.Errorf("expected command='uptime -a', got %q", p.Named["command"])
}
}
func TestParseArgs_Mixed(t *testing.T) {
p := ParseArgs([]string{"ssh_command", "host=server1", "command=ls"})
if len(p.Positional) != 1 || p.Positional[0] != "ssh_command" {
t.Errorf("expected positional [ssh_command], got %v", p.Positional)
}
if p.Named["host"] != "server1" {
t.Errorf("expected host=server1, got %q", p.Named["host"])
}
}
func TestParseArgs_SingleQuotes(t *testing.T) {
p := ParseArgs([]string{`query='hello`, `world'`})
if p.Named["query"] != "hello world" {
t.Errorf("expected query='hello world', got %q", p.Named["query"])
}
}
func TestArgsToJSON_Empty(t *testing.T) {
result := ArgsToJSON(nil)
if result != "" {
t.Errorf("expected empty string, got %q", result)
}
}
func TestArgsToJSON_Values(t *testing.T) {
result := ArgsToJSON(map[string]string{"host": "server1", "command": "uptime"})
if result == "" {
t.Error("expected non-empty JSON")
}
// Should contain both keys
if !contains(result, `"host"`) || !contains(result, `"server1"`) {
t.Errorf("JSON missing expected keys: %s", result)
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub))
}
func containsStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+51
View File
@@ -0,0 +1,51 @@
package command
import (
"os"
"path/filepath"
"strings"
)
// PromptCommand maps a command name to its prompt content loaded from a .md file.
type PromptCommand struct {
Name string // filename without .md extension
Content string // file content (the prompt text)
}
// LoadPromptCommands scans dir for .md files and returns one PromptCommand per file.
// Returns nil (no error) if the directory does not exist.
func LoadPromptCommands(dir string) ([]PromptCommand, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var prompts []PromptCommand
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
name := strings.TrimSuffix(e.Name(), ".md")
prompts = append(prompts, PromptCommand{
Name: name,
Content: strings.TrimSpace(string(data)),
})
}
return prompts, nil
}
// ExpandPrompt builds the final message by concatenating the prompt content
// with any extra arguments the user provided after the command.
func ExpandPrompt(content string, args []string) string {
if len(args) == 0 {
return content
}
return content + "\n\n" + strings.Join(args, " ")
}
+19
View File
@@ -0,0 +1,19 @@
// Package command defines pure types and functions for the bot command system.
// Commands are direct actions triggered by !prefix messages (e.g. !help, !ping).
package command
// Spec is the pure specification of a command. Only data, no side effects.
type Spec struct {
Name string
Aliases []string // e.g. ["h"] for help
Description string // short description for !help
Usage string // e.g. "!tool <name> [key=value ...]"
Hidden bool // do not show in !help
}
// ParsedArgs is the result of parsing "key=value key2=value2" arguments.
type ParsedArgs struct {
Positional []string // args without key=
Named map[string]string // args with key=value
Raw []string // original args
}