diff --git a/cmd/gnoma/main.go b/cmd/gnoma/main.go index 59f6d43..65414a8 100644 --- a/cmd/gnoma/main.go +++ b/cmd/gnoma/main.go @@ -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, diff --git a/docs/essentials/milestones.md b/docs/essentials/milestones.md index b01da0b..4303de2 100644 --- a/docs/essentials/milestones.md +++ b/docs/essentials/milestones.md @@ -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 diff --git a/internal/tool/bash/inventory.go b/internal/tool/bash/inventory.go new file mode 100644 index 0000000..50f3a4b --- /dev/null +++ b/internal/tool/bash/inventory.go @@ -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)) +} diff --git a/internal/tool/bash/inventory_test.go b/internal/tool/bash/inventory_test.go new file mode 100644 index 0000000..761202c --- /dev/null +++ b/internal/tool/bash/inventory_test.go @@ -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") + } +}