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
3// The estate's own sites, read only.
4//
5// These are the one group of tools that reach something private, so they work
6// differently from the rest. Each one forwards the session cookie of whoever is
7// chatting, and the site on the other end does its own check against
8// auth.bythewood.me. Nothing here holds a credential of its own, which means
9// this chat cannot read anything the person using it could not already open in
10// a browser, and signing that session out stops these tools on the next call.
11//
12// Every one is a GET against an endpoint that only reads. There is no tool here
13// that can change anything, and there is deliberately not going to be one.
14
15import (
16 "context"
17 "encoding/json"
18 "errors"
19 "fmt"
20 "io"
21 "net/http"
22 "net/url"
23 "strconv"
24 "strings"
25)
26
27// SessionCookie is the name web/session.go uses, repeated here rather than
28// imported because tools is a leaf package.
29const SessionCookie = "bw_session"
30
31// Site names resolve on the bridge, so these never leave the machine and never
32// pass through Cloudflare. A public hostname would work and would be slower,
33// cached, and a lie about where the data went.
34// Vars rather than constants so a test can stand a real server up in front of
35// one, which is the only way to check that a tool walks a repository correctly.
36var (
37 loggingBase = "http://orchard-logging:8000"
38 statusBase = "http://orchard-status:8000"
39 analyticsBase = "http://orchard-analytics:8000"
40 reposBase = "http://orchard-repos:8000"
41 dashBase = "http://orchard-dash:8000"
42)
43
44// errEstateMissing is a 404 from one of the sites, which for a tool walking a
45// repository is a path that is not there rather than a failure.
46var errEstateMissing = fmt.Errorf("no such path")
47
48// estateGet fetches one of the sites with the caller's session on it. It does
49// not go through get(): the Guard exists for third party endpoints that rate
50// limit this address, and putting a container on the bridge in the penalty box
51// would take a whole site out over a blip nobody else is throttling.
52func estateGet(ctx context.Context, d *Deps, rawURL string, into any) error {
53 if d.Session == "" {
54 return fmt.Errorf("this needs you to be signed in, and the turn carried no session")
55 }
56 req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
57 if err != nil {
58 return err
59 }
60 req.AddCookie(&http.Cookie{Name: SessionCookie, Value: d.Session})
61 req.Header.Set("Accept", "application/json")
62
63 resp, err := d.HTTP.Do(req)
64 if err != nil {
65 return fmt.Errorf("%s is not answering: %w", hostOf(rawURL), err)
66 }
67 defer resp.Body.Close()
68
69 switch {
70 case resp.StatusCode == http.StatusUnauthorized, resp.StatusCode == http.StatusForbidden:
71 return fmt.Errorf("%s refused the session, so it may have been signed out", hostOf(rawURL))
72 case resp.StatusCode == http.StatusSeeOther, resp.StatusCode == http.StatusFound:
73 // A redirect here is the login page, which means the same thing as a
74 // 401 and would otherwise be decoded as malformed JSON.
75 return fmt.Errorf("%s wants a sign in", hostOf(rawURL))
76 case resp.StatusCode == http.StatusNotFound:
77 return errEstateMissing
78 case resp.StatusCode >= 400:
79 return fmt.Errorf("%s answered %d", hostOf(rawURL), resp.StatusCode)
80 }
81 return json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(into)
82}
83
84var OrchardLogs = Tool{
85 Name: "orchard_logs",
86 Description: "Read Isaac's own log aggregation at logging.bythewood.me: how many records and " +
87 "errors each of his sites produced, every kind of error with the reason it gives, and the " +
88 "busiest paths with their p95 latency. Use it for anything about whether his sites are " +
89 "misbehaving, what is erroring, or what is slow. Each error comes back grouped, so count " +
90 "and first_seen say how big it is and how long it has run, and the details field carries " +
91 "the reason, which is where the actual cause is rather than in the message. Call it a " +
92 "second time with source or contains to narrow down on one thing. Read only.",
93 Schema: obj(map[string]any{
94 "hours": num("how far back to look, default 24, up to 720"),
95 "errors": num("how many kinds of error to return, default 20"),
96 "source": str("one site by name, optional, such as search or repos or chat"),
97 "contains": str("only errors whose message, reason or path contains this, optional"),
98 }),
99 Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
100 q := url.Values{}
101 if h := int(argNum(a, "hours", 0)); h > 0 {
102 q.Set("hours", strconv.Itoa(h))
103 }
104 if e := int(argNum(a, "errors", 0)); e > 0 {
105 q.Set("errors", strconv.Itoa(e))
106 }
107 if v := strings.TrimSpace(argStr(a, "source")); v != "" {
108 q.Set("source", v)
109 }
110 if v := strings.TrimSpace(argStr(a, "contains")); v != "" {
111 q.Set("contains", v)
112 }
113 var out any
114 err := estateGet(ctx, d, loggingBase+"/api/summary?"+q.Encode(), &out)
115 return out, err
116 },
117}
118
119var OrchardStatus = Tool{
120 Name: "orchard_status",
121 Description: "Read Isaac's own uptime monitoring at status.bythewood.me: every property he " +
122 "watches, whether it is up, when it was last checked, its Lighthouse scores and its crawler " +
123 "state. Use it for whether a site of his is down or slow, or how it scores. Read only.",
124 Schema: obj(map[string]any{}),
125 Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
126 var out any
127 err := estateGet(ctx, d, statusBase+"/api/properties", &out)
128 return out, err
129 },
130}
131
132var OrchardAnalytics = Tool{
133 Name: "orchard_analytics",
134 Description: "Read Isaac's own analytics at analytics.bythewood.me: sessions, page views, live " +
135 "users, and the top pages, referrers, countries, browsers and devices for each property. Use " +
136 "it for anything about his traffic or where his visitors come from. Read only.",
137 Schema: obj(map[string]any{
138 "days": num("how many days back, default 7, up to 365"),
139 "property": str("one property by name, optional, otherwise every one"),
140 }),
141 Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
142 q := url.Values{}
143 if dd := int(argNum(a, "days", 0)); dd > 0 {
144 q.Set("days", strconv.Itoa(dd))
145 }
146 if p := strings.TrimSpace(argStr(a, "property")); p != "" {
147 q.Set("property", p)
148 }
149 var out any
150 err := estateGet(ctx, d, analyticsBase+"/api/summary?"+q.Encode(), &out)
151 return out, err
152 },
153}
154
155var OrchardRepos = Tool{
156 Name: "orchard_repos",
157 Description: "Read Isaac's own git remote at repos.bythewood.me: every repository, its " +
158 "description, size, branch and tag counts, when it was last pushed, and how close it is to " +
159 "the push size limit. Use it for what he is working on or what a repository holds. Read only.",
160 Schema: obj(map[string]any{}),
161 Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
162 var out any
163 err := estateGet(ctx, d, reposBase+"/api/repos", &out)
164 return out, err
165 },
166}
167
168// OrchardCode is the other half of orchard_repos: the listing says what exists
169// and this says what is in it. Without it every question about Isaac's own code
170// ended the same way, with the model guessing raw addresses on repos and
171// github, collecting 404s, and eventually writing a file it said it had read.
172// Nine of the seventeen failed fetches on 2026-09-08 were that.
173var OrchardCode = Tool{
174 Name: "orchard_code",
175 Description: "Read the actual source of one of Isaac's repositories on repos.bythewood.me. " +
176 "Leave path empty to list the top of the repository, give a directory to list it, or give " +
177 "a file to read it. Use this for any question about how his own code works, and walk down " +
178 "to the file rather than guessing a path. Never guess a url for his code and never fetch " +
179 "one, this is the only way in. Read only.",
180 Schema: obj(map[string]any{
181 "repo": str("the repository name, as orchard_repos lists it"),
182 "path": str("a directory to list or a file to read, empty for the top level"),
183 "rev": str("a branch, tag or commit, optional, defaults to the default branch"),
184 }, "repo"),
185 Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
186 repo := strings.Trim(strings.TrimSpace(argStr(a, "repo")), "/")
187 if repo == "" {
188 return nil, fmt.Errorf("repo is required, and orchard_repos lists the names")
189 }
190 rev := strings.TrimSpace(argStr(a, "rev"))
191 if rev == "" {
192 rev = "HEAD"
193 }
194 path := strings.Trim(strings.TrimSpace(argStr(a, "path")), "/")
195
196 // A path with no dot in its last segment is a directory far more often
197 // than not, but guessing wrong either way costs a call, so the file
198 // read is tried first and a miss falls through to the listing. That way
199 // an extensionless file still reads and a directory still lists.
200 base := reposBase + "/api/repos/" + url.PathEscape(repo)
201 if path != "" {
202 var file map[string]any
203 err := estateGet(ctx, d, base+"/file/"+url.PathEscape(rev)+"/"+escapePath(path), &file)
204 if err == nil {
205 return file, nil
206 }
207 // Only a missing file falls through. A refused session or a binary
208 // file is the answer, and listing the directory would bury it.
209 if !errors.Is(err, errEstateMissing) {
210 return nil, err
211 }
212 }
213 treeURL := base + "/tree/" + url.PathEscape(rev)
214 if path != "" {
215 treeURL += "/" + escapePath(path)
216 }
217 var tree map[string]any
218 if err := estateGet(ctx, d, treeURL, &tree); err != nil {
219 if path == "" {
220 return nil, err
221 }
222 return nil, fmt.Errorf("%s has no file or directory at %q, so list the level above it first", repo, path)
223 }
224 return tree, nil
225 },
226}
227
228// escapePath escapes each segment and keeps the separators, since the whole
229// path is one wildcard on the other side and escaping it whole would turn every
230// slash into %2F.
231func escapePath(p string) string {
232 if p == "" {
233 return ""
234 }
235 parts := strings.Split(p, "/")
236 for i, seg := range parts {
237 parts[i] = url.PathEscape(seg)
238 }
239 return strings.Join(parts, "/")
240}
241
242var OrchardDash = Tool{
243 Name: "orchard_dash",
244 Description: "Read Isaac's dashboard at dash.bythewood.me in one call: markets, Hacker News, " +
245 "Lobsters, the weather, upcoming earnings, and whether each of his sites is answering. Use it " +
246 "when a question spans several of those rather than calling each tool separately. Read only.",
247 Schema: obj(map[string]any{}),
248 Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
249 var out any
250 // dash publishes this without a session, since the page it feeds has no
251 // login, so it is the one here that works signed out.
252 req, err := http.NewRequestWithContext(ctx, http.MethodGet, dashBase+"/api/state", nil)
253 if err != nil {
254 return nil, err
255 }
256 resp, err := d.HTTP.Do(req)
257 if err != nil {
258 return nil, fmt.Errorf("dash is not answering: %w", err)
259 }
260 defer resp.Body.Close()
261 if resp.StatusCode >= 400 {
262 return nil, fmt.Errorf("dash answered %d", resp.StatusCode)
263 }
264 err = json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&out)
265 return out, err
266 },
267}