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:
2026-04-03 14:36:22 +02:00
parent b9faa30ea8
commit d02b544e08
4 changed files with 434 additions and 5 deletions

View File

@@ -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,

View File

@@ -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

View 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))
}

View 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")
}
}