bots-garden/mini-mepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

💾 Saved. d722711 · on main · k33g · 1h ago
main.go · 195 lines · 7.6 KBGo Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
// Minimal coding agent: an agent loop with built-in tools — `bash`,
// `read_skill`, and (switchable) `read_file`, `write_file`, `edit_file`.
//
// LLM engine: any provider of internal/engine — Docker Model Runner by default,
// llama-server with `provider: llamacpp` — all through the OpenAI-compatible API.
//
// The code is split into packages (internal/ directory):
//   - config:   every setting, with YAML overrides (see agent.yaml);
//   - detector: repeated-action detection (the logical loop);
//   - engine:   connection to the LLM engine + generation (streaming + fallback),
//     with one Provider per kind of server;
//   - skills:   the markdown procedures of ./skills, and their catalogue;
//   - mention:  the @path notation of a question, shared by both front ends;
//   - fileedit: exact-replacement file editing, the rules of the `edit` CLI;
//   - tools:    the built-in tools (`bash`, `read_skill`, the file tools);
//   - ui:       where the human-facing output goes, and the event sink the
//     two front ends share;
//   - session:  what a conversation starts from, and the /new command that
//     takes it back there — shared by the two front ends;
//   - agent:    the terminal front end (the REPL);
//   - acp:      the editor front end — the same loop behind the Agent Client
//     Protocol (`bob --acp`), for Zed, JetBrains, Neovim…
//
// main() only does the WIRING: load the settings, engine init, tool list, then
// starting the loop.
package main

import (
	"context"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"mm/internal/acp"
	"mm/internal/agent"
	"mm/internal/config"
	"mm/internal/detector"
	"mm/internal/engine"
	"mm/internal/skills"
	"mm/internal/spinner"
	"mm/internal/tools"
	"mm/internal/ui"

	"github.com/firebase/genkit/go/ai"
)

func main() {
	ctx := context.Background()

	// Where the config file is. Same value, three ways to give it, from the most
	// local to the most ambient:
	//
	//	go run . -config ./fast.yaml     (the flag)
	//	go run . ./fast.yaml             (a lone argument, handy in a demo)
	//	AGENT_CONFIG=./fast.yaml go run .
	//
	// The flag package gives us "-h" for free; a second argument is a typo, and
	// saying so is better than ignoring it.
	configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml)")
	acpMode := flag.Bool("acp", false, "serve the Agent Client Protocol on stdio (for Zed, JetBrains, Neovim…) instead of the terminal REPL")
	flag.Parse()
	if *configPath == "" && flag.NArg() > 0 {
		*configPath = flag.Arg(0)
	}
	if flag.NArg() > 1 {
		fmt.Fprintln(ui.Out, "[usage: at most one config file, got", flag.NArg(), "arguments]")
		os.Exit(1)
	}

	// In ACP mode, stdout belongs to JSON-RPC — the spec forbids anything else
	// on it. Everything below that prints (banner, warnings, errors) goes
	// through ui.Out, so ONE move sends it all to stderr, where the editor's
	// agent logs pick it up. The spinner is silenced outright: supported()
	// cannot tell "a terminal" from "a terminal used as a protocol pipe".
	if *acpMode {
		ui.Out = os.Stderr
		spinner.Disable()
	}

	// Settings: built-in defaults, overridden by the YAML file if there is one.
	path, err := config.Load(*configPath)
	if err != nil {
		fmt.Fprintln(ui.Out, "[config error:", err, "]")
		os.Exit(1)
	}

	// Where a relative skillsDir points depends on who started mm. From a
	// terminal, it is the directory mm was started from — an installed mm
	// (/usr/local/bin) run inside a project loads THAT project's skills. The
	// editor launches mm with ITS working directory, so in ACP mode with a
	// config file the anchor is that file — the one path the user actually
	// named (env AGENT_CONFIG in the editor's settings). Either way the path
	// is made absolute here, so the banner can say exactly where it looked.
	config.Cfg.SkillsDir = resolveSkillsDir(config.Cfg.SkillsDir, path, *acpMode)

	e, err := engine.New(ctx)
	if err != nil {
		fmt.Fprintln(ui.Out, "[engine error:", err, "]")
		os.Exit(1)
	}

	// Initialize the global loop detector.
	loopDetector := detector.NewLoopDetector(10, 3)

	agentTools := []ai.ToolRef{
		tools.Bash(e.G, loopDetector),
	}

	// `read_skill` only exists when there is something to read. Its description
	// carries the catalogue, so declaring it with an empty directory would
	// advertise a tool that can do nothing.
	skillCount := len(skills.List(config.Cfg.SkillsDir))
	toolNames := []string{"bash"}
	if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, loopDetector); skillTool != nil {
		agentTools = append(agentTools, skillTool)
		toolNames = append(toolNames, "read_skill")
	}

	// The file tools are a switch, not a given: the same binary must run as
	// part 09 did (bash + the `edit` CLI) and as this part (built-in tools), so
	// the two can be compared on the same prompts.
	if config.Cfg.EditTools {
		agentTools = append(agentTools,
			tools.ReadFile(e.G, loopDetector),
			tools.WriteFile(e.G, loopDetector),
			tools.EditFile(e.G, loopDetector))
		toolNames = append(toolNames, "read_file", "write_file", "edit_file")
	}

	if path == "" {
		path = "built-in defaults"
	}

	// The probe runs before the banner so its warnings sit right under the
	// prompt they explain. It never stops the agent: on a demo machine the
	// server is often started AFTER the agent.
	info := e.Probe(ctx)
	ctxCol := "unknown"
	if info.ContextWindow > 0 {
		ctxCol = fmt.Sprintf("%d (%s)", info.ContextWindow, info.ContextSource)
	}
	// Dimmed on a terminal, plain in a pipe: the agent's output is meant to be
	// consumed by another program as much as it is meant to be read.
	dim, off := "", ""
	if spinner.Styled() {
		dim, off = "\033[2m", "\033[0m"
	}
	// The compression's own warning belongs with the provider's: it is the
	// same kind of advice, and it depends on what the probe just learned.
	if c := config.Cfg.Context; c.Enabled && e.ContextWindow == 0 && c.MaxMessages == 0 {
		info.Warnings = append(info.Warnings,
			"context compression is on, but the context window is unknown and context.maxMessages is 0 — it will never trigger (set contextWindow, or maxMessages)")
	}
	// "skills: 0" alone sends people hunting; the resolved path says at once
	// whether mm looked in the wrong place or found the right one empty.
	if skillCount == 0 {
		info.Warnings = append(info.Warnings,
			fmt.Sprintf("no skills found in %s (expected <name>.md or <name>/SKILL.md there)", config.Cfg.SkillsDir))
	}
	for _, w := range info.Warnings {
		fmt.Fprintf(ui.Out, "%s[warning: %s]%s\n", dim, w, off)
	}
	fmt.Fprintf(ui.Out, "%sconfig: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d%s\n",
		dim, path, e.Backend.Provider, config.Cfg.Model, ctxCol, strings.Join(toolNames, ", "), skillCount, off)

	// Two front ends, one wiring: everything above — config, engine, tools,
	// detector — is strictly identical whichever loop runs below.
	if *acpMode {
		acp.Run(ctx, e, config.Cfg.System, agentTools)
		return
	}
	agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector)
}

// resolveSkillsDir makes the skills directory absolute. An absolute dir is
// kept as-is. A relative one follows the config file in ACP mode when there
// is one (the editor's working directory is not the project's), and the
// current directory otherwise — for the terminal, that is where the user
// started mm.
func resolveSkillsDir(dir, configPath string, acpMode bool) string {
	if filepath.IsAbs(dir) {
		return dir
	}
	anchor := "."
	if acpMode && configPath != "" {
		anchor = filepath.Dir(configPath)
	}
	abs, err := filepath.Abs(filepath.Join(anchor, dir))
	if err != nil {
		return filepath.Join(anchor, dir)
	}
	return abs
}