16b44c9e90
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
236 lines
5.6 KiB
Go
236 lines
5.6 KiB
Go
// 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[:])
|
|
}
|