// Package ai is the Advisor port: it turns a user's free-text intent into a // structured commitment Proposal by shelling out to an LLM CLI. It imports // nothing from the rest of the app, so it stays a leaf package. package ai import ( "encoding/json" "errors" "fmt" "strings" ) // Proposal is the coach's structured suggestion for a commitment. It is not a // domain.Commitment: the AI does not mint IDs, timestamps, or state. type Proposal struct { NextAction string SuccessCondition string TimeboxSecs int64 AllowedWindowClasses []string } var ( ErrEmptyResponse = errors.New("ai: empty response") ErrNoJSON = errors.New("ai: no JSON object in response") ErrInvalidProposal = errors.New("ai: invalid proposal") ) // extractJSON returns the first balanced top-level JSON object in s, tolerating // leading/trailing prose or code fences around it. func extractJSON(s string) (string, error) { start := strings.IndexByte(s, '{') if start < 0 { return "", ErrNoJSON } depth, inStr, esc := 0, false, false for i := start; i < len(s); i++ { ch := s[i] if inStr { switch { case esc: esc = false case ch == '\\': esc = true case ch == '"': inStr = false } continue } switch ch { case '"': inStr = true case '{': depth++ case '}': depth-- if depth == 0 { return s[start : i+1], nil } } } return "", ErrNoJSON } type rawProposal struct { NextAction string `json:"next_action"` SuccessCondition string `json:"success_condition"` TimeboxMinutes int64 `json:"timebox_minutes"` AllowedWindowClasses []string `json:"allowed_window_classes"` } // parseProposal extracts and validates a Proposal from raw CLI output. func parseProposal(s string) (Proposal, error) { if strings.TrimSpace(s) == "" { return Proposal{}, ErrEmptyResponse } // If there is no opening brace at all, report ErrNoJSON. // If there is a brace but it is unbalanced or otherwise malformed, // report ErrInvalidProposal so callers can distinguish "no attempt" // from "bad attempt". if strings.IndexByte(s, '{') < 0 { return Proposal{}, ErrNoJSON } jsonStr, err := extractJSON(s) if err != nil { return Proposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err) } var raw rawProposal if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { return Proposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err) } na := strings.TrimSpace(raw.NextAction) sc := strings.TrimSpace(raw.SuccessCondition) if na == "" || sc == "" || raw.TimeboxMinutes <= 0 { return Proposal{}, ErrInvalidProposal } classes := make([]string, 0, len(raw.AllowedWindowClasses)) for _, cls := range raw.AllowedWindowClasses { if t := strings.TrimSpace(cls); t != "" { classes = append(classes, t) } } return Proposal{ NextAction: na, SuccessCondition: sc, TimeboxSecs: raw.TimeboxMinutes * 60, AllowedWindowClasses: classes, }, nil }