// Package offscreen is the away-from-the-desk mode: it proposes one worthwhile // off-screen action from the life-domain frame and files it on confirm. It is // one-shot — after confirm or dismiss it goes inactive and the harness releases // it. package offscreen import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "log" "sync" "time" "keel/internal/ai" "keel/internal/harness" "keel/internal/knowledge" "keel/internal/memory" "keel/internal/mode" "keel/internal/tasks" ) const proposeTimeout = 60 * time.Second const ( statusPending = "pending" statusProposed = "proposed" statusError = "error" statusDone = "done" ) const ( kindProposalMade = "proposal_made" kindActionTaken = "action_taken" kindProposalDismissed = "proposal_dismissed" ) // Mode is the one-shot off-screen advisor: collect → propose → confirm/dismiss. type Mode struct { mu sync.Mutex async harness.Async ai *ai.Service tasks tasks.Provider know knowledge.Source // may be nil; assembleBrief must guard mem memory.Store // may be nil; record helpers guard now func() time.Time // event timestamps; never nil after New status string gen int proposal *ai.OffscreenProposal errMsg string done bool proposalID string } var _ mode.Mode = (*Mode)(nil) // New builds an off-screen mode from the shared services. It does not start the // brain; call Command(ctx, "start", nil) (or use Factory) to kick off a brief. func New(svc harness.Services) *Mode { now := svc.Clock if now == nil { now = time.Now } m := &Mode{ ai: svc.AI, tasks: svc.Tasks, know: svc.Knowledge, mem: svc.Memory, now: now, status: statusPending, } m.async = harness.NewAsync(&m.mu, svc.Notify) return m } func (m *Mode) Kind() string { return "offscreen" } // Active reports whether the mode still holds the harness. It goes false after // confirm or dismiss. func (m *Mode) Active() bool { m.mu.Lock() defer m.mu.Unlock() return !m.done } // Command routes a surface command. start (re)runs the brief; confirm files the // proposed action and finishes; dismiss finishes without writing. func (m *Mode) Command(ctx context.Context, name string, _ json.RawMessage) error { switch name { case "start": return m.start() case "confirm": return m.confirm(ctx) case "dismiss": m.mu.Lock() id := m.proposalID m.done = true m.mu.Unlock() m.recordSync(memory.Event{ Kind: kindProposalDismissed, At: m.now(), Data: map[string]any{"proposal_id": id, "mode": "offscreen"}, }) return nil default: return fmt.Errorf("offscreen: unknown command %q", name) } } // start assembles the brief and kicks off the brain asynchronously. A new call // bumps the generation so a stale in-flight result is discarded. func (m *Mode) start() error { m.mu.Lock() m.gen++ gen := m.gen m.status = statusPending m.proposal = nil m.errMsg = "" m.mu.Unlock() brief := m.assembleBrief() var p ai.OffscreenProposal var err error m.async.Run(proposeTimeout, func(ctx context.Context) { p, err = m.ai.Propose(ctx, brief) }, func() bool { return gen != m.gen }, func() { if err != nil { m.status = statusError m.errMsg = err.Error() return } m.status = statusProposed m.proposal = &p id := newProposalID() m.proposalID = id m.recordAsync(memory.Event{ Kind: kindProposalMade, At: m.now(), Data: map[string]any{ "proposal_id": id, "mode": "offscreen", "next_action": p.NextAction, "rationale": p.Rationale, }, }) }) return nil } // confirm files the proposed action as a task and ends the mode. It errors if // there is nothing to confirm yet. func (m *Mode) confirm(ctx context.Context) error { m.mu.Lock() p := m.proposal m.mu.Unlock() if p == nil { return errors.New("offscreen: nothing to confirm") } if err := m.tasks.Create(ctx, tasks.NewTask(p.NextAction)); err != nil { return err } m.mu.Lock() id := m.proposalID m.status = statusDone m.done = true m.mu.Unlock() m.recordSync(memory.Event{ Kind: kindActionTaken, At: m.now(), Data: map[string]any{"proposal_id": id, "mode": "offscreen", "next_action": p.NextAction}, }) return nil } // View projects the mode's state for surfaces. Always JSON-marshalable. func (m *Mode) View() any { m.mu.Lock() defer m.mu.Unlock() v := map[string]any{"status": m.status} if m.proposal != nil { v["next_action"] = m.proposal.NextAction v["rationale"] = m.proposal.Rationale } if m.errMsg != "" { v["error"] = m.errMsg } return v } // Factory builds a fresh off-screen mode and kicks off the brief immediately. func Factory(svc harness.Services) mode.Mode { m := New(svc) _ = m.start() return m } // recordAsync persists an event off the mode's lock, best-effort. Used on the // propose path, which runs under the mode mutex (async apply). func (m *Mode) recordAsync(ev memory.Event) { if m.mem == nil { return } go func() { if err := m.mem.Record(context.Background(), ev); err != nil { log.Printf("offscreen: memory record %s: %v", ev.Kind, err) } }() } // recordSync persists an event inline, best-effort. Used on confirm/dismiss, // which already do synchronous work. func (m *Mode) recordSync(ev memory.Event) { if m.mem == nil { return } if err := m.mem.Record(context.Background(), ev); err != nil { log.Printf("offscreen: memory record %s: %v", ev.Kind, err) } } // newProposalID returns a short random hex id correlating a proposal with its // outcome event. func newProposalID() string { var b [8]byte if _, err := rand.Read(b[:]); err != nil { return "proposal" } return hex.EncodeToString(b[:]) }