package agent import ( "context" "errors" "fmt" "sort" "strconv" "strings" "sync" "time" "gitea.stevedudenhoeffer.com/steve/majordomo/llm" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service" ) // NewToolbox builds a majordomo toolbox over pansy's service layer, bound to a // single acting user and to the day it is where they are. Every tool call runs // as actorID, so pansy's permission checks (requireGardenRole / objectForRole) // apply unchanged. Construct one per authenticated agent session: // // box := agent.NewToolbox(svc, session.UserID, "2026-08-22") // agent.Run(ctx, model, box, "fill the NE corner with garlic") // // today (YYYY-MM-DD) is the date every tool stamps on what it plants, removes or // journals unless the model passes one — the gardener's local day, from the // client, because the server's UTC day is tomorrow by nine in the evening in // Ohio. Empty falls back to the service's UTC today. func NewToolbox(svc *service.Service, actorID int64, today string) *llm.Toolbox { box, _ := newToolbox(svc, actorID, today) return box } // newToolbox is NewToolbox plus the adapter behind it, which Run keeps hold of: // the adapter remembers what undo_change reverted, and a turn that only undid // something has no other handle to offer as its change. func newToolbox(svc *service.Service, actorID int64, today string) (*llm.Toolbox, *adapter) { a := &adapter{svc: svc, actor: actorID, today: strings.TrimSpace(today)} return llm.NewToolbox("pansy", llm.DefineTool("list_gardens", "List the gardens the user can see (owned and shared), with the user's role on each.", a.listGardens), llm.DefineTool("describe_garden", "Summarize a garden: its dimensions, notes, version, objects (with sizes/positions/version), "+ "and each object's plantings grouped by plant — how many, roughly where, when they went in, "+ "and days to maturity when known. A small group lists its plops individually (id + "+ "version for move_planting/remove_planting/update_planting, and xCm/yCm in the object's "+ "local frame so a move can keep their layout); a large one (a grid-filled bed) does not — "+ "use list_plantings for those, or act on the whole group with remove_plantings. Without a "+ "year it describes what is growing now; with one it is that season's view — every plop "+ "whose time in the ground overlapped the year, pulled ones included, each group saying how "+ "many were removed and when. That is how to answer \"what was in this bed last year?\" and "+ "to check rotation before replanting. list_years says which years have data.", a.describeGarden), llm.DefineTool("list_years", "List the years this garden has planting records for, newest first — the years "+ "describe_garden can show as a season view.", a.listYears), llm.DefineTool("list_plantings", "List one object's active plops one by one, each with its id, version, position (xCm/yCm "+ "in the object's local frame), location, count and planting date — the detail "+ "describe_garden leaves out for a large group. Narrow to one plant with plantId. Use it "+ "only when you need to address individual plops.", a.listPlantings), llm.DefineTool("create_object", "Add an object (bed, grow_bag, container, in_ground, tree, path, structure) to a garden, positioned by its center in garden cm.", a.createObject), llm.DefineTool("move_object", "Move an object to a new center position (garden cm). Needs the object's current version from describe_garden.", a.moveObject), llm.DefineTool("place_planting", "Place one plop of a plant inside a plantable object, positioned in the object's LOCAL frame "+ "(0,0 = object center, -y = north). Omit radiusCm for a single plant (it defaults to half "+ "the plant's spacing); a larger radius is a clump, whose plant count is derived from its "+ "area unless you pass count. Dated today unless plantedAt says otherwise.", a.placePlanting), llm.DefineTool("fill_region", "Fill part of a plantable object with one plant, hex-packed at the plant's spacing. "+ "Say where EITHER by region — a compass name, not coordinates: nw|ne|sw|se for the quarter "+ "corners, north|south|east|west (or top|bottom|left|right) for halves, or all for the whole "+ "thing; north is the top of the garden — OR by an explicit rectangle in the object's local "+ "frame (x0Cm,y0Cm,x1Cm,y1Cm; 0,0 = center, -y = north), for a middle third, a strip along "+ "one edge, or any area a compass name can't say. Example: to replant a whole bed, "+ "clear_object then fill_region with region=all. Filling skips spots already covered by an "+ "existing plant, so it is safe to run twice. Dated today unless plantedAt says otherwise.", a.fillRegion), llm.DefineTool("move_planting", "Move ONE plop to a new position — within its object, or into another plantable object of "+ "the same garden with toObjectId — keeping its plant, size, count and planting date. This "+ "is how to relocate plants; removing and re-placing them would lose when they were planted. "+ "Needs the plop's id and version (describe_garden or list_plantings).", a.movePlanting), llm.DefineTool("update_planting", "Correct ONE plop's record without moving it: the date it was planted (plantedAt), its "+ "plant count (count, or clearCount to go back to deriving it from area and spacing), "+ "its label, its radius, or the seed lot it came from. Use for \"those tomatoes actually "+ "went in on May 20\" or \"that clump is five plants\". Needs the plop's id and version "+ "(describe_garden or list_plantings). Only the fields you pass change.", a.updatePlanting), llm.DefineTool("remove_planting", "Remove ONE plop from a bed, leaving the rest — the single-plant answer to clear_object's "+ "all-or-nothing. Soft-removes it (kept for planting history, undoable), like clearing a "+ "bed does. Needs the plop's id and version (describe_garden or list_plantings). Use "+ "for \"pull the basil out of the corner\". Dated today unless removedAt says when it "+ "actually came out (\"I harvested the garlic on Aug 1\").", a.removePlanting), llm.DefineTool("remove_plantings", "Remove every plop of ONE plant from an object, leaving the other plants in it — \"take the "+ "beets out of the south bed\". Soft-removes them (kept for planting history, undoable as "+ "one change). Use this rather than many remove_planting calls. Dated today unless "+ "removedAt says when they actually came out.", a.removePlantings), llm.DefineTool("clear_object", "Remove all plants from an object. They are soft-removed, so the planting history for past "+ "seasons is kept and the change can be undone. Use this before replanting a bed with "+ "something else. Dated today unless removedAt says when the bed was actually cleared.", a.clearObject), llm.DefineTool("find_plant", "Look up plants in the user's catalog by name or category, to get the plantId that "+ "place_planting and fill_region need. Returns SEVERAL candidates when the query is "+ "ambiguous — \"garlic\" may match both the built-in \"Garlic\" and a custom \"German Red "+ "Garlic\" — so pick the one that fits what the user asked for, or ask them which. Each "+ "result also reports how much seed the user has left of it, when they have recorded any.", a.findPlant), llm.DefineTool("create_plant", "Add a new plant to the user's own catalog, for when they name a variety that isn't in it "+ "yet. Check find_plant first — creating a duplicate of something that already exists is "+ "worse than reusing it. The plant belongs to the user, not to any garden.", a.createPlant), llm.DefineTool("update_plant", "Change a plant in the user's own catalog: its name, category, spacing, color, days to "+ "maturity, vendor, source link or notes. Only the fields you pass change. Needs the "+ "plant's current version from find_plant. Built-in plants can't be edited — create_plant "+ "the user's own variety instead.", a.updatePlant), llm.DefineTool("add_journal_entry", "Write a dated observation into the garden's grow journal — what happened, and when. "+ "Attach it to one bed with objectId when it is about that bed. This is for events "+ "(\"powdery mildew on the west bed\", \"first frost\"), not for descriptions of what a "+ "thing is and not for narrating your own plantings. observedAt defaults to today; set "+ "it to backdate.", a.addJournalEntry), llm.DefineTool("read_journal", "Read back the garden's grow journal — the observations add_journal_entry wrote. "+ "Narrow it with objectId (one bed), or a from/to date range (YYYY-MM-DD). Most "+ "recently observed first. Each entry carries the id and version that "+ "update_journal_entry and delete_journal_entry need. Use this to answer \"what did I "+ "note about the west bed?\" or \"what happened last spring?\".", a.readJournal), llm.DefineTool("update_journal_entry", "Correct a journal entry the user wrote — its text, or the date it describes — instead "+ "of adding a second entry that contradicts the first: \"that note was about the "+ "cucumbers, not the cantaloupe\". Needs the entry's id and version from read_journal. "+ "Only the user's own entries can be edited.", a.updateJournalEntry), llm.DefineTool("delete_journal_entry", "Delete a journal entry, by its id from read_journal. This is permanent — the journal is "+ "not in the undo history — so delete only the entry the user pointed at.", a.deleteJournalEntry), llm.DefineTool("read_history", "Read the garden's change history: every change anyone made — by hand in the editor, or "+ "in an earlier conversation with you — newest first, with its id, what it changed and "+ "whether it was undone. Use it to answer \"what changed this week?\" or \"what did you do "+ "last time?\" rather than reciting from memory, and to find the id undo_change needs.", a.readHistory), llm.DefineTool("undo_change", "Undo one change from the history by its id (from read_history): it reverts everything "+ "that change did, as a new change that can itself be undone. This is how to do \"undo "+ "the beets\" or \"put it back the way it was\" — find the change in read_history, then "+ "undo it; never claim to have undone something without calling this. A change already "+ "marked undone needs no second undo. Anything edited since that change is left alone "+ "and reported under conflicts; tell the user about those.", a.undoChange), llm.DefineTool("update_object", "Change an existing object: resize it (widthCm/heightCm), rotate it (rotationDeg), "+ "rename it (name), or toggle whether it can hold plants (plantable). Only the fields "+ "you pass change. Needs the object's current version from describe_garden. Example: "+ "\"make that bed 60cm wider\" — read its widthCm from describe_garden, add 60, pass the "+ "sum.", a.updateObject), llm.DefineTool("delete_object", "Delete an object from a garden entirely, along with its plantings. This is the "+ "counterpart to create_object — use it for \"remove the old grow bag\". Permanent (not "+ "the same as clearing a bed's plants); prefer clear_object when the bed itself stays.", a.deleteObject), llm.DefineTool("list_seed_lots", "List the seed lots (purchases) the user has recorded — vendor, quantity, and what's "+ "left — optionally for one plant via plantId. This is the detail behind the \"seed "+ "remaining\" number find_plant reports.", a.listSeedLots), llm.DefineTool("record_seed_lot", "Record a seed purchase for a plant the user owns, so pansy can track how much is left. "+ "Get the plantId from find_plant first. quantity + unit is what was bought (e.g. 2 "+ "\"packets\", or 500 \"seeds\"). Use for \"I bought two packets of Cherokee Purple\". To "+ "count seed as used, plant with a seedLotId on place_planting or fill_region.", a.recordSeedLot), llm.DefineTool("copy_garden", "Duplicate a garden the user owns — beds, objects and plantings — as a new garden with the "+ "given name. This is how a season plan is made: a copy named \"\" "+ "(with an em dash) is that garden's plan for the year, and the editor offers it as such. "+ "Use it for \"set up next year's plan\"; never use another real garden as a scratch space.", a.copyGarden), llm.DefineTool("update_garden", "Change a garden the user owns: rename it, resize it (widthCm/heightCm), switch its units "+ "(metric|imperial), set its grid (gridSizeCm, snapToGrid), or rewrite its notes. Only the "+ "fields you pass change. Needs the garden's current version from describe_garden. The "+ "notes are the gardener's standing facts about the place — zone, frost dates, soil, sun, "+ "how they like things done — and you are given them at the start of every conversation, "+ "so when the user tells you something worth remembering (\"we're in zone 6a\", \"last "+ "frost is usually around May 10\"), add it here. notes replaces the WHOLE text: take the "+ "current notes from describe_garden, add the new line, and pass all of it.", a.updateGarden), llm.DefineTool("create_garden", "Start a NEW garden for the user — a different place (the front yard, the allotment), "+ "sized in cm and owned by them. Not for a season plan: a plan is a copy_garden of the "+ "real garden. The new garden opens from the gardens list; this conversation stays with "+ "the one it started in.", a.createGarden), llm.DefineTool("update_seed_lot", "Correct a seed lot the user recorded — quantity, unit, vendor, link, purchase date, "+ "packed-for year, germination rate or notes: \"it was three packets, not two\". Needs "+ "the lot's id and version from list_seed_lots. Only the fields you pass change; the "+ "plant a lot is for cannot change (record a new lot instead).", a.updateSeedLot), llm.DefineTool("delete_seed_lot", "Delete a seed lot the user recorded, by its id from list_seed_lots. Plantings attributed "+ "to it stay in the garden, just no longer linked to a purchase. Permanent — seed lots "+ "are not in the undo history — so delete only the lot the user pointed at.", a.deleteSeedLot), llm.DefineTool("delete_plant", "Delete a plant from the user's own catalog — a duplicate or a mistake. Refused while "+ "anything is planted with it (in any garden, past seasons included) or a seed lot "+ "records it; say so rather than removing those to make it deletable. Built-in plants "+ "can't be deleted. Permanent — the catalog is not in the undo history.", a.deletePlant), llm.DefineTool("delete_planting", "Delete ONE plop outright — for a plop that was never really planted (a misplacement, a "+ "duplicate), as opposed to remove_planting, which records that a real plant came out "+ "and keeps it in the season history. Recorded in the history, so it can be undone. "+ "Needs the plop's id (describe_garden or list_plantings).", a.deletePlanting), llm.DefineTool("list_shares", "List who a garden the user owns is shared with — each person's email, name, role "+ "(viewer|editor) and userId — and whether its public read-only link is on.", a.listShares), llm.DefineTool("share_garden", "Share a garden the user owns with another pansy account by its exact email, as a viewer "+ "or an editor; an existing share is changed to the new role. OUTWARD-FACING: only with "+ "confirmed=true, which you may pass only after the user has said yes, in this "+ "conversation, to sharing THIS garden with THAT email at THAT role — if they have not, "+ "say what you would do and ask. The other person must already have an account.", a.shareGarden), llm.DefineTool("remove_share", "Stop sharing a garden with someone, by their email (see list_shares). OUTWARD-FACING: "+ "only with confirmed=true, after the user has said yes to removing that person from "+ "this garden; otherwise say what you would do and ask.", a.removeShare), llm.DefineTool("public_link", "The garden's public read-only link, which anyone holding it can open without an account. "+ "action get reports whether it is on and the link. enable turns it on (or reports the "+ "existing link), rotate issues a fresh link so the old one stops working, disable turns "+ "it off. OUTWARD-FACING: enable, rotate and disable need confirmed=true, which you may "+ "pass only after the user has said yes to that exact action in this conversation.", a.publicLink), ), a } // adapter carries the service, the acting user and their local day for the // tool handlers. type adapter struct { svc *service.Service actor int64 today string mu sync.Mutex // reverts is every change set undo_change produced this turn. A revert is // its own change set (it points back at the one it undid, and the target is // marked undone), so it never joins the turn's scope — which leaves a turn // that only undid something with no change of its own. Run reports the last // revert as that turn's handle, so "Undo this" under the reply can redo it. reverts []int64 } // lastRevert is the newest change set undo_change produced this turn, if any. func (a *adapter) lastRevert() *int64 { a.mu.Lock() defer a.mu.Unlock() if len(a.reverts) == 0 { return nil } id := a.reverts[len(a.reverts)-1] return &id } // day is the date a tool stamps: the one the model passed, else the gardener's // local today, else nil for the service's UTC default. A date the model typed // is checked here, so every dated tool refuses a prose date the same way. func (a *adapter) day(explicit string) (*string, error) { if strings.TrimSpace(explicit) != "" { d, err := parseDay(explicit) if err != nil { return nil, err } return &d, nil } if a.today != "" { d := a.today return &d, nil } return nil, nil } func (a *adapter) listGardens(ctx context.Context, _ struct{}) (any, error) { return a.svc.ListGardens(ctx, a.actor) } func (a *adapter) describeGarden(ctx context.Context, args struct { GardenID int64 `json:"gardenId" description:"id of the garden to describe"` Year *int `json:"year" description:"optional: describe that year's season instead of what is growing now — every plop in the ground at any point in the year, pulled ones included"` }) (any, error) { return a.svc.DescribeGarden(ctx, a.actor, args.GardenID, args.Year) } func (a *adapter) listYears(ctx context.Context, args struct { GardenID int64 `json:"gardenId" description:"garden whose planting years to list"` }) (any, error) { years, err := a.svc.GardenYears(ctx, a.actor, args.GardenID) if err != nil { return nil, err } // The service pads the list with ITS current year (UTC); the gardener's may // differ around New Year. Theirs is the one "this year" means to them — and // it is not always the newest, so the list is re-sorted rather than // prepended to. if y, ok := yearOf(a.today); ok { present := false for _, have := range years { if have == y { present = true break } } if !present { years = append(years, y) sort.Sort(sort.Reverse(sort.IntSlice(years))) } } return map[string]any{"years": years}, nil } // yearOf is the year of a YYYY-MM-DD date, or false for anything else. func yearOf(date string) (int, bool) { if len(date) < 4 { return 0, false } y, err := strconv.Atoi(date[:4]) if err != nil { return 0, false } return y, true } func (a *adapter) listPlantings(ctx context.Context, args struct { ObjectID int64 `json:"objectId" description:"object whose plops to list"` PlantID *int64 `json:"plantId" description:"optional: only plops of this plant"` }) (any, error) { return a.svc.ListObjectPlantings(ctx, a.actor, args.ObjectID, args.PlantID) } func (a *adapter) createObject(ctx context.Context, args struct { GardenID int64 `json:"gardenId" description:"garden to add the object to"` Kind string `json:"kind" description:"bed | grow_bag | container | in_ground | tree | path | structure"` Name string `json:"name" description:"optional label for the object"` Shape string `json:"shape" description:"rect or circle (default rect)"` XCM float64 `json:"xCm" description:"center x in garden cm"` YCM float64 `json:"yCm" description:"center y in garden cm"` WidthCM float64 `json:"widthCm" description:"width in cm (a circle's diameter)"` HeightCM float64 `json:"heightCm" description:"height in cm"` }) (any, error) { return a.svc.CreateObject(ctx, a.actor, args.GardenID, service.ObjectInput{ Kind: args.Kind, Name: args.Name, Shape: args.Shape, XCM: args.XCM, YCM: args.YCM, WidthCM: args.WidthCM, HeightCM: args.HeightCM, }) } func (a *adapter) moveObject(ctx context.Context, args struct { ObjectID int64 `json:"objectId" description:"object to move"` XCM float64 `json:"xCm" description:"new center x in garden cm"` YCM float64 `json:"yCm" description:"new center y in garden cm"` Version int64 `json:"version" description:"the object's current version (from describe_garden)"` }) (any, error) { return a.svc.UpdateObject(ctx, a.actor, args.ObjectID, service.ObjectPatch{XCM: &args.XCM, YCM: &args.YCM}, args.Version) } func (a *adapter) placePlanting(ctx context.Context, args struct { ObjectID int64 `json:"objectId" description:"plantable object to plant in"` PlantID int64 `json:"plantId" description:"plant to place"` XCM float64 `json:"xCm" description:"center x in the object's local frame (cm; 0,0 = center, -y = north)"` YCM float64 `json:"yCm" description:"center y in the object's local frame (cm)"` RadiusCM float64 `json:"radiusCm" description:"optional plop radius in cm; omit (0) for one plant at half the plant's spacing"` Count *int `json:"count" description:"optional explicit plant count; omit to derive from area ÷ spacing²"` PlantedAt string `json:"plantedAt" description:"optional planting date, YYYY-MM-DD; defaults to today"` SeedLotID *int64 `json:"seedLotId" description:"optional seed lot (from list_seed_lots) this planting uses, so the lot counts it as used"` }) (any, error) { on, err := a.day(args.PlantedAt) if err != nil { return nil, err } return a.svc.CreatePlanting(ctx, a.actor, args.ObjectID, service.PlantingInput{ PlantID: args.PlantID, XCM: args.XCM, YCM: args.YCM, RadiusCM: args.RadiusCM, Count: args.Count, PlantedAt: on, SeedLotID: args.SeedLotID, }) } func (a *adapter) fillRegion(ctx context.Context, args struct { ObjectID int64 `json:"objectId" description:"plantable object to fill"` Region string `json:"region" description:"nw|ne|sw|se corner, north|south|east|west (or top|bottom|left|right) half, or all; leave empty when giving a rectangle"` X0CM *float64 `json:"x0Cm" description:"rectangle instead of region: west edge, local cm (0 = center)"` Y0CM *float64 `json:"y0Cm" description:"rectangle: north edge, local cm (negative is north of center)"` X1CM *float64 `json:"x1Cm" description:"rectangle: east edge, local cm"` Y1CM *float64 `json:"y1Cm" description:"rectangle: south edge, local cm"` PlantID int64 `json:"plantId" description:"plant to fill with"` SpacingOverride *float64 `json:"spacingOverrideCm" description:"optional in-row spacing override in cm; omit to use the plant's spacing"` Mode string `json:"mode" enum:"clump,grid" description:"clump (default) drops a few fat clumps for a quick sketch; grid lays out individual plants in rows at true spacing, a layout you could plant from"` PlantedAt string `json:"plantedAt" description:"optional planting date for every plop, YYYY-MM-DD; defaults to today"` SeedLotID *int64 `json:"seedLotId" description:"optional seed lot (from list_seed_lots) this fill uses, so the lot counts it as used"` }) (any, error) { on, err := a.day(args.PlantedAt) if err != nil { return nil, err } spec := service.FillSpec{ RegionName: args.Region, PlantID: args.PlantID, SpacingOverride: args.SpacingOverride, Layout: service.FillLayout(args.Mode), PlantedAt: on, SeedLotID: args.SeedLotID, } rect := []*float64{args.X0CM, args.Y0CM, args.X1CM, args.Y1CM} given := 0 for _, v := range rect { if v != nil { given++ } } switch { case given == 4 && strings.TrimSpace(args.Region) == "": if !(*args.X0CM < *args.X1CM && *args.Y0CM < *args.Y1CM) { return nil, fmt.Errorf("%w: x0Cm must be west of x1Cm and y0Cm north of y1Cm (-y is north)", domain.ErrInvalidInput) } spec.Region = service.Region{MinX: *args.X0CM, MinY: *args.Y0CM, MaxX: *args.X1CM, MaxY: *args.Y1CM} case given == 0 && strings.TrimSpace(args.Region) != "": // the named region case given == 4: return nil, fmt.Errorf("%w: give either a region name or a rectangle, not both", domain.ErrInvalidInput) case given > 0: return nil, fmt.Errorf("%w: a rectangle needs all four of x0Cm, y0Cm, x1Cm, y1Cm", domain.ErrInvalidInput) default: return nil, fmt.Errorf("%w: say where to fill — a region name, or a rectangle", domain.ErrInvalidInput) } return a.svc.Fill(ctx, a.actor, args.ObjectID, spec) } func (a *adapter) movePlanting(ctx context.Context, args struct { PlantingID int64 `json:"plantingId" description:"plop to move (its id from describe_garden or list_plantings)"` Version int64 `json:"version" description:"the plop's current version"` XCM float64 `json:"xCm" description:"new center x in the destination object's local frame (cm; 0,0 = center, -y = north)"` YCM float64 `json:"yCm" description:"new center y in the destination object's local frame (cm)"` ToObjectID *int64 `json:"toObjectId" description:"optional: another plantable object in the same garden to move it into; omit to move within its current object"` }) (any, error) { return a.svc.MovePlanting(ctx, a.actor, args.PlantingID, service.MoveInput{ToObjectID: args.ToObjectID, XCM: args.XCM, YCM: args.YCM}, args.Version) } func (a *adapter) findPlant(ctx context.Context, args struct { Query string `json:"query" description:"plant name or category to search for, e.g. \"cucumber\" or \"herb\"; empty lists the catalog"` }) (any, error) { return a.svc.FindPlants(ctx, a.actor, args.Query) } func (a *adapter) createPlant(ctx context.Context, args struct { Name string `json:"name" description:"the variety's name, e.g. \"German Red Garlic\""` Category string `json:"category" description:"vegetable | herb | flower | fruit | tree_shrub | cover"` SpacingCM float64 `json:"spacingCm" description:"mature in-row spacing in cm; this is what fill_region packs to"` Color string `json:"color" description:"hex color for the plant on the canvas, e.g. #8a5a8a"` Icon string `json:"icon" description:"a single emoji to draw it with, e.g. 🧄"` DaysToMaturity *int `json:"daysToMaturity" description:"optional days from planting to harvest"` SourceURL string `json:"sourceUrl" description:"optional http(s) link to where the seed came from"` Vendor string `json:"vendor" description:"optional vendor name, e.g. \"Johnny's Selected Seeds\""` }) (any, error) { return a.svc.CreatePlant(ctx, a.actor, service.PlantInput{ Name: args.Name, Category: args.Category, SpacingCM: args.SpacingCM, Color: args.Color, Icon: args.Icon, DaysToMaturity: args.DaysToMaturity, SourceURL: args.SourceURL, Vendor: args.Vendor, }) } func (a *adapter) updatePlant(ctx context.Context, args struct { PlantID int64 `json:"plantId" description:"plant to change (the user's own, from find_plant)"` Version int64 `json:"version" description:"the plant's current version (from find_plant)"` Name *string `json:"name" description:"optional new name"` Category *string `json:"category" description:"optional: vegetable | herb | flower | fruit | tree_shrub | cover"` SpacingCM *float64 `json:"spacingCm" description:"optional new mature in-row spacing in cm"` Color *string `json:"color" description:"optional new hex color"` DaysToMaturity *int `json:"daysToMaturity" description:"optional days from planting to harvest"` SourceURL *string `json:"sourceUrl" description:"optional http(s) link to where the seed came from"` Vendor *string `json:"vendor" description:"optional vendor name"` Notes *string `json:"notes" description:"optional free-text notes"` }) (any, error) { return a.svc.UpdatePlant(ctx, a.actor, args.PlantID, service.PlantPatch{ Name: args.Name, Category: args.Category, SpacingCM: args.SpacingCM, Color: args.Color, SetDays: args.DaysToMaturity != nil, DaysToMaturity: args.DaysToMaturity, SourceURL: args.SourceURL, Vendor: args.Vendor, Notes: args.Notes, }, args.Version) } func (a *adapter) addJournalEntry(ctx context.Context, args struct { GardenID int64 `json:"gardenId" description:"garden the observation is about"` ObjectID *int64 `json:"objectId" description:"optional bed the observation is about; omit for a garden-level note"` Body string `json:"body" description:"what happened, in plain words"` ObservedAt string `json:"observedAt" description:"optional date it happened, YYYY-MM-DD; defaults to today"` }) (any, error) { on, err := a.day(args.ObservedAt) if err != nil { return nil, err } return a.svc.CreateJournalEntry(ctx, a.actor, args.GardenID, service.JournalInput{ ObjectID: args.ObjectID, Body: args.Body, ObservedAt: on, }) } func (a *adapter) clearObject(ctx context.Context, args struct { ObjectID int64 `json:"objectId" description:"object to remove all plants from"` RemovedAt string `json:"removedAt" description:"optional date the plants came out, YYYY-MM-DD; defaults to today"` }) (any, error) { on, err := a.day(args.RemovedAt) if err != nil { return nil, err } n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID, service.ClearOptions{RemovedAt: on}) if err != nil { return nil, err } return map[string]int{"cleared": n}, nil } func (a *adapter) removePlantings(ctx context.Context, args struct { ObjectID int64 `json:"objectId" description:"object to remove the plant from"` PlantID int64 `json:"plantId" description:"the plant to remove every plop of (from describe_garden)"` RemovedAt string `json:"removedAt" description:"optional date they came out, YYYY-MM-DD; defaults to today"` }) (any, error) { if args.PlantID == 0 { // Left out, it would "remove" plant 0 — nothing — and report success. return nil, fmt.Errorf("%w: plantId is required — say which plant to remove, or use clear_object for all of them", domain.ErrInvalidInput) } on, err := a.day(args.RemovedAt) if err != nil { return nil, err } n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID, service.ClearOptions{PlantID: &args.PlantID, RemovedAt: on}) if err != nil { return nil, err } return map[string]int{"removed": n}, nil } func (a *adapter) updatePlanting(ctx context.Context, args struct { PlantingID int64 `json:"plantingId" description:"plop to correct (its id from describe_garden or list_plantings)"` Version int64 `json:"version" description:"the plop's current version"` PlantedAt *string `json:"plantedAt" description:"optional corrected planting date, YYYY-MM-DD"` Count *int `json:"count" description:"optional explicit plant count for the plop"` ClearCount bool `json:"clearCount" description:"optional: drop an explicit count and derive it from area and spacing again"` Label *string `json:"label" description:"optional label for the plop; empty clears it"` RadiusCM *float64 `json:"radiusCm" description:"optional new radius in cm"` SeedLotID *int64 `json:"seedLotId" description:"optional seed lot (from list_seed_lots) to attribute it to"` ClearSeedLot bool `json:"clearSeedLot" description:"optional: detach it from its seed lot"` }) (any, error) { patch := service.PlantingPatch{RadiusCM: args.RadiusCM} if args.PlantedAt != nil { on, err := parseDay(*args.PlantedAt) if err != nil { return nil, err } patch.SetPlantedAt, patch.PlantedAt = true, &on } switch { case args.ClearCount && args.Count != nil: return nil, fmt.Errorf("%w: give a count or clearCount, not both", domain.ErrInvalidInput) case args.ClearCount: patch.SetCount = true case args.Count != nil: patch.SetCount, patch.Count = true, args.Count } // SetLabel with a nil Label clears it: an empty string from the model means // "no label", not a label that happens to be empty. if args.Label != nil { patch.SetLabel = true if l := strings.TrimSpace(*args.Label); l != "" { patch.Label = &l } } switch { case args.ClearSeedLot && args.SeedLotID != nil: return nil, fmt.Errorf("%w: give a seedLotId or clearSeedLot, not both", domain.ErrInvalidInput) case args.ClearSeedLot: patch.SetSeedLotID = true case args.SeedLotID != nil: patch.SetSeedLotID, patch.SeedLotID = true, args.SeedLotID } return a.svc.UpdatePlanting(ctx, a.actor, args.PlantingID, patch, args.Version) } // parseDay checks a date the model typed, so a malformed one fails with a // message about the date rather than as a bare "invalid input" from the store. func parseDay(s string) (string, error) { s = strings.TrimSpace(s) if _, err := time.Parse(dateLayout, s); err != nil { return "", fmt.Errorf("%w: %q is not a YYYY-MM-DD date", domain.ErrInvalidInput, s) } return s, nil } func (a *adapter) readJournal(ctx context.Context, args struct { GardenID int64 `json:"gardenId" description:"garden whose journal to read"` ObjectID *int64 `json:"objectId" description:"optional bed to narrow to; omit for the whole garden"` From string `json:"from" description:"optional earliest observed date, YYYY-MM-DD"` To string `json:"to" description:"optional latest observed date, YYYY-MM-DD"` Offset int `json:"offset" description:"how many entries to skip; pass the count you've already seen to page when hasMore is true"` }) (any, error) { q := service.JournalQuery{ObjectID: args.ObjectID, Limit: 50, Offset: args.Offset} if args.From != "" { q.From = &args.From } if args.To != "" { q.To = &args.To } entries, hasMore, err := a.svc.ListJournal(ctx, a.actor, args.GardenID, q) if err != nil { return nil, err } // hasMore is actionable now: re-call with offset += len(entries) to page. return map[string]any{"entries": entries, "hasMore": hasMore}, nil } func (a *adapter) updateJournalEntry(ctx context.Context, args struct { EntryID int64 `json:"entryId" description:"journal entry to correct (its id from read_journal)"` Version int64 `json:"version" description:"the entry's current version (from read_journal)"` Body *string `json:"body" description:"optional corrected text"` ObservedAt *string `json:"observedAt" description:"optional corrected date it happened, YYYY-MM-DD"` }) (any, error) { if args.Body == nil && args.ObservedAt == nil { return nil, fmt.Errorf("%w: say what to change — the body, the date, or both", domain.ErrInvalidInput) } observed := args.ObservedAt if observed != nil { on, err := parseDay(*observed) if err != nil { return nil, err } observed = &on } return a.svc.UpdateJournalEntry(ctx, a.actor, args.EntryID, service.JournalPatch{Body: args.Body, ObservedAt: observed}, args.Version) } func (a *adapter) deleteJournalEntry(ctx context.Context, args struct { EntryID int64 `json:"entryId" description:"journal entry to delete (its id from read_journal)"` }) (any, error) { if err := a.svc.DeleteJournalEntry(ctx, a.actor, args.EntryID); err != nil { return nil, err } return map[string]any{"deleted": args.EntryID}, nil } // undoResult is what undo_change reports: the change it reverted, the new // change set that did so (undoable in turn), and what it had to leave alone. type undoResult struct { UndoneID int64 `json:"undoneId"` // ChangeSet is the revert itself — the history entry that can be undone // to redo — and Summary its row in the history ("Undid: …"). ChangeSet *int64 `json:"changeSetId,omitempty"` Summary string `json:"summary,omitempty"` Changes string `json:"changes"` Conflicts []domain.RevertConflict `json:"conflicts"` Note string `json:"note,omitempty"` } func (a *adapter) undoChange(ctx context.Context, args struct { ChangeSetID int64 `json:"changeSetId" description:"the change to undo — its id from read_history"` }) (any, error) { if args.ChangeSetID == 0 { return nil, fmt.Errorf("%w: changeSetId is required — find the change in read_history first", domain.ErrInvalidInput) } cs, conflicts, err := a.svc.RevertChangeSet(ctx, a.actor, args.ChangeSetID, domain.SourceAgent) if err != nil { return nil, err } res := undoResult{UndoneID: args.ChangeSetID, Conflicts: conflicts} if cs == nil { // Nothing applied: every revision was a conflict, or the set was empty. res.Changes = "nothing" res.Note = "Nothing was reverted — everything that change touched has been edited since, or there was nothing left to undo." return res, nil } res.ChangeSet = &cs.ID res.Summary = cs.Summary res.Changes = describeCounts(cs.Counts) if len(conflicts) > 0 { res.Note = "Part of the change was left alone because it had been edited since; see conflicts." } a.mu.Lock() a.reverts = append(a.reverts, cs.ID) a.mu.Unlock() return res, nil } // historyEntry is one change set as read_history reports it: the row a person // would read in the History panel, not the revision snapshots behind it. type historyEntry struct { ID int64 `json:"id"` When string `json:"when"` Source string `json:"source"` Who string `json:"who,omitempty"` Summary string `json:"summary"` Changes string `json:"changes"` Undone bool `json:"undone,omitempty"` // UndoOf is the earlier entry this one reverted, when it is itself an undo. UndoOf *int64 `json:"undoOf,omitempty"` } func (a *adapter) readHistory(ctx context.Context, args struct { GardenID int64 `json:"gardenId" description:"garden whose history to read"` Limit int `json:"limit" description:"how many of the newest entries to return (default 20, max 100)"` Offset int `json:"offset" description:"how many entries to skip; pass the count you've already seen to page when hasMore is true"` }) (any, error) { limit := args.Limit if limit <= 0 { limit = 20 } sets, hasMore, err := a.svc.GardenHistory(ctx, a.actor, args.GardenID, limit, args.Offset) if err != nil { return nil, err } entries := make([]historyEntry, 0, len(sets)) for _, cs := range sets { entries = append(entries, historyEntry{ ID: cs.ID, When: cs.CreatedAt, Source: cs.Source, Who: cs.ActorName, Summary: cs.Summary, Changes: describeCounts(cs.Counts), Undone: cs.RevertedByID != nil, UndoOf: cs.RevertsID, }) } return map[string]any{"entries": entries, "hasMore": hasMore}, nil } // describeCounts turns a change set's tallies into words: "12 plantings created, // 1 object updated". func describeCounts(counts []domain.ChangeCount) string { parts := make([]string, 0, len(counts)) for _, c := range counts { noun := c.EntityType if c.N != 1 { noun += "s" } parts = append(parts, fmt.Sprintf("%d %s %sd", c.N, noun, c.Op)) } if len(parts) == 0 { return "nothing" } return strings.Join(parts, ", ") } func (a *adapter) updateObject(ctx context.Context, args struct { ObjectID int64 `json:"objectId" description:"object to change"` Version int64 `json:"version" description:"the object's current version (from describe_garden)"` Name *string `json:"name" description:"optional new label"` WidthCM *float64 `json:"widthCm" description:"optional new width in cm (a circle's diameter)"` HeightCM *float64 `json:"heightCm" description:"optional new height in cm"` RotationDeg *float64 `json:"rotationDeg" description:"optional new rotation in degrees"` Plantable *bool `json:"plantable" description:"optional: whether the object can hold plants"` }) (any, error) { return a.svc.UpdateObject(ctx, a.actor, args.ObjectID, service.ObjectPatch{ Name: args.Name, WidthCM: args.WidthCM, HeightCM: args.HeightCM, RotationDeg: args.RotationDeg, Plantable: args.Plantable, }, args.Version) } func (a *adapter) deleteObject(ctx context.Context, args struct { ObjectID int64 `json:"objectId" description:"object to delete (with its plantings)"` }) (any, error) { if err := a.svc.DeleteObject(ctx, a.actor, args.ObjectID); err != nil { return nil, err } return map[string]any{"deleted": args.ObjectID}, nil } func (a *adapter) removePlanting(ctx context.Context, args struct { PlantingID int64 `json:"plantingId" description:"plop to remove (its id from describe_garden)"` Version int64 `json:"version" description:"the plop's current version (from describe_garden)"` RemovedAt string `json:"removedAt" description:"optional date it came out, YYYY-MM-DD; defaults to today"` }) (any, error) { // Soft-remove via the service, dated the day the gardener said, else their // local day like every other tool here (the service clock's UTC day when // that isn't known). on, err := a.day(args.RemovedAt) if err != nil { return nil, err } return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version, on) } func (a *adapter) listSeedLots(ctx context.Context, args struct { PlantID *int64 `json:"plantId" description:"optional: only lots for this plant"` }) (any, error) { return a.svc.ListSeedLots(ctx, a.actor, args.PlantID) } func (a *adapter) recordSeedLot(ctx context.Context, args struct { PlantID int64 `json:"plantId" description:"plant the seed is for (from find_plant); must be the user's own or a built-in"` Quantity float64 `json:"quantity" description:"how much was bought, in the given unit"` Unit string `json:"unit" description:"what quantity counts, e.g. packets | seeds | grams"` Vendor string `json:"vendor" description:"optional vendor name"` SourceURL string `json:"sourceUrl" description:"optional http(s) link to where it was bought"` PackedForYear *int `json:"packedForYear" description:"optional 'packed for' year from the packet"` Notes string `json:"notes" description:"optional free-text notes"` }) (any, error) { return a.svc.CreateSeedLot(ctx, a.actor, service.SeedLotInput{ PlantID: args.PlantID, Quantity: args.Quantity, Unit: args.Unit, Vendor: args.Vendor, SourceURL: args.SourceURL, PackedForYear: args.PackedForYear, Notes: args.Notes, }) } func (a *adapter) copyGarden(ctx context.Context, args struct { GardenID int64 `json:"gardenId" description:"garden to duplicate (the user must own it)"` Name string `json:"name" description:"name for the copy; \"\" makes it that garden's plan for the year"` }) (any, error) { return a.svc.CopyGarden(ctx, a.actor, args.GardenID, args.Name) } func (a *adapter) updateGarden(ctx context.Context, args struct { GardenID int64 `json:"gardenId" description:"garden to change (the user must own it)"` Version int64 `json:"version" description:"the garden's current version (from describe_garden)"` Name *string `json:"name" description:"optional new name"` WidthCM *float64 `json:"widthCm" description:"optional new width in cm"` HeightCM *float64 `json:"heightCm" description:"optional new height in cm"` UnitPref *string `json:"units" description:"optional: metric | imperial — how the gardener wants lengths shown"` Notes *string `json:"notes" description:"optional replacement for the WHOLE notes text (merge the current notes in yourself); empty clears them"` GridSizeCM *float64 `json:"gridSizeCm" description:"optional grid spacing for the editor, in cm"` SnapToGrid *bool `json:"snapToGrid" description:"optional: whether objects snap to that grid"` }) (any, error) { if args.Name == nil && args.WidthCM == nil && args.HeightCM == nil && args.UnitPref == nil && args.Notes == nil && args.GridSizeCM == nil && args.SnapToGrid == nil { return nil, fmt.Errorf("%w: say what to change about the garden", domain.ErrInvalidInput) } // UpdateGarden takes the whole row, so start from the current one and // overlay what the model passed — the same merge the editor's settings // dialog does, and the only way a one-field change leaves the rest alone. g, err := a.svc.GetGarden(ctx, a.actor, args.GardenID) if err != nil { return nil, err } in := service.GardenInput{ Name: g.Name, WidthCM: g.WidthCM, HeightCM: g.HeightCM, UnitPref: g.UnitPref, Notes: g.Notes, GridSizeCM: g.GridSizeCM, SnapToGrid: g.SnapToGrid, } if args.Name != nil { in.Name = *args.Name } if args.WidthCM != nil { in.WidthCM = *args.WidthCM } if args.HeightCM != nil { in.HeightCM = *args.HeightCM } if args.UnitPref != nil { in.UnitPref = strings.ToLower(strings.TrimSpace(*args.UnitPref)) } if args.Notes != nil { in.Notes = *args.Notes } if args.GridSizeCM != nil { in.GridSizeCM = *args.GridSizeCM } if args.SnapToGrid != nil { in.SnapToGrid = *args.SnapToGrid } return a.svc.UpdateGarden(ctx, a.actor, args.GardenID, in, args.Version) } func (a *adapter) createGarden(ctx context.Context, args struct { Name string `json:"name" description:"the garden's name"` WidthCM float64 `json:"widthCm" description:"optional width in cm (default 1000)"` HeightCM float64 `json:"heightCm" description:"optional height in cm (default 1000)"` UnitPref string `json:"units" description:"optional: metric (default) | imperial — how the gardener wants lengths shown"` Notes string `json:"notes" description:"optional standing notes about the place — zone, frost dates, soil"` GridSizeCM float64 `json:"gridSizeCm" description:"optional editor grid spacing in cm (default 100)"` SnapToGrid bool `json:"snapToGrid" description:"optional: snap objects to that grid"` }) (any, error) { return a.svc.CreateGarden(ctx, a.actor, service.GardenInput{ Name: args.Name, WidthCM: args.WidthCM, HeightCM: args.HeightCM, UnitPref: strings.ToLower(strings.TrimSpace(args.UnitPref)), Notes: args.Notes, GridSizeCM: args.GridSizeCM, SnapToGrid: args.SnapToGrid, }) } func (a *adapter) updateSeedLot(ctx context.Context, args struct { LotID int64 `json:"lotId" description:"seed lot to correct (its id from list_seed_lots)"` Version int64 `json:"version" description:"the lot's current version (from list_seed_lots)"` Quantity *float64 `json:"quantity" description:"optional corrected quantity, in the lot's unit"` Unit *string `json:"unit" description:"optional unit: seeds | grams | ounces | packets | bulbs | plants"` Vendor *string `json:"vendor" description:"optional vendor name"` SourceURL *string `json:"sourceUrl" description:"optional http(s) link to where it was bought; empty clears it"` PurchasedAt *string `json:"purchasedAt" description:"optional purchase date, YYYY-MM-DD"` PackedForYear *int `json:"packedForYear" description:"optional 'packed for' year from the packet"` GerminationPct *float64 `json:"germinationPct" description:"optional germination rate, 0–100"` Notes *string `json:"notes" description:"optional replacement notes"` }) (any, error) { if args.Quantity == nil && args.Unit == nil && args.Vendor == nil && args.SourceURL == nil && args.PurchasedAt == nil && args.PackedForYear == nil && args.GerminationPct == nil && args.Notes == nil { return nil, fmt.Errorf("%w: say what to change about the lot", domain.ErrInvalidInput) } purchased := args.PurchasedAt if purchased != nil { on, err := parseDay(*purchased) if err != nil { return nil, err } purchased = &on } patch := service.SeedLotPatch{ Vendor: args.Vendor, SourceURL: args.SourceURL, Quantity: args.Quantity, Unit: args.Unit, Notes: args.Notes, SetPurchasedAt: purchased != nil, PurchasedAt: purchased, SetPackedForYear: args.PackedForYear != nil, PackedForYear: args.PackedForYear, SetGerminationPct: args.GerminationPct != nil, GerminationPct: args.GerminationPct, } return a.svc.UpdateSeedLot(ctx, a.actor, args.LotID, patch, args.Version) } func (a *adapter) deleteSeedLot(ctx context.Context, args struct { LotID int64 `json:"lotId" description:"seed lot to delete (its id from list_seed_lots)"` }) (any, error) { if err := a.svc.DeleteSeedLot(ctx, a.actor, args.LotID); err != nil { return nil, err } return map[string]any{"deleted": args.LotID}, nil } func (a *adapter) deletePlant(ctx context.Context, args struct { PlantID int64 `json:"plantId" description:"plant to delete from the user's catalog (from find_plant)"` }) (any, error) { err := a.svc.DeletePlant(ctx, a.actor, args.PlantID) if errors.Is(err, domain.ErrPlantInUse) { // The sentinel's text is for a log line; the model needs to know what // to tell the person, and what not to do about it. return nil, fmt.Errorf("%w: the plant is still used — by plantings (past seasons count) or a seed lot — so it stays; tell the user rather than removing those", domain.ErrPlantInUse) } if err != nil { return nil, err } return map[string]any{"deleted": args.PlantID}, nil } func (a *adapter) deletePlanting(ctx context.Context, args struct { PlantingID int64 `json:"plantingId" description:"plop to delete outright (its id from describe_garden or list_plantings)"` }) (any, error) { if err := a.svc.DeletePlanting(ctx, a.actor, args.PlantingID); err != nil { return nil, err } return map[string]any{"deleted": args.PlantingID}, nil } // errUnconfirmed is the refusal every outward-facing tool gives without // confirmed=true: the action is named so the model can ask about it precisely. func errUnconfirmed(action string) error { return fmt.Errorf("%w: not done — %s is outward-facing, so say exactly what you would do and ask the user first; pass confirmed=true only once they have said yes", domain.ErrInvalidInput, action) } // shareView is one share as the tools report it, with the link state alongside // in list_shares. type shareView struct { UserID int64 `json:"userId"` Email string `json:"email"` DisplayName string `json:"displayName,omitempty"` Role string `json:"role"` } func (a *adapter) listShares(ctx context.Context, args struct { GardenID int64 `json:"gardenId" description:"garden whose shares to list (the user must own it)"` }) (any, error) { shares, err := a.svc.ListShares(ctx, a.actor, args.GardenID) if err != nil { return nil, err } out := make([]shareView, 0, len(shares)) for i := range shares { out = append(out, toShareView(&shares[i])) } link, err := a.svc.GetPublicShareLink(ctx, a.actor, args.GardenID) if err != nil { return nil, err } return map[string]any{"shares": out, "publicLink": a.linkOf(link)}, nil } // linkView is a public link as the tools report it: on/off, and the address // when on. The token itself is only ever shown as part of that address. type linkView struct { Enabled bool `json:"enabled"` URL string `json:"url,omitempty"` } func (a *adapter) linkOf(link *service.PublicShareLink) linkView { v := linkView{Enabled: link.Enabled} if link.Enabled { v.URL = a.svc.PublicShareURL(link.Token) } return v } func toShareView(sh *domain.ShareWithUser) shareView { return shareView{UserID: sh.UserID, Email: sh.Email, DisplayName: sh.DisplayName, Role: sh.Role} } func (a *adapter) shareGarden(ctx context.Context, args struct { GardenID int64 `json:"gardenId" description:"garden to share (the user must own it)"` Email string `json:"email" description:"the other person's pansy account email, exactly as the user gave it"` Role string `json:"role" enum:"viewer,editor" description:"viewer can look; editor can change the garden"` Confirmed bool `json:"confirmed" description:"true only after the user has said yes to this share in this conversation"` }) (any, error) { email := strings.TrimSpace(args.Email) role := strings.ToLower(strings.TrimSpace(args.Role)) if email == "" || role == "" { return nil, fmt.Errorf("%w: an email and a role (viewer or editor) are required", domain.ErrInvalidInput) } if !args.Confirmed { return nil, errUnconfirmed(fmt.Sprintf("sharing this garden with %s as %s", email, role)) } _, err := a.svc.AddShare(ctx, a.actor, args.GardenID, email, role) switch { case errors.Is(err, domain.ErrShareUserNotFound): return nil, fmt.Errorf("%w — they need to sign in to pansy once before a garden can be shared with them", err) case errors.Is(err, domain.ErrShareExists): // Already shared: "share it with them as an editor" means change the role. existing, ferr := a.findShare(ctx, args.GardenID, email) if ferr != nil { return nil, ferr } if existing.Role == role { return map[string]any{"share": toShareView(existing), "note": "already shared with them at that role; nothing changed"}, nil } if _, err := a.svc.UpdateShareRole(ctx, a.actor, args.GardenID, existing.UserID, role); err != nil { return nil, err } existing.Role = role return map[string]any{"share": toShareView(existing), "note": "they already had access; their role is now " + role}, nil case err != nil: return nil, err } // Read the share back so every path reports the same shape, name included. created, err := a.findShare(ctx, args.GardenID, email) if err != nil { return nil, err } return map[string]any{"share": toShareView(created)}, nil } // findShare resolves an email to the garden's share for it, case-insensitively // — the model has the person's address from the conversation, not their id. func (a *adapter) findShare(ctx context.Context, gardenID int64, email string) (*domain.ShareWithUser, error) { shares, err := a.svc.ListShares(ctx, a.actor, gardenID) if err != nil { return nil, err } for i := range shares { if strings.EqualFold(strings.TrimSpace(shares[i].Email), strings.TrimSpace(email)) { return &shares[i], nil } } return nil, fmt.Errorf("%w: this garden is not shared with %s (list_shares shows who it is shared with)", domain.ErrInvalidInput, email) } func (a *adapter) removeShare(ctx context.Context, args struct { GardenID int64 `json:"gardenId" description:"garden to stop sharing (the user must own it)"` Email string `json:"email" description:"the person's email, as list_shares reports it"` Confirmed bool `json:"confirmed" description:"true only after the user has said yes to removing this person in this conversation"` }) (any, error) { email := strings.TrimSpace(args.Email) if email == "" { return nil, fmt.Errorf("%w: say whose access to remove, by email", domain.ErrInvalidInput) } if !args.Confirmed { return nil, errUnconfirmed("removing " + email + " from this garden") } share, err := a.findShare(ctx, args.GardenID, email) if err != nil { return nil, err } if err := a.svc.RemoveShare(ctx, a.actor, args.GardenID, share.UserID); err != nil { return nil, err } return map[string]any{"removed": toShareView(share)}, nil } func (a *adapter) publicLink(ctx context.Context, args struct { GardenID int64 `json:"gardenId" description:"garden whose public link this is about (the user must own it)"` Action string `json:"action" enum:"get,enable,rotate,disable" description:"get reports it; enable turns it on; rotate replaces it so the old link stops working; disable turns it off"` Confirmed bool `json:"confirmed" description:"true only after the user has said yes to enabling, rotating or disabling the link in this conversation; not needed for get"` }) (any, error) { action := strings.ToLower(strings.TrimSpace(args.Action)) if action == "" { action = "get" } // What each outward-facing action does, in the words the model should ask // with. Checked before the confirmation gate, so an unknown action is told // it is unknown rather than asked to confirm nothing in particular. asks := map[string]string{ "get": "", "enable": "turning the public link on, so anyone with the link can see this garden", "rotate": "rotating the public link, so the old link stops working", "disable": "turning the public link off, so the link stops working", } ask, known := asks[action] if !known { return nil, fmt.Errorf("%w: action must be get, enable, rotate or disable", domain.ErrInvalidInput) } if ask != "" && !args.Confirmed { return nil, errUnconfirmed(ask) } var ( link *service.PublicShareLink err error ) switch action { case "get": link, err = a.svc.GetPublicShareLink(ctx, a.actor, args.GardenID) case "enable": link, err = a.svc.EnablePublicShareLink(ctx, a.actor, args.GardenID, false) case "rotate": link, err = a.svc.EnablePublicShareLink(ctx, a.actor, args.GardenID, true) default: // disable if err = a.svc.DisablePublicShareLink(ctx, a.actor, args.GardenID); err == nil { link = &service.PublicShareLink{Enabled: false} } } if err != nil { return nil, err } return a.linkOf(link), nil }