orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package tools
2
3import (
4 "context"
5 "fmt"
6 "net/url"
7 "regexp"
8 "strings"
9 "time"
10)
11
12// ---------------------------------------------------------------- music
13
14var MusicLookup = Tool{
15 Name: "music_lookup",
16 Description: "Check that a song or artist actually exists and get its album and year, from the " +
17 "iTunes catalogue. Use it before listing songs you are not certain about, since an invented " +
18 "track is the one mistake in a playlist a reader cannot see. " +
19 "Only for a question that is already about music. It searches a music catalogue and cannot " +
20 "say what an unfamiliar name refers to, so never reach for it to identify a person, a " +
21 "channel or a company because the name sounds like a band.",
22 Schema: obj(map[string]any{
23 "artist": str("the artist"),
24 "track": str("the song title, optional"),
25 }, "artist"),
26 Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
27 term := strings.TrimSpace(argStr(a, "artist") + " " + argStr(a, "track"))
28 if term == "" {
29 return nil, fmt.Errorf("artist is required")
30 }
31 var res struct {
32 Results []struct {
33 ArtistName string `json:"artistName"`
34 TrackName string `json:"trackName"`
35 CollectionName string `json:"collectionName"`
36 ReleaseDate string `json:"releaseDate"`
37 } `json:"results"`
38 }
39 u := "https://itunes.apple.com/search?media=music&limit=5&term=" + url.QueryEscape(term)
40 if err := getJSON(ctx, d, u, &res); err != nil {
41 return nil, err
42 }
43 type match struct {
44 Artist string `json:"artist"`
45 Track string `json:"track"`
46 Album string `json:"album"`
47 Year string `json:"year"`
48 }
49 out := make([]match, 0, len(res.Results))
50 for _, r := range res.Results {
51 y := r.ReleaseDate
52 if len(y) >= 4 {
53 y = y[:4]
54 }
55 out = append(out, match{r.ArtistName, r.TrackName, r.CollectionName, y})
56 }
57 if len(out) == 0 {
58 return map[string]any{"query": term, "matches": out,
59 "note": "nothing in the catalogue matches, so treat this as not existing"}, nil
60 }
61 return map[string]any{"query": term, "matches": out}, nil
62 },
63}
64
65// ---------------------------------------------------------------- convert
66
67// units are grouped so a length never converts into a weight. Volume to weight
68// is the one crossing allowed and it is only true for water, which is why it
69// says so in the answer rather than quietly being wrong about flour.
70var (
71 weight = map[string]float64{"mg": 0.001, "g": 1, "kg": 1000, "oz": 28.3495, "lb": 453.592, "st": 6350.29}
72 volume = map[string]float64{"ml": 1, "l": 1000, "tsp": 4.92892, "tbsp": 14.7868,
73 "cup": 236.588, "floz": 29.5735, "pint": 473.176, "quart": 946.353, "gallon": 3785.41}
74 length = map[string]float64{"mm": 0.001, "cm": 0.01, "m": 1, "km": 1000,
75 "in": 0.0254, "ft": 0.3048, "yd": 0.9144, "mi": 1609.34}
76)
77
78var unitAlias = map[string]string{
79 "gram": "g", "grams": "g", "gramme": "g", "kilogram": "kg", "kilograms": "kg", "kilo": "kg",
80 "ounce": "oz", "ounces": "oz", "pound": "lb", "pounds": "lb", "lbs": "lb",
81 "millilitre": "ml", "milliliter": "ml", "litre": "l", "liter": "l", "liters": "l", "litres": "l",
82 "teaspoon": "tsp", "teaspoons": "tsp", "tablespoon": "tbsp", "tablespoons": "tbsp",
83 "cups": "cup", "fluid ounce": "floz", "fl oz": "floz", "fahrenheit": "f", "celsius": "c",
84 "centigrade": "c", "inch": "in", "inches": "in", "foot": "ft", "feet": "ft",
85 "mile": "mi", "miles": "mi", "metre": "m", "meter": "m", "kilometre": "km", "kilometer": "km",
86}
87
88func normUnit(u string) string {
89 u = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(u, ".")))
90 u = strings.TrimPrefix(u, "°")
91 if v, ok := unitAlias[u]; ok {
92 return v
93 }
94 return u
95}
96
97var Convert = Tool{
98 Name: "convert",
99 Description: "Convert between units of weight, volume, length or temperature.",
100 Schema: obj(map[string]any{
101 "value": num("the number to convert"),
102 "from_unit": str("the unit it is in, like cup, lb, f, mi"),
103 "to_unit": str("the unit to convert to, like g, kg, c, km"),
104 }, "value", "from_unit", "to_unit"),
105 Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
106 v := argNum(a, "value", 0)
107 from, to := normUnit(argStr(a, "from_unit")), normUnit(argStr(a, "to_unit"))
108 if from == "" || to == "" {
109 return nil, fmt.Errorf("from_unit and to_unit are required")
110 }
111 if from == "c" || from == "f" || to == "c" || to == "f" {
112 switch {
113 case from == "c" && to == "f":
114 return map[string]any{"value": round1(v*9/5 + 32), "unit": "F"}, nil
115 case from == "f" && to == "c":
116 return map[string]any{"value": round1((v - 32) * 5 / 9), "unit": "C"}, nil
117 case from == to:
118 return map[string]any{"value": v, "unit": strings.ToUpper(from)}, nil
119 }
120 return nil, fmt.Errorf("temperature only converts to temperature")
121 }
122 for _, set := range []map[string]float64{weight, volume, length} {
123 f, okF := set[from]
124 t, okT := set[to]
125 if okF && okT {
126 return map[string]any{"value": round4(v * f / t), "unit": to}, nil
127 }
128 }
129 // The one crossing worth allowing, said out loud.
130 if f, ok := volume[from]; ok {
131 if t, ok2 := weight[to]; ok2 {
132 return map[string]any{"value": round4(v * f / t), "unit": to,
133 "warning": "volume to weight is only right for water. Flour, sugar and butter all differ, so say so or look up the real density."}, nil
134 }
135 }
136 return nil, fmt.Errorf("cannot convert %s to %s", from, to)
137 },
138}
139
140func round4(f float64) float64 { return float64(int(f*10000+0.5)) / 10000 }
141
142// ---------------------------------------------------------------- calc
143
144var safeExpr = regexp.MustCompile(`^[0-9\.\+\-\*/\(\)\s%]+$`)
145
146// Long enough for any real sum and short enough that writing one cannot use up
147// a whole round's token budget.
148const maxExprChars = 600
149
150var Calc = Tool{
151 Name: "calc",
152 Description: "Evaluate an arithmetic expression. Use it rather than doing sums in your head, especially " +
153 "for totals and budgets. Keep the expression short: to total a long list of numbers, add them in " +
154 "groups of about twenty and then total the groups, rather than writing every number into one call.",
155 Schema: obj(map[string]any{"expression": str("arithmetic only, like (1299 + 210) * 0.93")}, "expression"),
156 Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
157 e := argStr(a, "expression")
158 // A model totalling a bank statement will write every line into one
159 // expression and run out of tokens partway, so the call arrives cut in
160 // half. Saying so is more use than evaluating whatever survived.
161 if len(e) > maxExprChars {
162 return nil, fmt.Errorf("that expression is too long at %d characters, add the numbers in groups of about twenty and total the groups", len(e))
163 }
164 if !safeExpr.MatchString(e) {
165 return nil, fmt.Errorf("only arithmetic is supported, no names or functions")
166 }
167 v, err := evalExpr(e)
168 if err != nil {
169 return nil, err
170 }
171 return map[string]any{"expression": e, "value": v}, nil
172 },
173}
174
175// ---------------------------------------------------------------- now
176
177var Now = Tool{
178 Name: "now",
179 Description: "The current date and time. Use it whenever the answer depends on what day it is.",
180 Schema: obj(map[string]any{"timezone": str("IANA name, default America/New_York")}),
181 Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
182 name := argStr(a, "timezone")
183 if name == "" {
184 name = "America/New_York"
185 }
186 loc, err := time.LoadLocation(name)
187 if err != nil {
188 return nil, fmt.Errorf("no timezone called %q", name)
189 }
190 t := d.Now().In(loc)
191 return map[string]any{
192 "iso": t.Format(time.RFC3339), "readable": t.Format("Monday, 2 January 2006 at 3:04 PM MST"),
193 "weekday": t.Format("Monday"), "timezone": name,
194 }, nil
195 },
196}