mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-21 19:25:57 +00:00
42fe61493c
Chat turns, compaction, and title calls now stamp session_id on their cost.UsageEvent, and the done event carries session_cost_usd summed from the operator ledger (cost.Store.SessionCostUSD). The drawer's last-turn summary appends '$0.12 session' with a sub-cent floor. The figure is omitted whenever any of the session's models has unknown pricing, and free local models price known-at-zero, so no figure is ever partial.
448 lines
14 KiB
Go
448 lines
14 KiB
Go
package chat
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/cost"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/modelboundary"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/safety"
|
|
)
|
|
|
|
const (
|
|
sessionCompactionKeepRecentUserTurns = 4
|
|
sessionCompactionMinimumMessages = 4
|
|
sessionCompactionTranscriptMaxChars = 52000
|
|
sessionCompactionEntryMaxChars = 2400
|
|
sessionCompactionToolOutputMaxChars = 1200
|
|
sessionCompactionSummaryMaxChars = 9000
|
|
sessionCompactionTimeout = 45 * time.Second
|
|
)
|
|
|
|
const sessionCompactionSystemPrompt = `You compact Pulse Assistant chat sessions for future turns.
|
|
|
|
Produce a concise handoff summary that preserves:
|
|
- The user's goal and current task.
|
|
- Named infrastructure resources, hosts, VMs, containers, services, alerts, and findings.
|
|
- Facts already established from tools or prior assistant answers.
|
|
- Actions taken, approvals requested, safety boundaries, and failed attempts.
|
|
- Open questions and caveats the next turn must remember.
|
|
|
|
Do not invent facts. Do not include hidden reasoning. Do not include raw secrets, tokens, private keys, or passwords. Keep it practical and suitable as context for the next Pulse Assistant turn.`
|
|
|
|
// SessionCompactionResult describes a durable session rewrite after compacting
|
|
// older turns into a model-produced handoff summary.
|
|
type SessionCompactionResult struct {
|
|
Success bool `json:"success"`
|
|
Status string `json:"status"`
|
|
Message string `json:"message,omitempty"`
|
|
SessionID string `json:"session_id"`
|
|
SummaryMessageID string `json:"summary_message_id,omitempty"`
|
|
OriginalMessageCount int `json:"original_message_count"`
|
|
CompactedMessageCount int `json:"compacted_message_count"`
|
|
CompactedMessages int `json:"compacted_messages"`
|
|
KeptRecentMessages int `json:"kept_recent_messages"`
|
|
SummaryChars int `json:"summary_chars"`
|
|
}
|
|
|
|
func (r SessionCompactionResult) asMap() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"success": r.Success,
|
|
"status": r.Status,
|
|
"message": r.Message,
|
|
"session_id": r.SessionID,
|
|
"summary_message_id": r.SummaryMessageID,
|
|
"original_message_count": r.OriginalMessageCount,
|
|
"compacted_message_count": r.CompactedMessageCount,
|
|
"compacted_messages": r.CompactedMessages,
|
|
"kept_recent_messages": r.KeptRecentMessages,
|
|
"summary_chars": r.SummaryChars,
|
|
}
|
|
}
|
|
|
|
// SummarizeSession compacts a durable chat session using the active chat model.
|
|
func (s *Service) SummarizeSession(ctx context.Context, sessionID string) (map[string]interface{}, error) {
|
|
normalizedSessionID := strings.TrimSpace(sessionID)
|
|
if normalizedSessionID == "" {
|
|
return nil, fmt.Errorf("session id required")
|
|
}
|
|
|
|
s.mu.RLock()
|
|
started := s.started
|
|
sessions := s.sessions
|
|
provider := s.provider
|
|
cfgSnapshot := s.cfg
|
|
costStore := s.costStore
|
|
unifiedResourceProvider := s.unifiedResourceProvider
|
|
s.mu.RUnlock()
|
|
|
|
if !started {
|
|
return nil, fmt.Errorf("service not started")
|
|
}
|
|
if sessions == nil {
|
|
return nil, fmt.Errorf("service not started")
|
|
}
|
|
if provider == nil {
|
|
return nil, fmt.Errorf("provider not available")
|
|
}
|
|
if len(s.getActiveLoops(normalizedSessionID)) > 0 {
|
|
return nil, fmt.Errorf("session is currently running; wait for the active response to finish or stop it before compacting")
|
|
}
|
|
|
|
messages, err := sessions.GetMessages(normalizedSessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(messages) < sessionCompactionMinimumMessages {
|
|
return SessionCompactionResult{
|
|
Success: false,
|
|
Status: "not_needed",
|
|
Message: "This Assistant session is already short.",
|
|
SessionID: normalizedSessionID,
|
|
OriginalMessageCount: len(messages),
|
|
CompactedMessageCount: len(messages),
|
|
}.asMap(), nil
|
|
}
|
|
|
|
transcript := buildSessionCompactionTranscript(messages)
|
|
if strings.TrimSpace(transcript) == "" {
|
|
return SessionCompactionResult{
|
|
Success: false,
|
|
Status: "empty",
|
|
Message: "There is no Assistant transcript to compact.",
|
|
SessionID: normalizedSessionID,
|
|
OriginalMessageCount: len(messages),
|
|
CompactedMessageCount: len(messages),
|
|
}.asMap(), nil
|
|
}
|
|
|
|
requestModel := ""
|
|
if cfgSnapshot != nil {
|
|
requestModel = strings.TrimSpace(cfgSnapshot.GetChatModel())
|
|
}
|
|
|
|
compactCtx, cancel := context.WithTimeout(ctx, sessionCompactionTimeout)
|
|
defer cancel()
|
|
|
|
compactionRequest := providers.ChatRequest{
|
|
System: sessionCompactionSystemPrompt,
|
|
ExecutionID: "session-compaction:" + normalizedSessionID,
|
|
MaxTokens: 1600,
|
|
Temperature: 0.2,
|
|
Messages: []providers.Message{{
|
|
Role: "user",
|
|
Content: "Compact this Pulse Assistant transcript into a handoff summary for the next turn.\n\n" +
|
|
transcript,
|
|
}},
|
|
}
|
|
|
|
// The transcript is built from PERSISTED messages (original user prompts and
|
|
// tool outputs), which carry raw resource identifiers. Run it through the same
|
|
// model-boundary sanitizer as a normal turn so compaction never leaks
|
|
// local-only identifiers or credentials to a cloud model (local Ollama is
|
|
// unaffected — RequestSanitizerForModel returns nil).
|
|
if sanitizer := modelboundary.RequestSanitizerForModel(requestModel, unifiedResourceProvider); sanitizer != nil {
|
|
compactionRequest = sanitizer(compactionRequest)
|
|
}
|
|
|
|
response, err := provider.Chat(compactCtx, compactionRequest)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("compact session: %w", err)
|
|
}
|
|
if costStore != nil && response != nil && (response.InputTokens != 0 || response.OutputTokens != 0) {
|
|
providerName := ""
|
|
if provider != nil {
|
|
providerName = provider.Name()
|
|
}
|
|
costStore.Record(cost.UsageEvent{
|
|
Timestamp: time.Now(),
|
|
Provider: providerName,
|
|
RequestModel: requestModel,
|
|
ResponseModel: strings.TrimSpace(response.Model),
|
|
UseCase: "chat",
|
|
InputTokens: response.InputTokens,
|
|
OutputTokens: response.OutputTokens,
|
|
TargetType: "assistant_session_compaction",
|
|
TargetID: normalizedSessionID,
|
|
SessionID: normalizedSessionID,
|
|
})
|
|
}
|
|
|
|
summary := normalizeSessionCompactionSummary(response.Content)
|
|
if summary == "" {
|
|
return nil, fmt.Errorf("compact session: model returned an empty summary")
|
|
}
|
|
|
|
responseModel := strings.TrimSpace(response.Model)
|
|
if responseModel == "" {
|
|
responseModel = requestModel
|
|
}
|
|
|
|
result, err := sessions.CompactWithSummary(
|
|
normalizedSessionID,
|
|
summary,
|
|
sessionCompactionKeepRecentUserTurns,
|
|
responseModel,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return result.asMap(), nil
|
|
}
|
|
|
|
// CompactWithSummary rewrites a session to a model-produced summary plus the
|
|
// most recent turns. The full transcript is intentionally not kept in the hot
|
|
// model path after compaction.
|
|
func (s *SessionStore) CompactWithSummary(
|
|
id string,
|
|
summary string,
|
|
keepRecentUserTurns int,
|
|
model string,
|
|
) (*SessionCompactionResult, error) {
|
|
if err := validateSessionID(id); err != nil {
|
|
return nil, err
|
|
}
|
|
summary = normalizeSessionCompactionSummary(summary)
|
|
if summary == "" {
|
|
return nil, fmt.Errorf("summary required")
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
data, err := s.readSession(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
originalCount := len(data.Messages)
|
|
recentMessages := selectRecentMessagesForSessionCompaction(data.Messages, keepRecentUserTurns)
|
|
now := time.Now()
|
|
summaryMessage := Message{
|
|
ID: uuid.New().String(),
|
|
Role: "assistant",
|
|
Content: buildCompactedSessionMessage(summary, originalCount, len(recentMessages)),
|
|
Model: strings.TrimSpace(model),
|
|
Timestamp: now,
|
|
}
|
|
|
|
nextMessages := make([]Message, 0, 1+len(recentMessages))
|
|
nextMessages = append(nextMessages, summaryMessage)
|
|
nextMessages = append(nextMessages, recentMessages...)
|
|
data.Messages = nextMessages
|
|
data.TurnRedoStack = nil
|
|
data.UpdatedAt = now
|
|
|
|
if err := s.writeSession(*data); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
compactedMessages := originalCount - len(recentMessages)
|
|
if compactedMessages < 0 {
|
|
compactedMessages = 0
|
|
}
|
|
return &SessionCompactionResult{
|
|
Success: true,
|
|
Status: "compacted",
|
|
Message: fmt.Sprintf("Compacted %d older messages into a session summary.", compactedMessages),
|
|
SessionID: data.ID,
|
|
SummaryMessageID: summaryMessage.ID,
|
|
OriginalMessageCount: originalCount,
|
|
CompactedMessageCount: len(data.Messages),
|
|
CompactedMessages: compactedMessages,
|
|
KeptRecentMessages: len(recentMessages),
|
|
SummaryChars: len([]rune(summary)),
|
|
}, nil
|
|
}
|
|
|
|
func selectRecentMessagesForSessionCompaction(messages []Message, keepUserTurns int) []Message {
|
|
if keepUserTurns <= 0 || len(messages) == 0 {
|
|
return nil
|
|
}
|
|
|
|
start := len(messages)
|
|
userTurns := 0
|
|
for index := len(messages) - 1; index >= 0; index-- {
|
|
msg := messages[index]
|
|
if msg.ToolResult != nil {
|
|
continue
|
|
}
|
|
if !strings.EqualFold(msg.Role, "user") {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(msg.Content) == "" {
|
|
continue
|
|
}
|
|
start = index
|
|
userTurns++
|
|
if userTurns >= keepUserTurns {
|
|
break
|
|
}
|
|
}
|
|
if start >= len(messages) {
|
|
return nil
|
|
}
|
|
|
|
recent := cloneSessionMessages(messages[start:])
|
|
for len(recent) > 0 && recent[0].ToolResult != nil {
|
|
recent = recent[1:]
|
|
}
|
|
return recent
|
|
}
|
|
|
|
func buildCompactedSessionMessage(summary string, originalMessageCount int, keptRecentMessages int) string {
|
|
return strings.TrimSpace(fmt.Sprintf(
|
|
"Session compacted. Older turns were summarized for context; the latest %d messages remain below.\n\n%s\n\nCompaction source: %d original messages.",
|
|
keptRecentMessages,
|
|
summary,
|
|
originalMessageCount,
|
|
))
|
|
}
|
|
|
|
func normalizeSessionCompactionSummary(summary string) string {
|
|
summary = strings.TrimSpace(summary)
|
|
summary, _ = safety.RedactSensitiveText(summary)
|
|
return truncateSessionCompactionText(summary, sessionCompactionSummaryMaxChars)
|
|
}
|
|
|
|
func buildSessionCompactionTranscript(messages []Message) string {
|
|
entries := make([]string, 0, len(messages))
|
|
for index, message := range messages {
|
|
if entry := formatSessionCompactionTranscriptEntry(index, message); entry != "" {
|
|
entries = append(entries, entry)
|
|
}
|
|
}
|
|
return fitSessionCompactionEntries(entries, sessionCompactionTranscriptMaxChars)
|
|
}
|
|
|
|
func formatSessionCompactionTranscriptEntry(index int, message Message) string {
|
|
var parts []string
|
|
role := strings.TrimSpace(message.Role)
|
|
if role == "" {
|
|
role = "message"
|
|
}
|
|
|
|
if message.ToolResult != nil {
|
|
status := "ok"
|
|
if message.ToolResult.IsError {
|
|
status = "error"
|
|
}
|
|
content := sanitizeSessionCompactionText(message.ToolResult.Content)
|
|
if content == "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf(
|
|
"[%03d] TOOL RESULT %s (%s)\n%s",
|
|
index+1,
|
|
strings.TrimSpace(message.ToolResult.ToolUseID),
|
|
status,
|
|
truncateSessionCompactionText(content, sessionCompactionToolOutputMaxChars),
|
|
)
|
|
}
|
|
|
|
content := sanitizeSessionCompactionText(message.Content)
|
|
if content != "" {
|
|
parts = append(parts, truncateSessionCompactionText(content, sessionCompactionEntryMaxChars))
|
|
}
|
|
|
|
for _, toolCall := range message.ToolCalls {
|
|
parts = append(parts, formatSessionCompactionToolCall(toolCall))
|
|
}
|
|
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("[%03d] %s\n%s", index+1, strings.ToUpper(role), strings.Join(parts, "\n"))
|
|
}
|
|
|
|
func formatSessionCompactionToolCall(toolCall ToolCall) string {
|
|
input := "{}"
|
|
if len(toolCall.Input) > 0 {
|
|
if data, err := json.Marshal(toolCall.Input); err == nil {
|
|
input = string(data)
|
|
input, _ = safety.RedactSensitiveText(input)
|
|
}
|
|
}
|
|
parts := []string{
|
|
fmt.Sprintf("Tool call: %s %s", strings.TrimSpace(toolCall.Name), input),
|
|
}
|
|
output := sanitizeSessionCompactionText(toolCall.Output)
|
|
if output != "" {
|
|
parts = append(parts, "Tool output: "+truncateSessionCompactionText(output, sessionCompactionToolOutputMaxChars))
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
func sanitizeSessionCompactionText(value string) string {
|
|
value = cleanToolCallArtifacts(strings.TrimSpace(value))
|
|
value, _ = safety.RedactSensitiveText(value)
|
|
return strings.TrimSpace(value)
|
|
}
|
|
|
|
func fitSessionCompactionEntries(entries []string, maxChars int) string {
|
|
if len(entries) == 0 || maxChars <= 0 {
|
|
return ""
|
|
}
|
|
|
|
full := strings.Join(entries, "\n\n")
|
|
if len([]rune(full)) <= maxChars {
|
|
return full
|
|
}
|
|
|
|
headBudget := maxChars / 2
|
|
tailBudget := maxChars - headBudget
|
|
var head []string
|
|
var headChars int
|
|
headEnd := 0
|
|
for headEnd < len(entries) {
|
|
entryChars := len([]rune(entries[headEnd])) + 2
|
|
if headChars+entryChars > headBudget {
|
|
break
|
|
}
|
|
head = append(head, entries[headEnd])
|
|
headChars += entryChars
|
|
headEnd++
|
|
}
|
|
|
|
var tail []string
|
|
var tailChars int
|
|
tailStart := len(entries)
|
|
for tailStart > headEnd {
|
|
entry := entries[tailStart-1]
|
|
entryChars := len([]rune(entry)) + 2
|
|
if tailChars+entryChars > tailBudget {
|
|
break
|
|
}
|
|
tail = append([]string{entry}, tail...)
|
|
tailChars += entryChars
|
|
tailStart--
|
|
}
|
|
|
|
omitted := tailStart - headEnd
|
|
if omitted < 0 {
|
|
omitted = 0
|
|
}
|
|
notice := fmt.Sprintf("[Compaction transcript omitted %d middle entries to fit the request budget.]", omitted)
|
|
combined := append(head, notice)
|
|
combined = append(combined, tail...)
|
|
return strings.Join(combined, "\n\n")
|
|
}
|
|
|
|
func truncateSessionCompactionText(value string, maxRunes int) string {
|
|
value = strings.TrimSpace(value)
|
|
if maxRunes <= 0 {
|
|
return value
|
|
}
|
|
runes := []rune(value)
|
|
if len(runes) <= maxRunes {
|
|
return value
|
|
}
|
|
return strings.TrimSpace(string(runes[:maxRunes])) + "\n[truncated]"
|
|
}
|