0f1790c8d5
The ai.Coach interface gains a grounding parameter carrying standing context about the user (the knowledge port). buildPrompt injects an "About the user" block only when grounding is non-empty, so an empty grounding produces a byte-for-byte unchanged prompt. Drift judge and nudge are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
81 lines
3.2 KiB
Go
81 lines
3.2 KiB
Go
package ai
|
|
|
|
import "context"
|
|
|
|
// Coach turns a free-text intent into a validated Proposal. grounding is
|
|
// optional standing context about the user (the knowledge port); "" means none.
|
|
type Coach interface {
|
|
Coach(ctx context.Context, intent, grounding string) (Proposal, error)
|
|
}
|
|
|
|
// Backend is one way to reach an LLM CLI. Adapters differ only in the command
|
|
// and arguments they run; each returns the model's text answer.
|
|
type Backend interface {
|
|
Run(ctx context.Context, prompt string) (string, error)
|
|
Name() string
|
|
}
|
|
|
|
// Service implements Coach over any Backend.
|
|
type Service struct {
|
|
backend Backend
|
|
}
|
|
|
|
func NewService(b Backend) *Service { return &Service{backend: b} }
|
|
|
|
func (s *Service) Coach(ctx context.Context, intent, grounding string) (Proposal, error) {
|
|
out, err := s.backend.Run(ctx, buildPrompt(intent, grounding))
|
|
if err != nil {
|
|
return Proposal{}, err
|
|
}
|
|
return parseProposal(out)
|
|
}
|
|
|
|
func buildPrompt(intent, grounding string) string {
|
|
preamble := `You are a focus coach. The user gives a rough intent for a work session. Turn it into ONE concrete, actionable commitment.
|
|
|
|
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
|
{"next_action": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>, "allowed_window_classes": ["<app/window class that is on-task, e.g. code, firefox>"]}
|
|
|
|
Rules:
|
|
- next_action: a single concrete imperative action, doable now.
|
|
- success_condition: observable and verifiable; how you'd know it is done.
|
|
- timebox_minutes: a realistic integer number of minutes for the action.
|
|
- allowed_window_classes: a short list of window/application class names that count as on-task for this action (lowercase, e.g. "code", "firefox"). May be empty.`
|
|
|
|
about := ""
|
|
if grounding != "" {
|
|
about = "\n\n## About the user\n" + grounding + "\nUse this standing context to make the commitment fit who they are and what matters to them."
|
|
}
|
|
return preamble + about + "\n\nUser intent: " + intent
|
|
}
|
|
|
|
// DriftJudge decides whether the current window is on-task for a commitment.
|
|
// It takes primitives, not domain/evidence types, so ai stays a leaf package.
|
|
type DriftJudge interface {
|
|
JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error)
|
|
}
|
|
|
|
// JudgeDrift makes Service satisfy DriftJudge over the same backend as Coach.
|
|
func (s *Service) JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error) {
|
|
out, err := s.backend.Run(ctx, buildDriftPrompt(commitment, windowClass, windowTitle))
|
|
if err != nil {
|
|
return Verdict{}, err
|
|
}
|
|
return parseVerdict(out)
|
|
}
|
|
|
|
func buildDriftPrompt(commitment, class, title string) string {
|
|
return `You are a focus monitor. The user committed to a task. Decide whether their CURRENT window is on-task or a distraction.
|
|
|
|
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
|
{"on_task": <true or false>, "reason": "<short explanation, one sentence>"}
|
|
|
|
Rules:
|
|
- on_task: true if the window plausibly serves the commitment, false if it is a distraction.
|
|
- reason: one short sentence. REQUIRED when on_task is false.
|
|
|
|
Commitment: ` + commitment + `
|
|
Current window class: ` + class + `
|
|
Current window title: ` + title
|
|
}
|