feat: hybrid system inventory — dynamic PATH scan + runtime probing
No hardcoded tool lists. Scans all $PATH directories for executables (5541 on this system), then probes known runtime patterns for version info (23 detected: Go, Python, Node, Rust, Ruby, Perl, Java, Dart, Deno, Bun, Lua, LuaJIT, Guile, GCC, Clang, NASM + package managers). System prompt includes: OS, shell, runtime versions, and notable tools (git, docker, kubectl, fzf, rg, etc.) from the full PATH scan. Total executable count reported so the LLM knows the full scope. Milestones updated: M6 fixed context prefix, M12 multimodality.
This commit is contained in:
@@ -79,6 +79,13 @@ func main() {
|
||||
logger.Debug("harvested aliases", "count", aliases.Len())
|
||||
}
|
||||
|
||||
// Harvest system inventory
|
||||
inventory := bash.HarvestInventory(context.Background())
|
||||
logger.Debug("system inventory",
|
||||
"tools", len(inventory.Tools),
|
||||
"runtimes", len(inventory.Runtimes),
|
||||
)
|
||||
|
||||
// Re-register bash tool with aliases
|
||||
reg.Register(bash.New(bash.WithAliases(aliases)))
|
||||
|
||||
@@ -107,13 +114,19 @@ func main() {
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
// Build system prompt with inventory
|
||||
systemPrompt := *system
|
||||
if invStr := inventory.String(); invStr != "" {
|
||||
systemPrompt = systemPrompt + "\n\n" + invStr
|
||||
}
|
||||
|
||||
// Create engine
|
||||
eng, err := engine.New(engine.Config{
|
||||
Provider: prov,
|
||||
Router: rtr,
|
||||
Tools: reg,
|
||||
Firewall: fw,
|
||||
System: *system,
|
||||
System: systemPrompt,
|
||||
Model: *model,
|
||||
MaxTurns: *maxTurns,
|
||||
Logger: logger,
|
||||
|
||||
@@ -17,13 +17,13 @@ depends_on: [vision]
|
||||
| M3 | Security Firewall | Request/response scanning, redaction, incognito | M2 |
|
||||
| M4 | Router Foundation | Arm registry, pools, task classifier, heuristic selection | M2 |
|
||||
| M5 | TUI | Bubble Tea, 6 permission modes, config screen | M3, M4 |
|
||||
| M6 | Context Intelligence | Local tokenizer, full compaction (truncate + summarize) | M5 |
|
||||
| M6 | Context Intelligence | Local tokenizer, fixed context prefix, full compaction | M5 |
|
||||
| M7 | Elfs | Router-integrated sub-agents, parallel work | M4, M6 |
|
||||
| M8 | Extensibility | Hooks, skills, MCP client, MCP tool replaceability, plugins | M7 |
|
||||
| M9 | Router Advanced | Bandit core, feedback, ensemble strategies, state persistence | M7 |
|
||||
| M10 | Persistence & Serve | SQLite sessions, serve mode, coordinator | M7 |
|
||||
| M11 | Task Learning | Pattern recognition, task suggestions, persistent tasks | M9 |
|
||||
| M12 | Thinking & Structured Output | Thinking modes, schema validation | M2 |
|
||||
| M12 | Thinking, Multimodality & Structured Output | Thinking, multimodal I/O, schema validation | M2 |
|
||||
| M13 | Auth | OAuth PKCE, keyring, multi-account | M5 |
|
||||
| M14 | Observability | Feature flags, telemetry, cost dashboards | M10 |
|
||||
| M15 | Web UI | `gnoma web` CLI flag, browser UI via serve mode | M10 |
|
||||
@@ -43,6 +43,7 @@ depends_on: [vision]
|
||||
- [ ] Tool system: bash (with security checks), fs.read, fs.write, fs.edit, fs.glob, fs.grep
|
||||
- [ ] Engine agentic loop (stream → tool → re-query → done)
|
||||
- [ ] CLI pipe mode (`echo "list files" | gnoma`)
|
||||
- [ ] System package inventory: detect installed tools/packages at startup, include in system prompt so the LLM knows what's available
|
||||
|
||||
**Exit criteria:** Pipe a coding question in, get a response that uses tools, answer on stdout.
|
||||
|
||||
@@ -132,7 +133,8 @@ depends_on: [vision]
|
||||
|
||||
- [ ] Local tokenizer for accurate token counting
|
||||
- [ ] Token tracker with warning states (OK / Warning / Critical)
|
||||
- [ ] TruncateStrategy: drop oldest, preserve system + recent
|
||||
- [ ] Fixed context prefix: system prompt + loaded md files (CLAUDE.md, project docs) pinned as immutable prefix. Only conversation history after the prefix gets compacted.
|
||||
- [ ] TruncateStrategy: drop oldest, preserve system + fixed prefix + recent
|
||||
- [ ] SummarizeStrategy: spawn compaction elf, LLM-powered summary, image stripping, boundary messages
|
||||
- [ ] Auto-compaction triggers (threshold-based, reactive on 413, circuit breaker after 3 failures)
|
||||
- [ ] Pre/post compact hooks
|
||||
@@ -223,7 +225,7 @@ depends_on: [vision]
|
||||
|
||||
**Exit criteria:** gnoma suggests a persistent task after 3+ repetitions. `/task release v1.2.0` executes a saved workflow.
|
||||
|
||||
## M12: Thinking, Structured Output & Notebook
|
||||
## M12: Thinking, Structured Output, Notebook & Multimodality
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
@@ -232,6 +234,9 @@ depends_on: [vision]
|
||||
- [ ] Structured output with JSON schema validation
|
||||
- [ ] Retry logic for schema validation failures
|
||||
- [ ] NotebookEdit tool: read/write/edit Jupyter notebook cells (.ipynb)
|
||||
- [ ] Multimodal input: image support (Anthropic image blocks, OpenAI content parts, Google inline data)
|
||||
- [ ] Multimodal input: audio support (where provider supports it)
|
||||
- [ ] Multimodal output: image rendering in TUI (sixel/kitty protocol)
|
||||
|
||||
## M13: Auth
|
||||
|
||||
|
||||
318
internal/tool/bash/inventory.go
Normal file
318
internal/tool/bash/inventory.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package bash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const inventoryTimeout = 15 * time.Second
|
||||
|
||||
// SystemInventory holds dynamically discovered system information.
|
||||
type SystemInventory struct {
|
||||
OS string
|
||||
Shell string
|
||||
Tools []string // all executables found in PATH
|
||||
Runtimes []Runtime // detected runtimes with versions
|
||||
}
|
||||
|
||||
// Runtime is a detected language runtime or package manager.
|
||||
type Runtime struct {
|
||||
Name string
|
||||
Version string
|
||||
}
|
||||
|
||||
// HarvestInventory dynamically discovers installed tools and runtimes.
|
||||
// No hardcoded lists — scans $PATH and probes for version info.
|
||||
func HarvestInventory(ctx context.Context) *SystemInventory {
|
||||
ctx, cancel := context.WithTimeout(ctx, inventoryTimeout)
|
||||
defer cancel()
|
||||
|
||||
inv := &SystemInventory{
|
||||
OS: detectOS(ctx),
|
||||
Shell: detectShell(),
|
||||
}
|
||||
|
||||
// Scan all executables in PATH
|
||||
allBinaries := scanPATH()
|
||||
inv.Tools = allBinaries
|
||||
|
||||
// Probe for runtimes: try --version on known runtime name patterns
|
||||
inv.Runtimes = probeRuntimes(ctx, allBinaries)
|
||||
|
||||
return inv
|
||||
}
|
||||
|
||||
// scanPATH collects all unique executable names from $PATH directories.
|
||||
func scanPATH() []string {
|
||||
seen := make(map[string]bool)
|
||||
var names []string
|
||||
|
||||
pathDirs := filepath.SplitList(os.Getenv("PATH"))
|
||||
for _, dir := range pathDirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
// Check it's actually executable
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.Mode()&0o111 != 0 {
|
||||
seen[name] = true
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// runtimeCandidate defines how to detect a runtime from a binary name.
|
||||
type runtimeCandidate struct {
|
||||
name string // display name
|
||||
binary string // executable name to look for
|
||||
args []string // version flag
|
||||
}
|
||||
|
||||
// knownRuntimePatterns returns binary names that are likely runtimes.
|
||||
// We still need a pattern list to know WHICH binaries to try --version on,
|
||||
// but the actual detection is dynamic (only probes what's in PATH).
|
||||
var runtimePatterns = []runtimeCandidate{
|
||||
// Systems languages
|
||||
{"go", "go", []string{"version"}},
|
||||
{"rust", "rustc", []string{"--version"}},
|
||||
{"zig", "zig", []string{"version"}},
|
||||
{"nim", "nim", []string{"--version"}},
|
||||
{"crystal", "crystal", []string{"--version"}},
|
||||
{"gcc", "gcc", []string{"--version"}},
|
||||
{"clang", "clang", []string{"--version"}},
|
||||
{"nasm", "nasm", []string{"-v"}},
|
||||
// Scripting
|
||||
{"python3", "python3", []string{"--version"}},
|
||||
{"python2", "python2", []string{"--version"}},
|
||||
{"perl", "perl", []string{"--version"}},
|
||||
{"ruby", "ruby", []string{"--version"}},
|
||||
{"lua", "lua", []string{"-v"}},
|
||||
{"luajit", "luajit", []string{"-v"}},
|
||||
{"guile", "guile", []string{"--version"}},
|
||||
// tcl detection is tricky (needs stdin), skipped
|
||||
{"php", "php", []string{"--version"}},
|
||||
{"r", "R", []string{"--version"}},
|
||||
// JS/TS
|
||||
{"node", "node", []string{"--version"}},
|
||||
{"deno", "deno", []string{"--version"}},
|
||||
{"bun", "bun", []string{"--version"}},
|
||||
// JVM
|
||||
{"java", "java", []string{"-version"}},
|
||||
{"kotlin", "kotlin", []string{"-version"}},
|
||||
{"scala", "scala", []string{"-version"}},
|
||||
{"groovy", "groovy", []string{"--version"}},
|
||||
{"clojure", "clj", []string{"--version"}},
|
||||
// Functional
|
||||
{"haskell", "ghc", []string{"--version"}},
|
||||
{"ocaml", "ocaml", []string{"-version"}},
|
||||
{"elixir", "elixir", []string{"--version"}},
|
||||
{"erlang", "erl", []string{"-eval", "io:format(erlang:system_info(otp_release)),halt()."}},
|
||||
{"racket", "racket", []string{"--version"}},
|
||||
// Other
|
||||
{"dart", "dart", []string{"--version"}},
|
||||
{"julia", "julia", []string{"--version"}},
|
||||
{"swift", "swift", []string{"--version"}},
|
||||
// .NET
|
||||
{"dotnet", "dotnet", []string{"--version"}},
|
||||
{"mono", "mono", []string{"--version"}},
|
||||
// Package managers
|
||||
{"cargo", "cargo", []string{"--version"}},
|
||||
{"npm", "npm", []string{"--version"}},
|
||||
{"yarn", "yarn", []string{"--version"}},
|
||||
{"pnpm", "pnpm", []string{"--version"}},
|
||||
{"pip", "pip3", []string{"--version"}},
|
||||
{"gem", "gem", []string{"--version"}},
|
||||
{"composer", "composer", []string{"--version"}},
|
||||
{"mix", "mix", []string{"--version"}},
|
||||
{"cabal", "cabal", []string{"--version"}},
|
||||
{"stack", "stack", []string{"--version"}},
|
||||
{"opam", "opam", []string{"--version"}},
|
||||
{"maven", "mvn", []string{"--version"}},
|
||||
{"gradle", "gradle", []string{"--version"}},
|
||||
}
|
||||
|
||||
// probeRuntimes checks which runtime candidates exist in the discovered binaries
|
||||
// and gets their version info. Runs probes concurrently for speed.
|
||||
func probeRuntimes(ctx context.Context, available []string) []Runtime {
|
||||
availSet := make(map[string]bool, len(available))
|
||||
for _, name := range available {
|
||||
availSet[name] = true
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var runtimes []Runtime
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Semaphore to limit concurrent version probes
|
||||
sem := make(chan struct{}, 10)
|
||||
|
||||
for _, candidate := range runtimePatterns {
|
||||
if !availSet[candidate.binary] {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(c runtimeCandidate) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
version := probeVersion(ctx, c.binary, c.args)
|
||||
if version != "" {
|
||||
mu.Lock()
|
||||
runtimes = append(runtimes, Runtime{Name: c.name, Version: version})
|
||||
mu.Unlock()
|
||||
}
|
||||
}(candidate)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
sort.Slice(runtimes, func(i, j int) bool {
|
||||
return runtimes[i].Name < runtimes[j].Name
|
||||
})
|
||||
return runtimes
|
||||
}
|
||||
|
||||
// probeVersion runs a binary with version args and extracts the first line.
|
||||
func probeVersion(ctx context.Context, binary string, args []string) string {
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, binary, args...)
|
||||
cmd.Stdin = nil
|
||||
|
||||
// Some tools print version to stderr (java -version)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil && len(output) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// First non-empty line
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
return line
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// String formats the inventory for inclusion in a system prompt.
|
||||
func (inv *SystemInventory) String() string {
|
||||
if inv == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("System environment:\n")
|
||||
|
||||
if inv.OS != "" {
|
||||
fmt.Fprintf(&b, "- OS: %s\n", inv.OS)
|
||||
}
|
||||
if inv.Shell != "" {
|
||||
fmt.Fprintf(&b, "- Shell: %s\n", inv.Shell)
|
||||
}
|
||||
if len(inv.Runtimes) > 0 {
|
||||
b.WriteString("- Runtimes & package managers:\n")
|
||||
for _, rt := range inv.Runtimes {
|
||||
fmt.Fprintf(&b, " - %s: %s\n", rt.Name, rt.Version)
|
||||
}
|
||||
}
|
||||
if len(inv.Tools) > 0 {
|
||||
fmt.Fprintf(&b, "- Available commands: %d executables in PATH", len(inv.Tools))
|
||||
// Include notable tools only (not the full list — saves tokens)
|
||||
notable := filterNotable(inv.Tools)
|
||||
if len(notable) > 0 {
|
||||
fmt.Fprintf(&b, " (notable: %s)", strings.Join(notable, ", "))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// notableTools are development-relevant tools worth highlighting in the system prompt.
|
||||
var notableTools = map[string]bool{
|
||||
// VCS
|
||||
"git": true, "gh": true, "tea": true, "lazygit": true,
|
||||
// Build
|
||||
"make": true, "cmake": true, "just": true,
|
||||
// Containers
|
||||
"docker": true, "podman": true, "kubectl": true, "helm": true,
|
||||
// Data
|
||||
"jq": true, "yq": true, "rg": true, "fd": true, "fzf": true,
|
||||
// Modern CLI
|
||||
"bat": true, "eza": true, "delta": true, "sd": true, "dust": true,
|
||||
"hyperfine": true, "tokei": true, "scc": true,
|
||||
// Debug
|
||||
"gdb": true, "strace": true, "perf": true, "valgrind": true,
|
||||
// Database
|
||||
"sqlite3": true, "psql": true, "mysql": true, "mongosh": true, "redis-cli": true,
|
||||
// Media
|
||||
"ffmpeg": true, "convert": true,
|
||||
// Infra
|
||||
"terraform": true, "ansible": true,
|
||||
// Network
|
||||
"curl": true, "wget": true, "ssh": true,
|
||||
// Editors
|
||||
"nvim": true, "vim": true,
|
||||
// Multiplexer
|
||||
"tmux": true,
|
||||
}
|
||||
|
||||
func filterNotable(tools []string) []string {
|
||||
var out []string
|
||||
for _, t := range tools {
|
||||
if notableTools[t] {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func detectOS(ctx context.Context) string {
|
||||
if output := runQuiet(ctx, "uname", "-srm"); output != "" {
|
||||
return strings.TrimSpace(output)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectShell() string {
|
||||
if s := os.Getenv("SHELL"); s != "" {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func runQuiet(ctx context.Context, name string, args ...string) string {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
93
internal/tool/bash/inventory_test.go
Normal file
93
internal/tool/bash/inventory_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package bash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScanPATH(t *testing.T) {
|
||||
binaries := scanPATH()
|
||||
if len(binaries) == 0 {
|
||||
t.Fatal("should find at least some executables in PATH")
|
||||
}
|
||||
t.Logf("Found %d executables in PATH", len(binaries))
|
||||
|
||||
// Basic sanity — ls should be in PATH
|
||||
hasLS := false
|
||||
for _, b := range binaries {
|
||||
if b == "ls" {
|
||||
hasLS = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasLS {
|
||||
t.Error("ls should be in PATH")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHarvestInventory(t *testing.T) {
|
||||
inv := HarvestInventory(context.Background())
|
||||
|
||||
if inv.OS == "" {
|
||||
t.Error("OS should be detected")
|
||||
}
|
||||
t.Logf("OS: %s", inv.OS)
|
||||
|
||||
if inv.Shell == "" {
|
||||
t.Error("Shell should be detected")
|
||||
}
|
||||
t.Logf("Shell: %s", inv.Shell)
|
||||
|
||||
t.Logf("Tools: %d executables in PATH", len(inv.Tools))
|
||||
|
||||
t.Logf("Runtimes (%d):", len(inv.Runtimes))
|
||||
for _, rt := range inv.Runtimes {
|
||||
t.Logf(" %s: %s", rt.Name, rt.Version)
|
||||
}
|
||||
|
||||
// Should find at least Go (we're running Go tests)
|
||||
hasGo := false
|
||||
for _, rt := range inv.Runtimes {
|
||||
if rt.Name == "go" {
|
||||
hasGo = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasGo {
|
||||
t.Error("should detect Go runtime (we're running Go tests)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventory_String(t *testing.T) {
|
||||
inv := &SystemInventory{
|
||||
OS: "Linux 6.18 x86_64",
|
||||
Shell: "/usr/bin/zsh",
|
||||
Tools: []string{"git", "make", "docker"},
|
||||
Runtimes: []Runtime{
|
||||
{"go", "go version go1.26.1"},
|
||||
{"python3", "Python 3.14.3"},
|
||||
},
|
||||
}
|
||||
|
||||
s := inv.String()
|
||||
if !strings.Contains(s, "Linux") {
|
||||
t.Error("should contain OS")
|
||||
}
|
||||
if !strings.Contains(s, "git") {
|
||||
t.Error("should contain tools")
|
||||
}
|
||||
if !strings.Contains(s, "go:") {
|
||||
t.Error("should contain runtimes")
|
||||
}
|
||||
if !strings.Contains(s, "3 executables in PATH") {
|
||||
t.Errorf("should show tool count, got: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventory_NilString(t *testing.T) {
|
||||
var inv *SystemInventory
|
||||
if inv.String() != "" {
|
||||
t.Error("nil inventory should return empty string")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user