feat(offscreen): record proposal_made/action_taken/proposal_dismissed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 19:48:50 -04:00
parent 5bdb98c1a7
commit 16b44c9e90
2 changed files with 155 additions and 5 deletions
+82 -5
View File
@@ -6,15 +6,19 @@ 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"
)
@@ -28,6 +32,12 @@ const (
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
@@ -35,12 +45,15 @@ type Mode struct {
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
status string
gen int
proposal *ai.OffscreenProposal
errMsg string
done bool
proposalID string
}
var _ mode.Mode = (*Mode)(nil)
@@ -48,10 +61,16 @@ 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)
@@ -78,8 +97,14 @@ func (m *Mode) Command(ctx context.Context, name string, _ json.RawMessage) erro
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)
@@ -112,6 +137,18 @@ func (m *Mode) start() error {
}
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
}
@@ -129,9 +166,15 @@ func (m *Mode) confirm(ctx context.Context) error {
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
}
@@ -156,3 +199,37 @@ func Factory(svc harness.Services) mode.Mode {
_ = 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[:])
}