package rag import ( "context" "fmt" "strings" "time" "github.com/VictorVargas/rony-llm-agent/pkg/llm" ) // DefaultCapturePrompt is the summarization instruction EpisodeCapture uses // when Config doesn't provide one. Consumers localize it by passing their // own (e.g. the Rony harness passes a Spanish prompt). const DefaultCapturePrompt = "Summarize the following exchange between a user and an AI assistant " + "in 1-2 sentences, in the past tense, focusing on what was asked and what was done or answered. " + "Respond ONLY with the summary, no headers or extra commentary." // captureMaxInputChars bounds how much of the turn is sent to the // summarizing LLM. Auto-capture runs after every successful turn, so its // cost must stay small and constant โ€” the start of a long reply carries the // gist; the tail of a truncated one rarely changes the 1-2 sentence summary. const captureMaxInputChars = 6000 // EpisodeCapture implements Phase 2 ยง3.5 auto-capture: at the end of a // successful turn, an LLM (ideally a small/local one โ€” this runs on every // turn) condenses the exchange into a 1-2 sentence event and stores it as // episodic memory, so future sessions can recall "what happened" without the // user ever having asked to save anything. type EpisodeCapture struct { Memory Memory LLM llm.LLMClient ProjectID string // Prompt overrides DefaultCapturePrompt (e.g. for localization). Prompt string } // Capture summarizes one finished turn and stores it as an episodic // fragment. toolsUsed (may be empty) is recorded in metadata so a recalled // episode also says how the work was done. Callers typically run this in a // background goroutine with its own timeout โ€” a capture failure should never // block or break the turn that just finished. func (c *EpisodeCapture) Capture(ctx context.Context, userInput, assistantReply string, toolsUsed ...string) error { if c == nil || c.Memory == nil || c.LLM == nil { return fmt.Errorf("episode capture: memory and llm are required") } if strings.TrimSpace(userInput) == "" || strings.TrimSpace(assistantReply) == "" { return fmt.Errorf("episode capture: nothing to capture") } prompt := c.Prompt if prompt == "" { prompt = DefaultCapturePrompt } transcript := fmt.Sprintf("User: %s\n\nAssistant: %s", userInput, assistantReply) if len(transcript) > captureMaxInputChars { transcript = transcript[:captureMaxInputChars] } resp, err := c.LLM.Generate(ctx, llm.CompletionRequest{ Messages: []llm.Message{ {Role: llm.RoleSystem, Content: prompt}, {Role: llm.RoleUser, Content: transcript}, }, }) if err != nil { return fmt.Errorf("episode capture: summarize: %w", err) } summary := strings.TrimSpace(resp.Content) if summary == "" { return fmt.Errorf("episode capture: empty summary") } metadata := map[string]string{ "date": time.Now().Format("2006-01-02"), } if len(toolsUsed) > 0 { metadata["tools"] = strings.Join(toolsUsed, ",") } return c.Memory.Add(ctx, Fragment{ Content: summary, Type: MemoryEpisodic, ProjectID: c.ProjectID, Metadata: metadata, }) }