Files
pulse/internal/ai/chat/session.go
T
rcourtman e37353f937 Remove inferred Assistant continuation gates
Let the configured model choose investigation steps within explicit budgets.
Keep canonical planning, permissions and independent verification authoritative,
and preserve streamed conclusions in conversation history.

Expose recorded action outcomes so cached inventory cannot stand in for an
approval or execution receipt. Retain full plan context and make the exact
action review reachable from Assistant, including on narrow screens.

Refs #1782
2026-09-07 14:06:58 +01:00

1790 lines
52 KiB
Go

package chat
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rs/zerolog/log"
)
// SessionStore manages chat sessions persisted as JSON files
type SessionStore struct {
mu sync.RWMutex
dataDir string
// resolvedContexts holds per-session resolved resource contexts (in-memory only)
// These are NOT persisted - resources should be re-resolved after restart
// because infrastructure state may have changed
resolvedContexts map[string]*ResolvedContext
// sessionToolSets holds per-session tool allowlists (in-memory only).
// These keep tool availability stable across turns while allowing additive expansion.
sessionToolSets map[string]map[string]bool
// knowledgeAccumulators holds per-session knowledge accumulators (in-memory only).
// These extract and preserve key facts from tool results to prevent amnesia
// when old tool results are compacted from the conversation context.
knowledgeAccumulators map[string]*KnowledgeAccumulator
// summaryMu guards summaryCache. It is a leaf lock: never acquire s.mu
// while holding it. List() must stay off s.mu (see
// TestSessionStoreListDoesNotHoldStoreMutex), so the cache has its own.
summaryMu sync.Mutex
// summaryCache holds per-file session summaries keyed by file name,
// validated by (modTime, size). Listing sessions must not scale with
// transcript bytes: a session file is only re-read when it changed.
summaryCache map[string]sessionSummaryCacheEntry
// indexMu serializes summary index file writes.
indexMu sync.Mutex
}
type sessionSummaryCacheEntry struct {
modTime time.Time
size int64
summary Session
}
// sessionSummaryIndexFile persists the summary cache across restarts so the
// first List() after boot does not re-read every transcript. Entries are
// validated against each file's (modTime, size) before use, so a stale or
// hand-edited index only costs a re-parse, never a wrong summary.
const sessionSummaryIndexFile = ".sessions_index.json"
const sessionSummaryIndexVersion = 1
type sessionSummaryIndexEntry struct {
ModTime time.Time `json:"mod_time"`
Size int64 `json:"size"`
Summary Session `json:"summary"`
}
type sessionSummaryIndexData struct {
Version int `json:"version"`
Entries map[string]sessionSummaryIndexEntry `json:"entries"`
}
const maxSessionTurnRedoStack = 10
// sessionData is the on-disk format for a session
type sessionData struct {
ID string `json:"id"`
Title string `json:"title"`
Messages []Message `json:"messages"`
ModelContext *sessionModelContext `json:"model_context,omitempty"`
TurnRedoStack []sessionTurnRedoEntry `json:"turn_redo_stack,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type sessionTurnRedoEntry struct {
Messages []Message `json:"messages"`
UpdatedAt time.Time `json:"updated_at"`
}
type sessionModelContext struct {
HandoffFindingID string `json:"handoff_finding_id,omitempty"`
HandoffContext string `json:"handoff_context,omitempty"`
HandoffResources []HandoffResource `json:"handoff_resources,omitempty"`
HandoffActions []HandoffAction `json:"handoff_actions,omitempty"`
HandoffMetadata HandoffMetadata `json:"handoff_metadata,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
func cloneSessionMessages(messages []Message) []Message {
if len(messages) == 0 {
return nil
}
cloned := make([]Message, len(messages))
for i, msg := range messages {
cloned[i] = msg.NormalizeCollections()
}
return cloned
}
func normalizeHandoffResources(resources []HandoffResource) []HandoffResource {
if len(resources) == 0 {
return nil
}
normalized := make([]HandoffResource, 0, len(resources))
seen := make(map[string]struct{}, len(resources))
for _, resource := range resources {
resource.ID = strings.TrimSpace(resource.ID)
resource.Name = strings.TrimSpace(resource.Name)
resource.Type = strings.TrimSpace(resource.Type)
resource.Node = strings.TrimSpace(resource.Node)
if resource.ID == "" && resource.Name == "" {
continue
}
key := strings.ToLower(resource.Type + "\x00" + resource.ID + "\x00" + resource.Name + "\x00" + resource.Node)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
normalized = append(normalized, resource)
}
if len(normalized) == 0 {
return nil
}
return normalized
}
func normalizeHandoffActions(actions []HandoffAction) []HandoffAction {
if len(actions) == 0 {
return nil
}
normalized := make([]HandoffAction, 0, len(actions))
seen := make(map[string]struct{}, len(actions))
for _, action := range actions {
action.FindingID = strings.TrimSpace(action.FindingID)
action.RecordID = strings.TrimSpace(action.RecordID)
action.ApprovalID = strings.TrimSpace(action.ApprovalID)
action.ApprovalStatus = strings.TrimSpace(action.ApprovalStatus)
action.ApprovalRequestedAt = strings.TrimSpace(action.ApprovalRequestedAt)
action.ApprovalExpiresAt = strings.TrimSpace(action.ApprovalExpiresAt)
action.ApprovalDecidedAt = strings.TrimSpace(action.ApprovalDecidedAt)
action.ActionID = strings.TrimSpace(action.ActionID)
action.ActionState = strings.TrimSpace(action.ActionState)
action.ActionUpdatedAt = strings.TrimSpace(action.ActionUpdatedAt)
action.ActionRequestedBy = strings.TrimSpace(action.ActionRequestedBy)
action.ActionCapability = strings.TrimSpace(action.ActionCapability)
action.ActionApprovalPolicy = strings.TrimSpace(action.ActionApprovalPolicy)
action.ActionPlanExpiresAt = strings.TrimSpace(action.ActionPlanExpiresAt)
action.ActionPlanMessage = strings.TrimSpace(action.ActionPlanMessage)
action.ActionPreflight = strings.TrimSpace(action.ActionPreflight)
action.ActionDryRunSummary = strings.TrimSpace(action.ActionDryRunSummary)
action.ActionResult = strings.TrimSpace(action.ActionResult)
action.FixID = strings.TrimSpace(action.FixID)
action.Description = strings.TrimSpace(action.Description)
action.RiskLevel = strings.TrimSpace(action.RiskLevel)
action.TargetHost = strings.TrimSpace(action.TargetHost)
action.TargetResourceID = strings.TrimSpace(action.TargetResourceID)
action.TargetResourceName = strings.TrimSpace(action.TargetResourceName)
action.TargetResourceType = strings.TrimSpace(action.TargetResourceType)
action.TargetNode = strings.TrimSpace(action.TargetNode)
if action.ApprovalID == "" && action.ActionID == "" && action.FixID == "" && action.Description == "" && action.FindingID == "" {
continue
}
key := strings.ToLower(action.FindingID + "\x00" + action.RecordID + "\x00" + action.ApprovalID + "\x00" + action.ActionID + "\x00" + action.FixID + "\x00" + action.Description + "\x00" + action.TargetResourceID + "\x00" + action.TargetHost)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
normalized = append(normalized, action)
}
if len(normalized) == 0 {
return nil
}
return normalized
}
func trimHandoffMetadataField(value string, maxRunes int) string {
value = strings.TrimSpace(value)
if len([]rune(value)) <= maxRunes {
return value
}
runes := []rune(value)
return strings.TrimSpace(string(runes[:maxRunes]))
}
// NormalizeHandoffMetadata returns the browser-safe subset of product-originated
// handoff identity that can be persisted and exposed in session summaries.
func NormalizeHandoffMetadata(metadata HandoffMetadata) HandoffMetadata {
kind := strings.ToLower(trimHandoffMetadataField(metadata.Kind, 64))
switch kind {
case sessionHandoffKindPatrolAssessment,
sessionHandoffKindPatrolConfigurationFailure,
sessionHandoffKindPatrolFinding,
sessionHandoffKindPatrolRun,
sessionHandoffKindResourceContext:
default:
return HandoffMetadata{}
}
normalized := HandoffMetadata{
Kind: kind,
RunID: trimHandoffMetadataField(metadata.RunID, 256),
RunType: trimHandoffMetadataField(metadata.RunType, 128),
RunStatus: trimHandoffMetadataField(metadata.RunStatus, 128),
RuntimeFailure: metadata.RuntimeFailure,
}
if normalized.Kind == sessionHandoffKindPatrolRun && normalized.RunID == "" {
return HandoffMetadata{}
}
if normalized.Kind != sessionHandoffKindPatrolRun {
normalized.RunID = ""
normalized.RunType = ""
normalized.RunStatus = ""
}
if normalized.Kind != sessionHandoffKindPatrolRun && normalized.Kind != sessionHandoffKindPatrolConfigurationFailure {
normalized.RuntimeFailure = false
}
return normalized
}
func handoffMetadataEmpty(metadata HandoffMetadata) bool {
return NormalizeHandoffMetadata(metadata) == (HandoffMetadata{})
}
func inferPatrolRunHandoffMetadata(handoffContext string) HandoffMetadata {
lines := strings.Split(strings.TrimSpace(handoffContext), "\n")
if len(lines) == 0 {
return HandoffMetadata{}
}
var metadata HandoffMetadata
sawRunContext := false
sawRunHistorySource := false
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "[Patrol Run Context]" {
sawRunContext = true
continue
}
if !sawRunContext {
continue
}
label, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
label = strings.ToLower(strings.TrimSpace(label))
value = strings.TrimSpace(value)
switch label {
case "source":
if strings.EqualFold(value, "Pulse Patrol run history") {
sawRunHistorySource = true
}
case "run id":
metadata.RunID = value
case "run type":
metadata.RunType = value
case "status":
metadata.RunStatus = value
case "runtime failure":
metadata.RuntimeFailure = value != ""
}
}
if !sawRunContext || !sawRunHistorySource || strings.TrimSpace(metadata.RunID) == "" {
return HandoffMetadata{}
}
metadata.Kind = sessionHandoffKindPatrolRun
return NormalizeHandoffMetadata(metadata)
}
func modelContextEmpty(modelContext *sessionModelContext) bool {
if modelContext == nil {
return true
}
return strings.TrimSpace(modelContext.HandoffFindingID) == "" &&
strings.TrimSpace(modelContext.HandoffContext) == "" &&
len(normalizeHandoffResources(modelContext.HandoffResources)) == 0 &&
len(normalizeHandoffActions(modelContext.HandoffActions)) == 0 &&
handoffMetadataEmpty(modelContext.HandoffMetadata)
}
func cloneSessionModelContext(modelContext *sessionModelContext) *sessionModelContext {
if modelContext == nil {
return nil
}
clone := *modelContext
if len(modelContext.HandoffResources) > 0 {
clone.HandoffResources = append([]HandoffResource(nil), modelContext.HandoffResources...)
}
if len(modelContext.HandoffActions) > 0 {
clone.HandoffActions = append([]HandoffAction(nil), modelContext.HandoffActions...)
}
return &clone
}
const (
sessionHandoffKindPatrolAssessment = "patrol_assessment"
sessionHandoffKindPatrolConfigurationFailure = "patrol_configuration_failure"
sessionHandoffKindPatrolFinding = "patrol_finding"
sessionHandoffKindPatrolRun = "patrol_run"
sessionHandoffKindResourceContext = "resource_context"
sessionHandoffKindScopedContext = "scoped_context"
)
func handoffActionCurrentlyRequiresApproval(action HandoffAction) bool {
approvalStatus := strings.ToLower(strings.TrimSpace(action.ApprovalStatus))
actionState := strings.ToLower(strings.TrimSpace(action.ActionState))
if approvalStatus == "pending" && !action.ApprovalConsumed {
return true
}
switch actionState {
case "pending_approval", "awaiting_approval":
return approvalStatus == "" || approvalStatus == "pending"
case "approved", "rejected", "executing", "completed", "failed", "planned":
return false
}
if approvalStatus != "" || strings.TrimSpace(action.ApprovalID) != "" {
return false
}
return action.ActionRequiresApproval
}
func modelContextHandoffSummary(modelContext *sessionModelContext) *SessionHandoffSummary {
if modelContextEmpty(modelContext) {
return nil
}
resources := normalizeHandoffResources(modelContext.HandoffResources)
actions := normalizeHandoffActions(modelContext.HandoffActions)
metadata := NormalizeHandoffMetadata(modelContext.HandoffMetadata)
if handoffMetadataEmpty(metadata) {
metadata = inferPatrolRunHandoffMetadata(modelContext.HandoffContext)
}
findingID := strings.TrimSpace(modelContext.HandoffFindingID)
if findingID == "" && metadata.Kind == "" {
for _, action := range actions {
if strings.TrimSpace(action.FindingID) != "" {
findingID = strings.TrimSpace(action.FindingID)
break
}
}
}
kind := sessionHandoffKindScopedContext
if metadata.Kind != "" {
kind = metadata.Kind
} else if findingID != "" {
kind = sessionHandoffKindPatrolFinding
}
summary := &SessionHandoffSummary{
Kind: kind,
FindingID: findingID,
RunID: metadata.RunID,
RunType: metadata.RunType,
RunStatus: metadata.RunStatus,
RuntimeFailure: metadata.RuntimeFailure,
HasModelContext: strings.TrimSpace(modelContext.HandoffContext) != "",
ResourceCount: len(resources),
ActionCount: len(actions),
}
if kind != sessionHandoffKindPatrolRun {
summary.RunID = ""
summary.RunType = ""
summary.RunStatus = ""
}
if kind != sessionHandoffKindPatrolRun && kind != sessionHandoffKindPatrolConfigurationFailure {
summary.RuntimeFailure = false
}
if !modelContext.UpdatedAt.IsZero() {
updatedAt := modelContext.UpdatedAt
summary.UpdatedAt = &updatedAt
}
if len(resources) > 0 {
primaryResource := resources[0]
summary.PrimaryResource = &primaryResource
}
for _, action := range actions {
if !summary.RequiresApproval && handoffActionCurrentlyRequiresApproval(action) {
summary.RequiresApproval = true
}
if summary.LastKnownApprovalStatus == "" {
summary.LastKnownApprovalStatus = strings.TrimSpace(action.ApprovalStatus)
}
if summary.LastKnownActionState == "" {
summary.LastKnownActionState = strings.TrimSpace(action.ActionState)
}
if summary.LastKnownActionRisk == "" {
summary.LastKnownActionRisk = strings.TrimSpace(action.RiskLevel)
}
}
return summary
}
func sessionSummaryFromData(data sessionData) Session {
return Session{
ID: data.ID,
Title: data.Title,
CreatedAt: data.CreatedAt,
UpdatedAt: data.UpdatedAt,
MessageCount: len(data.Messages),
CanRedo: len(data.TurnRedoStack) > 0,
System: IsSystemSessionID(data.ID),
HandoffSummary: modelContextHandoffSummary(data.ModelContext),
}
}
// sessionListData decodes only what a session summary needs. Message and redo
// payloads stay as raw JSON so listing cost does not scale with transcript
// size — only their counts matter here.
type sessionListData struct {
ID string `json:"id"`
Title string `json:"title"`
Messages []json.RawMessage `json:"messages"`
ModelContext *sessionModelContext `json:"model_context,omitempty"`
TurnRedoStack []json.RawMessage `json:"turn_redo_stack,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func sessionSummaryFromListData(data sessionListData) Session {
return Session{
ID: data.ID,
Title: data.Title,
CreatedAt: data.CreatedAt,
UpdatedAt: data.UpdatedAt,
MessageCount: len(data.Messages),
CanRedo: len(data.TurnRedoStack) > 0,
System: IsSystemSessionID(data.ID),
HandoffSummary: modelContextHandoffSummary(data.ModelContext),
}
}
const (
maxSessionIDLength = 128
maxSessionTitleRunes = 120
)
var errSessionNotFound = errors.New("session not found")
// NewSessionStore creates a new session store
func NewSessionStore(dataDir string) (*SessionStore, error) {
sessionsDir := filepath.Join(dataDir, "ai_sessions")
if err := os.MkdirAll(sessionsDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create sessions directory: %w", err)
}
store := &SessionStore{
dataDir: sessionsDir,
resolvedContexts: make(map[string]*ResolvedContext),
sessionToolSets: make(map[string]map[string]bool),
knowledgeAccumulators: make(map[string]*KnowledgeAccumulator),
summaryCache: make(map[string]sessionSummaryCacheEntry),
}
store.loadSummaryIndex()
return store, nil
}
// sessionPath returns the file path for a session
func (s *SessionStore) sessionPath(id string) (string, error) {
if err := validateSessionID(id); err != nil {
return "", err
}
return securityutil.JoinStorageLeaf(s.dataDir, securityutil.HashedStorageName(id)+".json")
}
func (s *SessionStore) directLegacySessionPath(id string) (string, error) {
if err := validateSessionID(id); err != nil {
return "", err
}
return securityutil.JoinStorageLeaf(s.dataDir, id+".json")
}
func (s *SessionStore) findLegacySessionPath(id string) (string, error) {
if err := validateSessionID(id); err != nil {
return "", err
}
canonicalPath, err := s.sessionPath(id)
if err != nil {
return "", err
}
directPath, err := s.directLegacySessionPath(id)
if err != nil {
return "", err
}
if directPath != canonicalPath {
if _, err := os.Stat(directPath); err == nil {
return directPath, nil
} else if err != nil && !os.IsNotExist(err) {
return "", fmt.Errorf("failed to stat legacy session path: %w", err)
}
}
canonicalName := securityutil.HashedStorageName(id) + ".json"
entries, err := os.ReadDir(s.dataDir)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("failed to scan session directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
if entry.Name() == canonicalName {
continue
}
path, err := securityutil.JoinStorageLeaf(s.dataDir, entry.Name())
if err != nil {
log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to resolve legacy session candidate path")
continue
}
data, err := os.ReadFile(path)
if err != nil {
log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to read legacy session candidate")
continue
}
var session sessionData
if err := json.Unmarshal(data, &session); err != nil {
log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to parse legacy session candidate")
continue
}
if session.ID == id {
return path, nil
}
}
return "", nil
}
// List returns all sessions, sorted by updated_at descending.
// Summaries are served from a per-file cache validated by (modTime, size);
// only files that changed since the last call are re-read and re-parsed.
func (s *SessionStore) List() ([]Session, error) {
entries, err := os.ReadDir(s.dataDir)
if err != nil {
return nil, fmt.Errorf("failed to read sessions directory: %w", err)
}
seen := make(map[string]struct{}, len(entries))
cacheChanged := false
var sessions []Session
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || strings.HasPrefix(name, ".") || !strings.HasSuffix(name, ".json") {
continue
}
info, err := entry.Info()
if err != nil {
log.Warn().Err(err).Str("file", name).Msg("failed to stat session file")
continue
}
seen[name] = struct{}{}
s.summaryMu.Lock()
cached, ok := s.summaryCache[name]
s.summaryMu.Unlock()
if ok && cached.modTime.Equal(info.ModTime()) && cached.size == info.Size() {
sessions = append(sessions, cached.summary)
continue
}
path, err := securityutil.JoinStorageLeaf(s.dataDir, name)
if err != nil {
log.Warn().Err(err).Str("file", name).Msg("failed to resolve session file path")
continue
}
file, err := os.ReadFile(path)
if err != nil {
log.Warn().Err(err).Str("file", name).Msg("failed to read session file")
continue
}
var data sessionListData
if err := json.Unmarshal(file, &data); err != nil {
log.Warn().Err(err).Str("file", name).Msg("failed to parse session file")
continue
}
summary := sessionSummaryFromListData(data)
s.summaryMu.Lock()
s.summaryCache[name] = sessionSummaryCacheEntry{modTime: info.ModTime(), size: info.Size(), summary: summary}
s.summaryMu.Unlock()
cacheChanged = true
sessions = append(sessions, summary)
}
// Drop cache entries for files that no longer exist
s.summaryMu.Lock()
for name := range s.summaryCache {
if _, ok := seen[name]; !ok {
delete(s.summaryCache, name)
cacheChanged = true
}
}
s.summaryMu.Unlock()
if cacheChanged {
s.saveSummaryIndex()
}
// Sort by updated_at descending (newest first)
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].UpdatedAt.After(sessions[j].UpdatedAt)
})
return sessions, nil
}
// loadSummaryIndex hydrates the summary cache from the persisted index.
// Best-effort: a missing or corrupt index just means the next List()
// re-parses changed files and rewrites it.
func (s *SessionStore) loadSummaryIndex() {
path := filepath.Join(s.dataDir, sessionSummaryIndexFile)
raw, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
log.Warn().Err(err).Msg("failed to read session summary index")
}
return
}
var data sessionSummaryIndexData
if err := json.Unmarshal(raw, &data); err != nil {
log.Warn().Err(err).Msg("failed to parse session summary index; rebuilding on next list")
return
}
if data.Version != sessionSummaryIndexVersion {
return
}
s.summaryMu.Lock()
defer s.summaryMu.Unlock()
for name, entry := range data.Entries {
if strings.HasPrefix(name, ".") || !strings.HasSuffix(name, ".json") {
continue
}
s.summaryCache[name] = sessionSummaryCacheEntry{modTime: entry.ModTime, size: entry.Size, summary: entry.Summary}
}
}
// saveSummaryIndex persists the summary cache next to the session files.
// Best-effort: failures are logged and never block session operations.
func (s *SessionStore) saveSummaryIndex() {
s.summaryMu.Lock()
data := sessionSummaryIndexData{
Version: sessionSummaryIndexVersion,
Entries: make(map[string]sessionSummaryIndexEntry, len(s.summaryCache)),
}
for name, entry := range s.summaryCache {
data.Entries[name] = sessionSummaryIndexEntry{ModTime: entry.modTime, Size: entry.size, Summary: entry.summary}
}
s.summaryMu.Unlock()
raw, err := json.Marshal(data)
if err != nil {
log.Warn().Err(err).Msg("failed to marshal session summary index")
return
}
s.indexMu.Lock()
defer s.indexMu.Unlock()
path := filepath.Join(s.dataDir, sessionSummaryIndexFile)
tmpFile, err := os.CreateTemp(s.dataDir, sessionSummaryIndexFile+".tmp-")
if err != nil {
log.Warn().Err(err).Msg("failed to create session summary index temp file")
return
}
tmpPath := tmpFile.Name()
if err := tmpFile.Chmod(0600); err == nil {
_, err = tmpFile.Write(raw)
}
if closeErr := tmpFile.Close(); err == nil {
err = closeErr
}
if err == nil {
err = os.Rename(tmpPath, path)
}
if err != nil {
_ = os.Remove(tmpPath)
log.Warn().Err(err).Msg("failed to write session summary index")
}
}
// Create creates a new session
func (s *SessionStore) Create() (*Session, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
data := sessionData{
ID: uuid.New().String(),
Title: "",
Messages: []Message{},
CreatedAt: now,
UpdatedAt: now,
}
if err := s.writeSession(data); err != nil {
return nil, err
}
session := sessionSummaryFromData(data)
return &session, nil
}
// Get retrieves a session by ID
func (s *SessionStore) Get(id string) (*Session, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := s.readSession(id)
if err != nil {
return nil, err
}
session := sessionSummaryFromData(*data)
return &session, nil
}
// Rename updates a session title without touching messages or handoff context.
func (s *SessionStore) Rename(id, title string) (*Session, error) {
normalizedTitle := normalizeSessionTitle(title)
if normalizedTitle == "" {
return nil, fmt.Errorf("session title required")
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readSession(id)
if err != nil {
return nil, err
}
data.Title = normalizedTitle
data.UpdatedAt = time.Now()
if err := s.writeSession(*data); err != nil {
return nil, err
}
session := sessionSummaryFromData(*data)
return &session, nil
}
// Fork clones a persisted session into a new durable session. The copied
// messages intentionally preserve their per-session IDs so tool-call/result
// relationships remain intact inside the forked transcript.
func (s *SessionStore) Fork(id string) (*Session, error) {
if err := validateSessionID(id); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
source, err := s.readSession(id)
if err != nil {
return nil, err
}
now := time.Now()
messages := cloneSessionMessages(source.Messages)
title := strings.TrimSpace(source.Title)
if title == "" {
for _, msg := range messages {
if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
title = generateTitle(msg.Content)
break
}
}
}
if title == "" {
title = "Forked session"
} else if !strings.HasPrefix(strings.ToLower(title), "fork of ") {
title = "Fork of " + title
}
fork := sessionData{
ID: uuid.New().String(),
Title: title,
Messages: messages,
ModelContext: cloneSessionModelContext(source.ModelContext),
CreatedAt: now,
UpdatedAt: now,
}
if err := s.writeSession(fork); err != nil {
return nil, err
}
session := sessionSummaryFromData(fork)
return &session, nil
}
// UndoLastTurn removes the latest user-authored turn from a session and stores
// the removed messages so RedoLastTurn can restore them.
func (s *SessionStore) UndoLastTurn(id string) (*SessionTurnUndoResult, error) {
return s.UndoLastTurnWithOptions(id, SessionTurnUndoOptions{})
}
// UndoLastTurnWithOptions removes the latest user-authored turn subject to the
// given options: an expected-prompt guard so retry/regenerate flows cannot
// remove a turn other than the one being re-run.
func (s *SessionStore) UndoLastTurnWithOptions(id string, opts SessionTurnUndoOptions) (*SessionTurnUndoResult, error) {
if err := validateSessionID(id); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readSession(id)
if err != nil {
return nil, err
}
lastUserIndex := -1
for i := len(data.Messages) - 1; i >= 0; i-- {
if strings.EqualFold(data.Messages[i].Role, "user") {
lastUserIndex = i
break
}
}
if lastUserIndex < 0 {
return &SessionTurnUndoResult{
Success: false,
SessionID: data.ID,
CanRedo: len(data.TurnRedoStack) > 0,
Message: "No user turn to undo.",
}, nil
}
if expected := strings.TrimSpace(opts.ExpectedPrompt); expected != "" &&
strings.TrimSpace(data.Messages[lastUserIndex].Content) != expected {
return &SessionTurnUndoResult{
Success: false,
SessionID: data.ID,
CanRedo: len(data.TurnRedoStack) > 0,
Message: "The latest turn no longer matches the prompt being retried.",
}, nil
}
removed := cloneSessionMessages(data.Messages[lastUserIndex:])
prompt := ""
for _, msg := range removed {
if strings.EqualFold(msg.Role, "user") {
prompt = msg.Content
break
}
}
now := time.Now()
data.Messages = cloneSessionMessages(data.Messages[:lastUserIndex])
data.TurnRedoStack = append(data.TurnRedoStack, sessionTurnRedoEntry{
Messages: removed,
UpdatedAt: now,
})
if len(data.TurnRedoStack) > maxSessionTurnRedoStack {
data.TurnRedoStack = data.TurnRedoStack[len(data.TurnRedoStack)-maxSessionTurnRedoStack:]
}
data.UpdatedAt = now
if err := s.writeSession(*data); err != nil {
return nil, err
}
return &SessionTurnUndoResult{
Success: true,
SessionID: data.ID,
RestoredPrompt: prompt,
RemovedMessages: len(removed),
CanRedo: len(data.TurnRedoStack) > 0,
}, nil
}
// RedoLastTurn restores the most recently undone turn, if one is available.
func (s *SessionStore) RedoLastTurn(id string) (*SessionTurnRedoResult, error) {
if err := validateSessionID(id); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readSession(id)
if err != nil {
return nil, err
}
if len(data.TurnRedoStack) == 0 {
return &SessionTurnRedoResult{
Success: false,
SessionID: data.ID,
CanRedo: false,
Message: "No undone turn to redo.",
}, nil
}
entryIndex := len(data.TurnRedoStack) - 1
entry := data.TurnRedoStack[entryIndex]
data.TurnRedoStack = data.TurnRedoStack[:entryIndex]
data.Messages = append(data.Messages, cloneSessionMessages(entry.Messages)...)
if len(data.TurnRedoStack) == 0 {
data.TurnRedoStack = nil
}
data.UpdatedAt = time.Now()
if err := s.writeSession(*data); err != nil {
return nil, err
}
return &SessionTurnRedoResult{
Success: true,
SessionID: data.ID,
RestoredMessages: len(entry.Messages),
CanRedo: len(data.TurnRedoStack) > 0,
}, nil
}
// Delete deletes a session
func (s *SessionStore) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
path, err := s.sessionPath(id)
if err != nil {
return err
}
var removed bool
candidates := []string{path}
legacyPath, err := s.findLegacySessionPath(id)
if err != nil {
return err
}
if legacyPath != "" && legacyPath != path {
candidates = append(candidates, legacyPath)
}
for _, candidate := range candidates {
if err := os.Remove(candidate); err != nil {
if os.IsNotExist(err) {
continue
}
return fmt.Errorf("failed to delete session: %w", err)
}
removed = true
s.summaryMu.Lock()
delete(s.summaryCache, filepath.Base(candidate))
s.summaryMu.Unlock()
}
if !removed {
return fmt.Errorf("session not found: %s", id)
}
s.saveSummaryIndex()
// Also clean up resolved context and knowledge accumulator
delete(s.resolvedContexts, id)
delete(s.sessionToolSets, id)
delete(s.knowledgeAccumulators, id)
return nil
}
// GetMessages retrieves all messages for a session
func (s *SessionStore) GetMessages(id string) ([]Message, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := s.readSession(id)
if err != nil {
return nil, err
}
return data.Messages, nil
}
// TrimMessages keeps at most keepMostRecent messages in the session,
// dropping older ones. Used for sessions like patrol-main that are
// reused indefinitely across scheduled runs and would otherwise grow
// unbounded — at the default 3-hour Patrol cadence with ~20 messages
// per run, the file grew to 16 MB / 3,593 messages before this bound
// existed, and every AddMessage was rewriting the whole file to disk.
//
// keepMostRecent <= 0 is treated as a no-op so callers can disable the
// bound by passing 0 when they want full retention (e.g. user-driven
// chat sessions where conversation history is the product).
func (s *SessionStore) TrimMessages(id string, keepMostRecent int) error {
if keepMostRecent <= 0 {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readSession(id)
if err != nil {
return err
}
if len(data.Messages) <= keepMostRecent {
return nil
}
start := len(data.Messages) - keepMostRecent
trimmed := make([]Message, keepMostRecent)
copy(trimmed, data.Messages[start:])
data.Messages = trimmed
data.TurnRedoStack = nil
data.UpdatedAt = time.Now()
return s.writeSession(*data)
}
// AddMessage adds a message to a session
func (s *SessionStore) AddMessage(id string, msg Message) error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readSession(id)
if err != nil {
return err
}
// Generate message ID if not set
if msg.ID == "" {
msg.ID = uuid.New().String()
}
if msg.Timestamp.IsZero() {
msg.Timestamp = time.Now()
}
msg = msg.NormalizeCollections()
data.Messages = append(data.Messages, msg)
data.TurnRedoStack = nil
data.UpdatedAt = time.Now()
// Auto-generate title from first user message if not set
if data.Title == "" && msg.Role == "user" && msg.Content != "" {
data.Title = generateTitle(msg.Content)
}
return s.writeSession(*data)
}
// SetModelHandoffFindingID stores the product-originated finding reference for
// follow-up turns. The reference lets API handlers refresh the current Patrol
// context without treating the finding as user-authored chat text.
func (s *SessionStore) SetModelHandoffFindingID(id, findingID string) error {
findingID = strings.TrimSpace(findingID)
if findingID == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readSession(id)
if err != nil {
return err
}
now := time.Now()
if data.ModelContext == nil {
data.ModelContext = &sessionModelContext{}
}
data.ModelContext.HandoffFindingID = findingID
data.ModelContext.UpdatedAt = now
data.UpdatedAt = now
return s.writeSession(*data)
}
// SetModelHandoffEnvelope replaces the session's product-originated handoff
// as one coherent scope. This avoids stale finding, run, resource, or action
// identity leaking between separate handoffs within the same chat session.
func (s *SessionStore) SetModelHandoffEnvelope(id string, findingID string, handoffContext string, handoffResources []HandoffResource, handoffActions []HandoffAction, handoffMetadata HandoffMetadata) error {
findingID = strings.TrimSpace(findingID)
handoffContext = strings.TrimSpace(handoffContext)
resources := normalizeHandoffResources(handoffResources)
actions := normalizeHandoffActions(handoffActions)
metadata := NormalizeHandoffMetadata(handoffMetadata)
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readSession(id)
if err != nil {
return err
}
now := time.Now()
modelContext := &sessionModelContext{
HandoffFindingID: findingID,
HandoffContext: handoffContext,
HandoffResources: resources,
HandoffActions: actions,
HandoffMetadata: metadata,
UpdatedAt: now,
}
if modelContextEmpty(modelContext) {
data.ModelContext = nil
} else {
data.ModelContext = modelContext
}
data.UpdatedAt = now
return s.writeSession(*data)
}
// SetModelHandoffContext stores model-only handoff context for future turns.
// It is intentionally session metadata, not a user-authored chat message.
func (s *SessionStore) SetModelHandoffContext(id, handoffContext string) error {
handoffContext = strings.TrimSpace(handoffContext)
if handoffContext == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readSession(id)
if err != nil {
return err
}
now := time.Now()
if data.ModelContext == nil {
data.ModelContext = &sessionModelContext{}
}
data.ModelContext.HandoffContext = handoffContext
data.ModelContext.UpdatedAt = now
data.UpdatedAt = now
return s.writeSession(*data)
}
// SetModelHandoffResources stores product-originated resource references for
// future turns. These references are not authority by themselves; chat execution
// re-resolves them through the canonical unified-resource model before use.
func (s *SessionStore) SetModelHandoffResources(id string, handoffResources []HandoffResource) error {
resources := normalizeHandoffResources(handoffResources)
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readSession(id)
if err != nil {
return err
}
now := time.Now()
if data.ModelContext == nil {
data.ModelContext = &sessionModelContext{}
}
data.ModelContext.HandoffResources = resources
data.ModelContext.UpdatedAt = now
if modelContextEmpty(data.ModelContext) {
data.ModelContext = nil
}
data.UpdatedAt = now
return s.writeSession(*data)
}
// SetModelHandoffActions stores product-originated pending action references
// for future turns. These references are not executable authority and must not
// contain raw command text.
func (s *SessionStore) SetModelHandoffActions(id string, handoffActions []HandoffAction) error {
actions := normalizeHandoffActions(handoffActions)
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readSession(id)
if err != nil {
return err
}
now := time.Now()
if data.ModelContext == nil {
data.ModelContext = &sessionModelContext{}
}
data.ModelContext.HandoffActions = actions
data.ModelContext.UpdatedAt = now
if modelContextEmpty(data.ModelContext) {
data.ModelContext = nil
}
data.UpdatedAt = now
return s.writeSession(*data)
}
// SetModelHandoffMetadata stores browser-safe handoff identity for future
// session summaries without exposing private model context details.
func (s *SessionStore) SetModelHandoffMetadata(id string, handoffMetadata HandoffMetadata) error {
metadata := NormalizeHandoffMetadata(handoffMetadata)
if handoffMetadataEmpty(metadata) {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readSession(id)
if err != nil {
return err
}
now := time.Now()
if data.ModelContext == nil {
data.ModelContext = &sessionModelContext{}
}
data.ModelContext.HandoffMetadata = metadata
data.ModelContext.UpdatedAt = now
data.UpdatedAt = now
return s.writeSession(*data)
}
// GetModelHandoffFindingID returns the stored product-originated finding
// reference for a session.
func (s *SessionStore) GetModelHandoffFindingID(id string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := s.readSession(id)
if err != nil {
return "", err
}
if data.ModelContext == nil {
return "", nil
}
return strings.TrimSpace(data.ModelContext.HandoffFindingID), nil
}
// GetModelHandoffMetadata returns the browser-safe handoff identity stored for
// a session.
func (s *SessionStore) GetModelHandoffMetadata(id string) (HandoffMetadata, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := s.readSession(id)
if err != nil {
return HandoffMetadata{}, err
}
if data.ModelContext == nil {
return HandoffMetadata{}, nil
}
return NormalizeHandoffMetadata(data.ModelContext.HandoffMetadata), nil
}
// GetModelHandoffContext returns model-only handoff context for a session.
func (s *SessionStore) GetModelHandoffContext(id string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := s.readSession(id)
if err != nil {
return "", err
}
if data.ModelContext == nil {
return "", nil
}
return strings.TrimSpace(data.ModelContext.HandoffContext), nil
}
// GetModelHandoffResources returns stored handoff resource references for a
// session. Callers must rehydrate them through canonical resource registration
// before using them for action validation.
func (s *SessionStore) GetModelHandoffResources(id string) ([]HandoffResource, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := s.readSession(id)
if err != nil {
return nil, err
}
if data.ModelContext == nil {
return nil, nil
}
return normalizeHandoffResources(data.ModelContext.HandoffResources), nil
}
// GetModelHandoffActions returns stored product-originated pending action
// references for a session. Callers must treat them as review context only.
func (s *SessionStore) GetModelHandoffActions(id string) ([]HandoffAction, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := s.readSession(id)
if err != nil {
return nil, err
}
if data.ModelContext == nil {
return nil, nil
}
return normalizeHandoffActions(data.ModelContext.HandoffActions), nil
}
// GetModelHandoffEnvelope returns the persisted model-only handoff fields in
// one read. Send paths use this instead of several independent metadata reads
// so large stores do not pay repeated session-file I/O before the model starts.
func (s *SessionStore) GetModelHandoffEnvelope(id string) (string, []HandoffResource, []HandoffAction, HandoffMetadata, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := s.readSessionFast(id)
if err != nil {
return "", nil, nil, HandoffMetadata{}, err
}
if data.ModelContext == nil {
return "", nil, nil, HandoffMetadata{}, nil
}
return strings.TrimSpace(data.ModelContext.HandoffContext),
normalizeHandoffResources(data.ModelContext.HandoffResources),
normalizeHandoffActions(data.ModelContext.HandoffActions),
NormalizeHandoffMetadata(data.ModelContext.HandoffMetadata),
nil
}
func (s *SessionStore) clearModelHandoffContextLocked(id string) error {
data, err := s.readSession(id)
if err != nil {
return err
}
if modelContextEmpty(data.ModelContext) {
return nil
}
data.ModelContext = nil
data.UpdatedAt = time.Now()
return s.writeSession(*data)
}
// ClearModelHandoffContext removes product-originated model-only handoff
// metadata while leaving the user-authored message history intact.
func (s *SessionStore) ClearModelHandoffContext(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.clearModelHandoffContextLocked(id)
}
// UpdateLastMessage updates the last message in a session (for streaming updates)
func (s *SessionStore) UpdateLastMessage(id string, msg Message) error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readSession(id)
if err != nil {
return err
}
if len(data.Messages) == 0 {
return fmt.Errorf("no messages to update")
}
data.Messages[len(data.Messages)-1] = msg.NormalizeCollections()
data.UpdatedAt = time.Now()
return s.writeSession(*data)
}
// readSession reads a session from disk (caller must hold lock).
func (s *SessionStore) readSession(id string) (*sessionData, error) {
return s.readSessionWithLegacyScan(id, true)
}
// readSessionFast reads canonical sessions and direct <id>.json legacy sessions
// without scanning the full session directory. Use it on create/send hot paths
// where a missing session usually means "create a new one".
func (s *SessionStore) readSessionFast(id string) (*sessionData, error) {
return s.readSessionWithLegacyScan(id, false)
}
func (s *SessionStore) readSessionWithLegacyScan(id string, allowLegacyScan bool) (*sessionData, error) {
path, err := s.sessionPath(id)
if err != nil {
return nil, err
}
file, err := os.ReadFile(path)
if os.IsNotExist(err) {
directLegacyPath, directLegacyErr := s.directLegacySessionPath(id)
if directLegacyErr != nil {
return nil, directLegacyErr
}
if directLegacyPath != path {
file, err = os.ReadFile(directLegacyPath)
}
if os.IsNotExist(err) && allowLegacyScan {
legacyPath, legacyErr := s.findLegacySessionPath(id)
if legacyErr != nil {
return nil, legacyErr
}
if legacyPath != "" && legacyPath != directLegacyPath {
file, err = os.ReadFile(legacyPath)
}
}
if os.IsNotExist(err) {
return nil, sessionNotFoundError(id)
}
}
if err != nil {
return nil, fmt.Errorf("failed to read session: %w", err)
}
var data sessionData
if err := json.Unmarshal(file, &data); err != nil {
return nil, fmt.Errorf("failed to parse session: %w", err)
}
for i := range data.Messages {
data.Messages[i] = data.Messages[i].NormalizeCollections()
}
for i := range data.TurnRedoStack {
data.TurnRedoStack[i].Messages = cloneSessionMessages(data.TurnRedoStack[i].Messages)
}
return &data, nil
}
// writeSession writes a session to disk (caller must hold lock)
func (s *SessionStore) writeSession(data sessionData) error {
if err := validateSessionID(data.ID); err != nil {
return err
}
for i := range data.Messages {
data.Messages[i] = data.Messages[i].NormalizeCollections()
}
for i := range data.TurnRedoStack {
data.TurnRedoStack[i].Messages = cloneSessionMessages(data.TurnRedoStack[i].Messages)
}
if len(data.TurnRedoStack) == 0 {
data.TurnRedoStack = nil
}
file, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal session: %w", err)
}
path, err := s.sessionPath(data.ID)
if err != nil {
return err
}
tmpFile, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-")
if err != nil {
return fmt.Errorf("failed to create session temp file: %w", err)
}
tmpPath := tmpFile.Name()
cleanupTemp := true
defer func() {
if cleanupTemp {
_ = os.Remove(tmpPath)
}
}()
if err := tmpFile.Chmod(0600); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("failed to secure session temp file: %w", err)
}
if _, err := tmpFile.Write(file); err != nil {
_ = tmpFile.Close()
return fmt.Errorf("failed to write session temp file: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close session temp file: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("failed to write session: %w", err)
}
cleanupTemp = false
if legacyPath, err := s.directLegacySessionPath(data.ID); err == nil && legacyPath != "" && legacyPath != path {
_ = os.Remove(legacyPath)
s.summaryMu.Lock()
delete(s.summaryCache, filepath.Base(legacyPath))
s.summaryMu.Unlock()
}
// Keep the list summary cache warm so List() never re-reads this file.
s.summaryMu.Lock()
if info, err := os.Stat(path); err == nil {
s.summaryCache[filepath.Base(path)] = sessionSummaryCacheEntry{
modTime: info.ModTime(),
size: info.Size(),
summary: sessionSummaryFromData(data),
}
} else {
delete(s.summaryCache, filepath.Base(path))
}
s.summaryMu.Unlock()
s.saveSummaryIndex()
return nil
}
// generateTitle creates a session title from the first user message
func generateTitle(content string) string {
// Clean up the content
content = strings.TrimSpace(content)
content = strings.ReplaceAll(content, "\n", " ")
content = strings.ReplaceAll(content, "\r", " ")
// Collapse multiple spaces
for strings.Contains(content, " ") {
content = strings.ReplaceAll(content, " ", " ")
}
const maxLen = 50
runes := []rune(content)
if len(runes) <= maxLen {
return content
}
// Find a good break point
truncated := string(runes[:maxLen])
lastSpace := strings.LastIndex(truncated, " ")
if lastSpace > 20 {
return truncated[:lastSpace] + "..."
}
return truncated + "..."
}
func normalizeSessionTitle(title string) string {
normalized := strings.Join(strings.Fields(strings.TrimSpace(title)), " ")
runes := []rune(normalized)
if len(runes) <= maxSessionTitleRunes {
return normalized
}
return string(runes[:maxSessionTitleRunes])
}
// EnsureSession ensures a session exists, creating one if needed
func (s *SessionStore) EnsureSession(id string) (*Session, error) {
if id == "" {
return s.Create()
}
if err := validateSessionID(id); err != nil {
return nil, err
}
s.mu.RLock()
data, err := s.readSessionFast(id)
s.mu.RUnlock()
if err == nil {
session := sessionSummaryFromData(*data)
return &session, nil
}
if !errors.Is(err, errSessionNotFound) {
return nil, err
}
// Session doesn't exist, create it with the specified ID. Re-check under the
// write lock so concurrent callers do not race into duplicate writes.
s.mu.Lock()
defer s.mu.Unlock()
data, err = s.readSessionFast(id)
if err == nil {
session := sessionSummaryFromData(*data)
return &session, nil
}
if !errors.Is(err, errSessionNotFound) {
return nil, err
}
now := time.Now()
data = &sessionData{
ID: id,
Title: "",
Messages: []Message{},
CreatedAt: now,
UpdatedAt: now,
}
if err := s.writeSession(*data); err != nil {
return nil, err
}
session := sessionSummaryFromData(*data)
return &session, nil
}
func validateSessionID(id string) error {
if id == "" {
return fmt.Errorf("invalid session id: cannot be empty")
}
if len(id) > maxSessionIDLength {
return fmt.Errorf("invalid session id: too long")
}
for _, r := range id {
isLower := r >= 'a' && r <= 'z'
isUpper := r >= 'A' && r <= 'Z'
isDigit := r >= '0' && r <= '9'
if isLower || isUpper || isDigit || r == '-' || r == '_' {
continue
}
return fmt.Errorf("invalid session id: only letters, numbers, '-' and '_' are allowed")
}
return nil
}
func sessionNotFoundError(id string) error {
return fmt.Errorf("%w: %s", errSessionNotFound, id)
}
// GetResolvedContext returns the resolved context for a session, creating one if needed
func (s *SessionStore) GetResolvedContext(sessionID string) *ResolvedContext {
s.mu.Lock()
defer s.mu.Unlock()
ctx, ok := s.resolvedContexts[sessionID]
if !ok {
ctx = NewResolvedContext(sessionID)
s.resolvedContexts[sessionID] = ctx
}
return ctx
}
// GetKnowledgeAccumulator returns the knowledge accumulator for a session, creating one if needed.
// For user chat sessions, this persists across messages (facts accumulate during a conversation).
func (s *SessionStore) GetKnowledgeAccumulator(sessionID string) *KnowledgeAccumulator {
s.mu.Lock()
defer s.mu.Unlock()
ka, ok := s.knowledgeAccumulators[sessionID]
if !ok {
ka = NewKnowledgeAccumulator()
s.knowledgeAccumulators[sessionID] = ka
}
return ka
}
// NewKnowledgeAccumulatorForRun creates a fresh KA for a patrol run.
// Unlike GetKnowledgeAccumulator (which reuses a session-scoped KA),
// this always returns a new instance to avoid stale facts from prior runs.
func (s *SessionStore) NewKnowledgeAccumulatorForRun(sessionID string) *KnowledgeAccumulator {
s.mu.Lock()
defer s.mu.Unlock()
ka := NewKnowledgeAccumulator()
s.knowledgeAccumulators[sessionID] = ka
return ka
}
// AddResolvedResource adds a resolved resource to a session's context
func (s *SessionStore) AddResolvedResource(sessionID, name string, res *ResolvedResource) {
s.mu.Lock()
defer s.mu.Unlock()
ctx, ok := s.resolvedContexts[sessionID]
if !ok {
ctx = NewResolvedContext(sessionID)
s.resolvedContexts[sessionID] = ctx
}
ctx.AddResource(name, res)
log.Debug().
Str("session_id", sessionID).
Str("name", name).
Str("resource_id", res.ResourceID).
Str("resource_type", res.ResourceType).
Str("target_host", res.TargetHost).
Msg("[SessionStore] Added resolved resource to context")
}
// ValidateResourceForAction validates that a resource can perform an action
// Returns the resolved resource if valid, error if not
func (s *SessionStore) ValidateResourceForAction(sessionID, resourceID, action string) (*ResolvedResource, error) {
s.mu.RLock()
defer s.mu.RUnlock()
ctx, ok := s.resolvedContexts[sessionID]
if !ok {
return nil, &ResourceNotResolvedError{ResourceID: resourceID}
}
if err := ctx.ValidateAction(resourceID, action); err != nil {
return nil, err
}
res, _ := ctx.GetResourceByID(resourceID)
return res, nil
}
// ClearResolvedContext removes the resolved context for a session
func (s *SessionStore) ClearResolvedContext(sessionID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.resolvedContexts, sessionID)
}
// ClearSessionState clears the retained resource and conversation context.
// This is the preferred method when clearing session state.
// - keepPinned=false: Clear resource bindings and retained model context.
// - keepPinned=true: Keep explicitly pinned resources.
func (s *SessionStore) ClearSessionState(sessionID string, keepPinned bool) {
s.mu.Lock()
defer s.mu.Unlock()
// Clear resolved context
ctx, hasCtx := s.resolvedContexts[sessionID]
if hasCtx {
ctx.Clear(keepPinned)
}
if !keepPinned {
delete(s.sessionToolSets, sessionID)
delete(s.knowledgeAccumulators, sessionID)
if err := s.clearModelHandoffContextLocked(sessionID); err != nil {
log.Warn().Err(err).Str("session_id", sessionID).Msg("[SessionStore] Failed to clear model handoff context")
}
}
log.Debug().
Str("session_id", sessionID).
Bool("keep_pinned", keepPinned).
Bool("has_resources", hasCtx && ctx.HasAnyResources()).
Msg("[SessionStore] Cleared session state")
}
// cleanupResolvedContext is called when a session is deleted to also remove its context
func (s *SessionStore) cleanupResolvedContext(sessionID string) {
// Note: caller must NOT hold the lock (or use a separate lock for contexts)
delete(s.resolvedContexts, sessionID)
}
// GetToolSet returns a copy of the tool allowlist for a session, or nil if none set.
func (s *SessionStore) GetToolSet(sessionID string) map[string]bool {
s.mu.RLock()
defer s.mu.RUnlock()
toolSet, ok := s.sessionToolSets[sessionID]
if !ok {
return nil
}
return copyToolSet(toolSet)
}
// SetToolSet stores a tool allowlist for a session.
func (s *SessionStore) SetToolSet(sessionID string, toolSet map[string]bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessionToolSets[sessionID] = copyToolSet(toolSet)
}
// AddToolSet merges tool allowlist entries into the session's tool set.
// Returns a copy of the updated tool set.
func (s *SessionStore) AddToolSet(sessionID string, additions map[string]bool) map[string]bool {
s.mu.Lock()
defer s.mu.Unlock()
toolSet, ok := s.sessionToolSets[sessionID]
if !ok {
toolSet = make(map[string]bool)
}
for name := range additions {
toolSet[name] = true
}
s.sessionToolSets[sessionID] = toolSet
return copyToolSet(toolSet)
}
func copyToolSet(source map[string]bool) map[string]bool {
if source == nil {
return nil
}
out := make(map[string]bool, len(source))
for key, value := range source {
out[key] = value
}
return out
}