bots-garden/mini-mepublic Fork 0
d72271127802973540c648bfb372176cdaaa8e4f
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 d72271127802973540c648bfb372176cdaaa8e4f · k33g · 6h ago
files.go · 324 lines · 12.9 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
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
package tools

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

	"mm/internal/detector"
	"mm/internal/fileedit"
	"mm/internal/spinner"
	"mm/internal/ui"

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

// --- read_file / write_file / edit_file ---------------------------------------
//
// Three built-in tools with the rules of the `edit` CLI (tools/edit). Part 05
// gave the model that CLI through bash; this part gives it the same operations
// as tools, so the two can be COMPARED: does a 12B model edit more reliably
// when the operation is a tool it can see, or a command it has to spell?
//
// Same contract as `bash`: a failure is returned AS TEXT, never as a Go error,
// so the model reads why and retries; every call prints one scannable line
// before running and echoes its result to the user before the model sees it.

// maxDisplayWidth is where a display line is cut — the same hundred columns as
// the command recap: it fits a demo terminal without wrapping.
const maxDisplayWidth = 100

// clip folds a display line onto ONE line and truncates it. A path can be
// long, an old text longer: the line is there to be scanned, not read.
func clip(s string) string {
	s = strings.Join(strings.Fields(s), " ")
	if r := []rune(s); len(r) > maxDisplayWidth {
		return string(r[:maxDisplayWidth-1]) + "…"
	}
	return s
}

// announce prints the display line of a file tool, the way `bash` prints its
// 🛠️ line: under the spinner's Suspend so the label comes back for the wait
// that follows, and under printMu so two tools of one turn do not interleave.
func announce(line string) {
	spinner.Suspend(func() {
		printMu.Lock()
		fmt.Printf("\n%s\n", clip(line))
		printMu.Unlock()
	})
}

// record feeds the loop detector like `bash` does, and appends its instruction
// to the output when a loop is detected: the same edit refused three times in
// a row is the model going in circles, exactly like a failing command.
func record(d *detector.LoopDetector, tool, input, out string) string {
	if d.Record(detector.Action{ToolName: tool, Input: input, Output: out}) {
		return fmt.Sprintf("%s\n\n%s", out, d.LoopError())
	}
	return out
}

type readFileInput struct {
	Path     string `json:"path" jsonschema_description:"Path of the file, relative to the working directory"`
	Start    int    `json:"start,omitempty" jsonschema_description:"First line to return (1-based); 0 or absent = from the start"`
	End      int    `json:"end,omitempty" jsonschema_description:"Last line to return (1-based, inclusive); 0 or absent = to the end"`
	Numbered bool   `json:"numbered,omitempty" jsonschema_description:"Prefix each line with its number — useful to pick a range for a second read"`
}

// ReadFile declares the `read_file` tool.
func ReadFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
	description := `Read a text file, or a range of its lines.
Read before you edit: edit_file needs the EXACT text of the file, and you cannot match text you have not seen. Do not type it from memory.
Use start/end for a slice of a long file, and numbered=true to get line numbers. Long content is truncated, and the text says so.`

	return genkit.DefineTool(g, "read_file", description,
		func(_ *ai.ToolContext, in readFileInput) (string, error) {
			where := in.Path
			if in.Start > 0 || in.End > 0 {
				where += fmt.Sprintf(" %d-%d", in.Start, in.End)
			}
			if s := ui.ActiveSink(); s != nil {
				return readFileEvented(s, d, in, where)
			}
			announce("📄 read_file: " + where)

			content, err := fileedit.Read(in.Path, in.Start, in.End, in.Numbered)
			if err != nil {
				// Information, not a crash: the message says how to fix the call.
				out := err.Error()
				spinner.Suspend(func() { echoOutput(out) })
				return record(d, "read_file", where, out), nil
			}
			out := truncate(content)
			// Shown to the user like a command's output: "show me that file"
			// means show it, whatever tool read it.
			spinner.Suspend(func() { echoOutput(out) })
			return record(d, "read_file", where, out), nil
		})
}

type writeFileInput struct {
	Path    string `json:"path" jsonschema_description:"Path of the file, relative to the working directory; parent directories are created"`
	Content string `json:"content" jsonschema_description:"The WHOLE content of the file"`
}

// WriteFile declares the `write_file` tool.
func WriteFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
	description := `Write the WHOLE content of a file: create it, or rewrite it entirely and on purpose.
On an existing file it overwrites everything, including what you did not intend to touch: to change part of a file, use edit_file instead.
Returns a one-line summary and the diff against the previous content. Writing identical content changes nothing and says so.`

	return genkit.DefineTool(g, "write_file", description,
		func(tctx *ai.ToolContext, in writeFileInput) (string, error) {
			if s := ui.ActiveSink(); s != nil {
				return writeFileEvented(tctx, s, d, in)
			}
			announce(fmt.Sprintf("💾 write_file: %s (%d bytes)", in.Path, len(in.Content)))

			r, err := fileedit.Write(in.Path, in.Content)
			if err != nil {
				out := err.Error()
				spinner.Suspend(func() { echoOutput(out) })
				return record(d, "write_file", in.Path, out), nil
			}
			return report(d, "write_file", in.Path, r), nil
		})
}

type editFileInput struct {
	Path   string          `json:"path" jsonschema_description:"Path of an EXISTING file, relative to the working directory"`
	Edits  []fileedit.Edit `json:"edits" jsonschema_description:"The replacements, all resolved against the ORIGINAL file; two edits must not overlap"`
	DryRun bool            `json:"dry_run,omitempty" jsonschema_description:"Show what would change and write nothing"`
}

// EditFile declares the `edit_file` tool.
func EditFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
	description := `Change an existing file by EXACT text replacement — the way to modify a file without rewriting it.
Each edit has an old text and a new text. The rules, which are what makes an edit verifiable:
1. old must match the file EXACTLY: same spaces, same indentation, same line breaks. Read the file first with read_file.
2. old must appear EXACTLY ONCE. If it appears twice the tool refuses: add the surrounding lines until the text is unique.
3. Several edits are resolved against the ORIGINAL file, all at once; a replacement never sees what another one wrote.
4. Two edits must not overlap or repeat: if two changes touch the same block, merge them into one edit.
5. An empty new deletes the matched text.
6. Keep old as short as possible while still unique; use two edits rather than one that bridges unchanged lines.
Nothing is written when any edit fails. Returns a one-line summary and the diff with line numbers: read it, it shows exactly what changed. If the tool refuses (text not found, ambiguous, overlapping), read the file again and fix old; do not switch to write_file to force the change through.`

	return genkit.DefineTool(g, "edit_file", description,
		func(tctx *ai.ToolContext, in editFileInput) (string, error) {
			what := fmt.Sprintf("%s (%d edit(s))", in.Path, len(in.Edits))
			if in.DryRun {
				what += " dry-run"
			}
			if s := ui.ActiveSink(); s != nil {
				return editFileEvented(tctx, s, d, in, what)
			}
			announce("📝 edit_file: " + what)

			r, err := fileedit.Replace(in.Path, in.Edits, in.DryRun)
			if err != nil {
				out := err.Error()
				spinner.Suspend(func() { echoOutput(out) })
				return record(d, "edit_file", editKey(in), out), nil
			}
			return report(d, "edit_file", editKey(in), r), nil
		})
}

// editKey is what the loop detector compares for edit_file: the path AND the
// edits. Two calls with the same path but different old texts are the model
// correcting itself, not looping.
func editKey(in editFileInput) string {
	var b strings.Builder
	b.WriteString(in.Path)
	for _, e := range in.Edits {
		b.WriteString("\x00" + e.Old + "\x00" + e.New)
	}
	return b.String()
}

// --- the same three tools, for an event-consuming front end (ACP) -------------
//
// The information is the same as the terminal path prints; only the channel
// changes. Two things exist here that the terminal never had: write_file and
// edit_file ask the client's user for permission before touching a file, and a
// successful change travels as a structured diff the editor can render. Paths
// resolve against the SESSION's working directory (the project open in the
// editor), not the process's — that is ui.Resolve.

// absolute is the location a client can jump to: ACP wants absolute paths.
func absolute(path string) string {
	if abs, err := filepath.Abs(path); err == nil {
		return abs
	}
	return path
}

func readFileEvented(s ui.Sink, d *detector.LoopDetector, in readFileInput, where string) (string, error) {
	path := ui.Resolve(in.Path)
	ev := ui.ToolEvent{
		ID:    ui.NextCallID(),
		Tool:  "read_file",
		Title: "read_file: " + where,
		Kind:  "read",
		Input: map[string]any{"path": in.Path},
		Path:  absolute(path),
	}
	// Reading is not gated: the permission dialog exists to protect the user's
	// files from changes and their shell from commands, and asking before every
	// read teaches them to stop reading the dialogs.
	s.ToolStart(ev)
	s.ToolRunning(ev.ID)

	content, err := fileedit.Read(path, in.Start, in.End, in.Numbered)
	if err != nil {
		out := err.Error()
		s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
		return record(d, "read_file", where, out), nil
	}
	out := truncate(content)
	s.ToolEnd(ev.ID, ui.ToolResult{Output: out})
	return record(d, "read_file", where, out), nil
}

func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in writeFileInput) (string, error) {
	path := ui.Resolve(in.Path)
	ev := ui.ToolEvent{
		ID:    ui.NextCallID(),
		Tool:  "write_file",
		Title: fmt.Sprintf("write_file: %s (%d bytes)", in.Path, len(in.Content)),
		Kind:  "edit",
		Input: map[string]any{"path": in.Path},
		Path:  absolute(path),
	}
	s.ToolStart(ev)
	if !s.Allow(ctx, ev) {
		out := "[write_file rejected by the user]"
		s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
		return record(d, "write_file", in.Path, out), nil
	}
	s.ToolRunning(ev.ID)

	// The old content is read BEFORE the write, for the diff the client
	// renders. Best effort: an unreadable file simply means "created".
	before, readErr := os.ReadFile(path)

	r, err := fileedit.Write(path, in.Content)
	if err != nil {
		out := err.Error()
		s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
		return record(d, "write_file", in.Path, out), nil
	}
	out := resultText(r)
	res := ui.ToolResult{Output: out}
	if r.Changed {
		res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: in.Content, Created: readErr != nil}
	}
	s.ToolEnd(ev.ID, res)
	return record(d, "write_file", in.Path, truncate(out)), nil
}

func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in editFileInput, what string) (string, error) {
	path := ui.Resolve(in.Path)
	ev := ui.ToolEvent{
		ID:    ui.NextCallID(),
		Tool:  "edit_file",
		Title: "edit_file: " + what,
		Kind:  "edit",
		Input: map[string]any{"path": in.Path, "edits": len(in.Edits), "dry_run": in.DryRun},
		Path:  absolute(path),
	}
	s.ToolStart(ev)
	if !s.Allow(ctx, ev) {
		out := "[edit_file rejected by the user]"
		s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
		return record(d, "edit_file", editKey(in), out), nil
	}
	s.ToolRunning(ev.ID)

	before, _ := os.ReadFile(path)

	r, err := fileedit.Replace(path, in.Edits, in.DryRun)
	if err != nil {
		// A refused edit (text not found, ambiguous, overlapping) is the
		// NORMAL path of this tool: failed status for the client, text for the
		// model — which reads the rule and fixes its `old`.
		out := err.Error()
		s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
		return record(d, "edit_file", editKey(in), out), nil
	}
	out := resultText(r)
	res := ui.ToolResult{Output: out}
	if r.Changed && !in.DryRun {
		after, readErr := os.ReadFile(path)
		if readErr == nil {
			res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: string(after)}
		}
	}
	s.ToolEnd(ev.ID, res)
	return record(d, "edit_file", editKey(in), truncate(out)), nil
}

// resultText is what report() builds for the model, without the echo: the
// headline, then the numbered diff.
func resultText(r fileedit.Result) string {
	out := r.Headline
	if r.Diff != "" {
		out += "\n" + strings.TrimRight(r.Diff, "\n")
	}
	return out
}

// report echoes a write/edit result to the user — headline then diff, dimmed
// and capped by previewLines like a command's output — and returns the same
// text to the model, capped by maxOutput. The user sees the change BEFORE the
// model comments on it: what the file became no longer depends on what the
// model chooses to say about it.
func report(d *detector.LoopDetector, tool, key string, r fileedit.Result) string {
	out := resultText(r)
	spinner.Suspend(func() { echoOutput(out) })
	return record(d, tool, key, truncate(out))
}