package ai import ( "context" "encoding/json" "fmt" "strings" ) // OffscreenProposal is the brain's suggestion for one worthwhile thing to do // away from the screen. It is deliberately distinct from Proposal (the focus // coach's commitment shape) to avoid a name collision. type OffscreenProposal struct { NextAction string `json:"next_action"` Rationale string `json:"rationale"` } // Proposer turns an off-screen brief (goals, life-domain notes, today's tasks) // into a single grounded off-screen action. type Proposer interface { Propose(ctx context.Context, brief string) (OffscreenProposal, error) } // Propose makes Service satisfy Proposer over the same backend as Coach. func (s *Service) Propose(ctx context.Context, brief string) (OffscreenProposal, error) { out, err := s.backend.Run(ctx, buildProposePrompt(brief)) if err != nil { return OffscreenProposal{}, err } return parseOffscreenProposal(out) } func buildProposePrompt(brief string) string { return `You are an off-screen advisor. Read the brief below — the user's goals, life-domain notes, and today's tasks — and pick ONE worthwhile thing for them to do now, away from the screen. Respond with ONLY a JSON object, no prose and no code fences, exactly this shape: {"next_action": "", "rationale": ""} Rules: - next_action: a single concrete off-screen action, doable now. - rationale: one short sentence naming the goal or value it serves. ## Brief ` + brief } // parseOffscreenProposal extracts and validates an OffscreenProposal from raw // CLI output, mirroring parseProposal's error discipline. func parseOffscreenProposal(s string) (OffscreenProposal, error) { if strings.TrimSpace(s) == "" { return OffscreenProposal{}, ErrEmptyResponse } if strings.IndexByte(s, '{') < 0 { return OffscreenProposal{}, ErrNoJSON } jsonStr, err := extractJSON(s) if err != nil { return OffscreenProposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err) } var raw OffscreenProposal if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { return OffscreenProposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err) } na := strings.TrimSpace(raw.NextAction) if na == "" { return OffscreenProposal{}, ErrInvalidProposal } return OffscreenProposal{ NextAction: na, Rationale: strings.TrimSpace(raw.Rationale), }, nil }