package audit import ( "context" "errors" "fmt" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // ErrQueryEmpty is returned by ContextAuditor.Keyword when the query is empty // or blank. Use errors.Is to test for it. var ErrQueryEmpty = errors.New("audit: query is empty") // contextRadius is how many messages on each side of a keyword hit are included // in the surrounding window (one above and one below). const contextRadius = 1 // Located describes a single keyword match within a subagent context. type Located struct { // Line is the 1-based index of the matched message in the full context. Line int // Surrounding is a window centered on the matched message: the message // immediately before it, the matched message, and the message immediately // after it, clamped at the context boundaries. It always contains the // matched message. Surrounding []model.Message } // ContextAuditor provides read-only, traceable access to a subagent's context. // It snapshots the message list at construction so later mutation by the caller // does not change what the auditor reports. It is a value type that holds no // storage or policy; the orchestral layer decides which context to inspect. type ContextAuditor struct { messages []model.Message } // NewContextAuditor snapshots messages so subsequent caller mutations do not // affect the auditor. A nil input is allowed and yields an empty context. func NewContextAuditor(messages []model.Message) ContextAuditor { cp := make([]model.Message, len(messages)) copy(cp, messages) return ContextAuditor{messages: cp} } // Full returns a defensive copy of the entire context. func (a ContextAuditor) Full() []model.Message { cp := make([]model.Message, len(a.messages)) copy(cp, a.messages) return cp } // Tail returns the last n messages of the context in their original order. // // n <= 0 yields an empty, non-nil slice; n >= len returns a copy of the full // context. The result is a defensive copy. func (a ContextAuditor) Tail(n int) []model.Message { if n <= 0 { return []model.Message{} } if n >= len(a.messages) { return a.Full() } start := len(a.messages) - n out := make([]model.Message, n) copy(out, a.messages[start:]) return out } // Keyword locates the first message whose text contains query, using a // case-insensitive substring match against the message's text parts (image // payloads are ignored). It returns the message's 1-based index and a // surrounding window, or nil, nil when there is no match. // // It returns ErrQueryEmpty for a blank query and wraps ctx.Err() when ctx is // canceled. Only the earliest match is reported. func (a ContextAuditor) Keyword(ctx context.Context, query string) (*Located, error) { if strings.TrimSpace(query) == "" { return nil, fmt.Errorf("%w: query is blank", ErrQueryEmpty) } if err := ctx.Err(); err != nil { return nil, fmt.Errorf("audit: keyword: %w", err) } q := strings.ToLower(query) for i, m := range a.messages { if strings.Contains(strings.ToLower(messageText(m)), q) { return &Located{Line: i + 1, Surrounding: a.window(i)}, nil } } return nil, nil } // window returns up to 2*contextRadius+1 messages centered on index i, clamped // to the context boundaries. func (a ContextAuditor) window(i int) []model.Message { lo := i - contextRadius hi := i + contextRadius if lo < 0 { lo = 0 } if hi >= len(a.messages) { hi = len(a.messages) - 1 } if lo > hi { return nil } out := make([]model.Message, 0, hi-lo+1) out = append(out, a.messages[lo:hi+1]...) return out } // messageText returns the concatenated text of a message's text parts. func messageText(m model.Message) string { var b strings.Builder for _, c := range m.Content { if c.Type == model.ContentTypeText && c.Text != "" { b.WriteString(c.Text) } } return b.String() }