1package cowork23import (4 "bytes"5 "context"6 "errors"7 "fmt"8 "path/filepath"9 "strings"10 "sync"1112 sdk "github.com/modelcontextprotocol/go-sdk/mcp"1314 "src.rest/cat/012/internal/diff"15 "src.rest/cat/012/internal/headless"16 "src.rest/cat/012/internal/mcp"17 "src.rest/cat/012/internal/room"18 "src.rest/cat/012/internal/sheet"19)2021// Agent is an attached agent's side of the room: its seat, and the22// mcp.Live its tools stand on. Every call is a turn of its seat, so its23// reads and changes take their place among the people's, and its steps24// are its own.25type Agent struct {26 seat *room.Seat27 book string // the workbook's name, for the tools2829 mu sync.Mutex30 notices []string // news of its suggestions, for its next write31}3233var _ mcp.Live = (*Agent)(nil)3435// NewAgent is the agent sitting at seat.36func NewAgent(seat *room.Seat) *Agent {37 a := &Agent{seat: seat}38 seat.Do(func(*sheet.Workbook) {39 name := seat.Saved().Name40 a.book = strings.TrimSuffix(filepath.Base(name), filepath.Ext(name))41 })42 if a.book == "" || a.book == "." {43 a.book = "Untitled"44 }45 return a46}4748// Name is the workbook's.49func (a *Agent) Name() string { return a.book }5051// Seat is the agent's seat.52func (a *Agent) Seat() *room.Seat { return a.seat }5354// View runs fn on the room's workbook in a turn.55func (a *Agent) View(_ context.Context, fn func(*sheet.Workbook) error) error {56 var err error57 a.seat.Do(func(w *sheet.Workbook) { err = fn(w) })58 return err59}6061// Scratch runs fn on a copy of the workbook taken in a turn.62func (a *Agent) Scratch(_ context.Context, fn func(*sheet.Workbook) error) error {63 var data bytes.Buffer64 var err error65 a.seat.Do(func(w *sheet.Workbook) { err = w.Write(&data) })66 if err != nil {67 return err68 }69 s, err := sheet.Read(&data)70 if err != nil {71 return err72 }73 return fn(s.Book())74}7576// Change is Make without a message.77func (a *Agent) Change(ctx context.Context, label string, dryRun bool, fn func(*sheet.Workbook) error) ([]diff.Change, error) {78 made, err := a.Make(ctx, mcp.Making{Label: label, DryRun: dryRun, Fn: fn})79 return made.Changes, err80}8182// Make proposes the change on a copy of the workbook, checks it against83// the scope, and then suggests it, or with direct edits allowed makes84// it as the agent's step; a formula asking JEV made directly asks the85// person first.86func (a *Agent) Make(ctx context.Context, mk mcp.Making) (mcp.Made, error) {87 var made mcp.Made88 var p *sheet.Proposal89 var err error90 needJEV := false91 a.seat.Do(func(w *sheet.Workbook) {92 b := BoardOf(a.seat)93 if !a.seat.Writing() {94 err = fmt.Errorf("%s writes here and the others follow: nobody else can change the workbook", a.seat.Writer())95 return96 }97 if p, err = sheet.Propose(w, mk.Label, mk.Fn); err != nil || p.Empty() {98 return99 }100 if err = b.Scope.Check(p); err != nil {101 return102 }103 if made.Changes, err = changesOf(w, p.Result()); err != nil || mk.DryRun {104 return105 }106 switch {107 case !b.Direct:108 made.Suggestion = b.Add(&Suggestion{Author: a.seat.ID(), Agent: a.seat.Name(), Color: a.seat.Color(), Message: mk.Message, Proposal: p})109 a.seat.Touch()110 case p.Remote && !b.Grants.JEV:111 needJEV = true112 return113 default:114 err = p.Apply(w, nil)115 made.Applied = err == nil116 }117 a.point(w, p)118 })119 if needJEV {120 err = a.grant(ctx, "jev", a.seat.Name()+" enters formulas that ask JEV, over the network, with your key")121 if err == nil {122 a.seat.Do(func(w *sheet.Workbook) {123 err = p.Apply(w, nil)124 made.Applied = err == nil125 a.point(w, p)126 })127 }128 }129 made.Notices = a.takeNotices()130 return made, err131}132133// changesOf is what the copy changes of w, as 012 diff lists it.134func changesOf(w, copy *sheet.Workbook) ([]diff.Change, error) {135 var before, after bytes.Buffer136 if err := w.Write(&before); err != nil {137 return nil, err138 }139 if err := copy.Write(&after); err != nil {140 return nil, err141 }142 x, err := diff.Read(before.Bytes())143 if err != nil {144 return nil, err145 }146 y, err := diff.Read(after.Bytes())147 if err != nil {148 return nil, err149 }150 return diff.Compare(x, y), nil151}152153// point moves the agent's pointer to the cells a proposal sets.154func (a *Agent) point(w *sheet.Workbook, p *sheet.Proposal) {155 if len(p.Cells) == 0 {156 return157 }158 first := p.Cells[0]159 s := w.Lookup(first.Sheet)160 if s == nil {161 return162 }163 r := sheet.Rect{From: first.At, To: first.At}164 for _, c := range p.Cells {165 if c.Sheet == first.Sheet {166 r.From.Row, r.From.Col = min(r.From.Row, c.At.Row), min(r.From.Col, c.At.Col)167 r.To.Row, r.To.Col = max(r.To.Row, c.At.Row), max(r.To.Col, c.At.Col)168 }169 }170 a.seat.Move(room.Presence{Sheet: s, Cursor: first.At, Selection: r})171}172173// Focus moves the agent's pointer to ref.174func (a *Agent) Focus(_ context.Context, ref string) (string, error) {175 var at string176 var err error177 a.seat.Do(func(w *sheet.Workbook) {178 var t headless.Target179 if t, err = headless.Resolve(w, ref); err != nil {180 return181 }182 scope := BoardOf(a.seat).Scope183 if !scope.Allows(t.Sheet, t.Range) {184 err = fmt.Errorf("%s is outside what the person let you work on (%s)", ref, scope)185 return186 }187 a.seat.Move(room.Presence{Sheet: t.Sheet, Cursor: t.Range.From, Selection: t.Range})188 at = sheet.Qualified(t.Sheet.Name(), t.Range)189 })190 return at, err191}192193// Undo takes back the agent's latest step.194func (a *Agent) Undo(context.Context) (string, error) {195 var label string196 var err error197 a.seat.Do(func(w *sheet.Workbook) {198 switch b, blocked := w.UndoBlocked(); {199 case !w.CanUndo():200 err = errors.New("you have no change of yours to undo: a suggestion is one once the person accepts it; the suggestions tool lists them")201 case blocked:202 who := "someone who left"203 if p, ok := a.seat.Peer(b.Author); ok {204 who = p.Name205 }206 err = fmt.Errorf("%s has changed what your latest change (%s) changed since: undo takes back only your own changes, never over theirs", who, w.UndoLabel())207 default:208 label = w.UndoLabel()209 w.Undo()210 }211 })212 return label, err213}214215// Ask puts the question on the board and waits for the person.216func (a *Agent) Ask(ctx context.Context, p *sdk.ElicitParams) (*sdk.ElicitResult, error) {217 q, err := NewAsk(p.Message, p.RequestedSchema)218 if err != nil {219 return nil, err220 }221 ans, err := a.wait(ctx, q)222 if err != nil {223 return nil, err224 }225 return &sdk.ElicitResult{Action: ans.Action, Content: ans.Content}, nil226}227228// grant asks leave for what, unless the person gave it for the session.229func (a *Agent) grant(ctx context.Context, what, message string) error {230 ans, err := a.wait(ctx, NewGrant(what, message))231 if err != nil {232 return err233 }234 if ans.Action != "accept" {235 return errors.New("the person didn't allow it")236 }237 return nil238}239240// wait asks q and waits for the answer, withdrawing the question when241// ctx ends first.242func (a *Agent) wait(ctx context.Context, q *Ask) (Answer, error) {243 q.Author, q.Agent, q.Color = a.seat.ID(), a.seat.Name(), a.seat.Color()244 a.seat.Do(func(*sheet.Workbook) {245 BoardOf(a.seat).AddAsk(q)246 a.seat.Touch()247 })248 select {249 case ans := <-q.Reply():250 return ans, nil251 case <-ctx.Done():252 a.seat.Do(func(*sheet.Workbook) {253 BoardOf(a.seat).Withdraw(q)254 a.seat.Touch()255 })256 return Answer{}, ctx.Err()257 }258}259260// Suggestions lists the agent's suggestions.261func (a *Agent) Suggestions(context.Context) ([]mcp.Suggestion, error) {262 var out []mcp.Suggestion263 a.seat.Do(func(*sheet.Workbook) {264 for _, s := range BoardOf(a.seat).Of(a.seat.ID()) {265 n := len(s.Cells)266 acc, rej := s.Count(CellAccepted), s.Count(CellRejected)267 if s.Whole() {268 acc, rej = acc*n, rej*n269 }270 out = append(out, mcp.Suggestion{ID: s.ID, Label: s.Label, Message: s.Message, State: s.Outcome(), Cells: n, Accepted: acc, Rejected: rej})271 }272 })273 return out, nil274}275276// Scope is the board's scope in words.277func (a *Agent) Scope() string {278 var s string279 a.seat.Do(func(*sheet.Workbook) { s = BoardOf(a.seat).Scope.String() })280 return s281}282283// RunCell is a request for the keeper's session to run a notebook cell,284// which it takes from the room (room.Seat.Post).285type RunCell struct {286 Notebook string287 Cell int // from 0288 Agent string289}290291// RunCell asks leave to run a notebook cell, unless the person gave it292// for the session, and has the room's keeper run it.293func (a *Agent) RunCell(ctx context.Context, notebook string, cell int) (string, error) {294 var err error295 granted := false296 a.seat.Do(func(w *sheet.Workbook) {297 b := BoardOf(a.seat)298 granted = b.Grants.Notebooks299 if b.Scope.Kind != Workbook {300 err = fmt.Errorf("running a notebook cell changes its outputs and the sheets they're sent to, outside what the person let you change (%s)", b.Scope)301 return302 }303 s := w.Lookup(notebook)304 switch {305 case s == nil || !s.IsNotebook():306 err = fmt.Errorf("there's no notebook tab named %s: describe lists them", notebook)307 case cell < 1 || cell > len(s.NotebookCells()):308 err = fmt.Errorf("%s has %d cells", notebook, len(s.NotebookCells()))309 }310 })311 if err != nil {312 return "", err313 }314 if !granted {315 if err := a.grant(ctx, "notebooks", fmt.Sprintf("%s asks to run cell %d of %s with nu, as you", a.seat.Name(), cell, notebook)); err != nil {316 return "", err317 }318 }319 a.seat.Post(RunCell{Notebook: notebook, Cell: cell - 1, Agent: a.seat.Name()})320 return fmt.Sprintf("cell %d of %s runs on the person's screen: its output arrives in the workbook as when they run it; read it with describe or read_range", cell, notebook), nil321}322323// report takes the news of the agent's settled suggestions, keeping it324// for its next write, and returns it.325func (a *Agent) report() []string {326 var news []string327 a.seat.Do(func(*sheet.Workbook) {328 for _, s := range BoardOf(a.seat).Resolved(a.seat.ID()) {329 news = append(news, s.Notice())330 }331 })332 if len(news) > 0 {333 a.mu.Lock()334 a.notices = append(a.notices, news...)335 a.mu.Unlock()336 }337 return news338}339340// takeNotices is the news kept since the agent last wrote.341func (a *Agent) takeNotices() []string {342 a.report()343 a.mu.Lock()344 defer a.mu.Unlock()345 n := a.notices346 a.notices = nil347 return n348}349350// Leave takes the agent's questions off the board and gives up its351// seat. Its suggestions stay for the person to settle.352func (a *Agent) Leave() {353 a.seat.Do(func(*sheet.Workbook) {354 b := BoardOf(a.seat)355 for _, q := range b.Asks() {356 if q.Author == a.seat.ID() {357 b.Withdraw(q)358 }359 }360 a.seat.Touch()361 })362 a.seat.Leave()363}