Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

633 lines
26 KiB
Go

// ABOUTME: The six read-only MCP tools, implemented as thin adapters
// ABOUTME: over service.SessionService (the same seam the CLI and HTTP
// ABOUTME: handlers use).
package mcp
import (
"context"
"fmt"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/service"
)
// toolset holds the dependencies shared by every tool handler. now is
// injectable so tests can control the self-reference exclusion window.
type toolset struct {
svc service.SessionService
now func() time.Time
}
func (t *toolset) clock() time.Time {
if t.now != nil {
return t.now()
}
return time.Now()
}
func strval(p *string) string {
if p == nil {
return ""
}
return *p
}
// sessionActivity returns a session's most-recent activity timestamp,
// mirroring the DB's canonical activity expression: prefer ended_at, then
// started_at (each only when non-empty), then created_at. created_at is
// always set, so a freshly created/synced session with no parsed start/end
// is still treated as active rather than slipping past the self-reference
// guard.
func sessionActivity(s db.Session) string {
if ts := strval(s.EndedAt); ts != "" {
return ts
}
if ts := strval(s.StartedAt); ts != "" {
return ts
}
return s.CreatedAt
}
// isSystemMessage reports whether a message is system content that
// get_messages and the overview tail must never surface: the persisted
// is_system flag, or a legacy system prefix on a user message (the prefix
// rule catches older sessions parsed before is_system was backfilled).
func isSystemMessage(m db.Message) bool {
return m.IsSystem || db.IsSystemPrefixed(m.Content, m.Role)
}
// lookupActivity returns a session's activity timestamp (ended_at, then
// started_at, then created_at) via the service, cached per call so each
// session is fetched at most once. ok is false when the lookup fails, so
// callers can pick their own fallback.
func (t *toolset) lookupActivity(
ctx context.Context, id string, cache map[string]string,
) (string, bool) {
if a, ok := cache[id]; ok {
return a, true
}
if d, err := t.svc.Get(ctx, id); err == nil && d != nil {
a := sessionActivity(d.Session)
cache[id] = a
return a, true
}
return "", false
}
// --- search_sessions ---
type searchSessionsIn struct {
Query string `json:"query" jsonschema:"Search terms across all agent sessions. Every term must appear (AND), not an exact phrase; wrap the whole query in double quotes for an exact phrase, e.g. \"build failed\". Punctuation in a term (hyphens, colons) is handled safely."`
Project string `json:"project,omitempty" jsonschema:"Restrict to one project (repo/directory name)."`
Sort string `json:"sort,omitempty" jsonschema:"relevance (default) or recency."`
Limit int `json:"limit,omitempty" jsonschema:"Max results, default 10, max 30."`
Cursor int `json:"cursor,omitempty" jsonschema:"Pagination cursor from a previous next_cursor."`
IncludeActive bool `json:"include_active,omitempty" jsonschema:"Include sessions active in the last 10 minutes. Default false: the conversation you are in right now is also recorded, so without this exclusion you would find yourself."`
}
type sessionHit struct {
SessionID string `json:"session_id"`
Project string `json:"project,omitempty"`
Agent string `json:"agent"`
Name string `json:"name"`
EndedAt string `json:"ended_at"`
Snippet string `json:"snippet"`
MatchOrdinal int `json:"match_ordinal"`
}
type searchSessionsOut struct {
Results []sessionHit `json:"results"`
NextCursor *int `json:"next_cursor,omitempty"`
ExcludedActive int `json:"excluded_active,omitempty"`
}
func (t *toolset) searchSessions(
ctx context.Context, _ *mcp.CallToolRequest, in searchSessionsIn,
) (*mcp.CallToolResult, searchSessionsOut, error) {
res, err := t.svc.Search(ctx, service.SearchRequest{
Query: buildSearchQuery(in.Query),
Project: in.Project,
Sort: in.Sort,
Cursor: in.Cursor,
Limit: clampLimit(in.Limit, defaultSearchLimit, maxSearchLimit),
})
if err != nil {
return nil, searchSessionsOut{}, err
}
now := t.clock()
activity := make(map[string]string)
out := searchSessionsOut{Results: make([]sessionHit, 0, len(res.Results))}
for _, r := range res.Results {
if !in.IncludeActive {
act := r.SessionEndedAt
if act == "" {
// Search results COALESCE only ended_at/started_at; a current,
// timestampless session has an empty SessionEndedAt, so fall
// back to created_at via a lookup like search_content does.
if a, ok := t.lookupActivity(ctx, r.SessionID, activity); ok {
act = a
}
}
if isActiveSince(act, now) {
out.ExcludedActive++
continue
}
}
name, _ := truncate(r.Name, nameMaxChars)
out.Results = append(out.Results, sessionHit{
SessionID: r.SessionID,
Project: r.Project,
Agent: r.Agent,
Name: name,
EndedAt: r.SessionEndedAt,
Snippet: r.Snippet,
MatchOrdinal: r.Ordinal,
})
}
if res.NextCursor > 0 && len(res.Results) > 0 {
nc := res.NextCursor
out.NextCursor = &nc
}
return nil, out, nil
}
// --- list_sessions ---
type listSessionsIn struct {
Project string `json:"project,omitempty" jsonschema:"Filter by project name."`
Agent string `json:"agent,omitempty" jsonschema:"Filter by agent (e.g. claude, codex, gemini, antigravity)."`
Machine string `json:"machine,omitempty" jsonschema:"Filter by machine name."`
DateFrom string `json:"date_from,omitempty" jsonschema:"Only sessions on or after this date (YYYY-MM-DD)."`
DateTo string `json:"date_to,omitempty" jsonschema:"Only sessions on or before this date (YYYY-MM-DD)."`
ActiveSince string `json:"active_since,omitempty" jsonschema:"Only sessions active since this RFC3339 timestamp."`
IncludeAutomated bool `json:"include_automated,omitempty" jsonschema:"Include automated (non-interactive) sessions."`
Limit int `json:"limit,omitempty" jsonschema:"Max results, default 20, max 100."`
Cursor string `json:"cursor,omitempty" jsonschema:"Pagination cursor from a previous next_cursor."`
}
type sessionRow struct {
SessionID string `json:"session_id"`
Project string `json:"project,omitempty"`
Machine string `json:"machine,omitempty"`
Agent string `json:"agent"`
Name string `json:"name"`
StartedAt string `json:"started_at"`
EndedAt string `json:"ended_at"`
MessageCount int `json:"message_count" jsonschema:"Total stored messages for the session across all roles, including system messages. get_messages always drops system messages, so no roles filter makes its returned count match this; reconcile instead via message_count = returned + filtered summed over a full get_messages pagination sweep."`
UserMessageCount int `json:"user_message_count" jsonschema:"Stored user-role messages, excluding those flagged as system-injected."`
OutputTokens int64 `json:"output_tokens,omitempty"`
Outcome string `json:"outcome,omitempty"`
HealthGrade string `json:"health_grade,omitempty"`
GitBranch string `json:"git_branch,omitempty"`
}
type listSessionsOut struct {
Sessions []sessionRow `json:"sessions"`
Total int `json:"total"`
NextCursor string `json:"next_cursor,omitempty"`
}
func (t *toolset) listSessions(
ctx context.Context, _ *mcp.CallToolRequest, in listSessionsIn,
) (*mcp.CallToolResult, listSessionsOut, error) {
res, err := t.svc.List(ctx, service.ListFilter{
Project: in.Project,
Agent: in.Agent,
Machine: in.Machine,
DateFrom: in.DateFrom,
DateTo: in.DateTo,
ActiveSince: in.ActiveSince,
IncludeAutomated: in.IncludeAutomated,
Cursor: in.Cursor,
Limit: clampLimit(in.Limit, defaultListLimit, maxListLimit),
})
if err != nil {
return nil, listSessionsOut{}, err
}
out := listSessionsOut{
Sessions: make([]sessionRow, 0, len(res.Sessions)),
Total: res.Total,
NextCursor: res.NextCursor,
}
for _, s := range res.Sessions {
out.Sessions = append(out.Sessions, toSessionRow(s))
}
return nil, out, nil
}
func toSessionRow(s db.Session) sessionRow {
name := strval(s.DisplayName)
if name == "" {
name = strval(s.FirstMessage)
}
name, _ = truncate(name, nameMaxChars)
return sessionRow{
SessionID: s.ID,
Project: s.Project,
Machine: s.Machine,
Agent: s.Agent,
Name: name,
StartedAt: strval(s.StartedAt),
EndedAt: strval(s.EndedAt),
MessageCount: s.MessageCount,
UserMessageCount: s.UserMessageCount,
OutputTokens: int64(s.TotalOutputTokens),
Outcome: s.Outcome,
HealthGrade: strval(s.HealthGrade),
GitBranch: s.GitBranch,
}
}
// --- get_session_overview ---
type sessionOverviewIn struct {
SessionID string `json:"session_id" jsonschema:"The session to summarize."`
}
type overviewMessage struct {
Ordinal int `json:"ordinal"`
Role string `json:"role"`
Content string `json:"content"`
Truncated bool `json:"truncated,omitempty"`
}
type sessionOverviewOut struct {
Session sessionRow `json:"session"`
CWD string `json:"cwd,omitempty"`
IsAutomated bool `json:"is_automated"`
FirstMessage string `json:"first_message"`
LastMessages []overviewMessage `json:"last_messages"`
}
func (t *toolset) sessionOverview(
ctx context.Context, _ *mcp.CallToolRequest, in sessionOverviewIn,
) (*mcp.CallToolResult, sessionOverviewOut, error) {
detail, err := t.svc.Get(ctx, in.SessionID)
if err != nil {
return nil, sessionOverviewOut{}, err
}
if detail == nil {
return nil, sessionOverviewOut{}, fmt.Errorf(
"session not found: %s", in.SessionID)
}
out := sessionOverviewOut{
Session: toSessionRow(detail.Session),
CWD: detail.Cwd,
IsAutomated: detail.IsAutomated,
FirstMessage: strval(detail.FirstMessage),
}
// Tail of the conversation: how did it end? desc returns
// newest-first; collect the newest few then restore chronological
// order for display.
msgs, err := t.svc.Messages(ctx, in.SessionID, service.MessageFilter{
Direction: "desc",
Limit: overviewTailFetch,
})
if err != nil {
return nil, sessionOverviewOut{}, err
}
for _, m := range msgs.Messages {
if isSystemMessage(m) || !roleAllowed(m.Role, nil) {
continue
}
content, cut := truncate(m.Content, overviewMaxChars)
out.LastMessages = append(out.LastMessages, overviewMessage{
Ordinal: m.Ordinal, Role: m.Role, Content: content, Truncated: cut,
})
if len(out.LastMessages) == overviewLastMessages {
break
}
}
for i, j := 0, len(out.LastMessages)-1; i < j; i, j = i+1, j-1 {
out.LastMessages[i], out.LastMessages[j] =
out.LastMessages[j], out.LastMessages[i]
}
return nil, out, nil
}
// --- get_messages ---
type getMessagesIn struct {
SessionID string `json:"session_id" jsonschema:"The session to read."`
From *int `json:"from,omitempty" jsonschema:"Ordinal to start from (e.g. match_ordinal from search_sessions). Ordinal 0 is a valid anchor (the first message). Mutually exclusive with around."`
Direction string `json:"direction,omitempty" jsonschema:"asc (default, oldest first) or desc (newest first). Mutually exclusive with around."`
Limit int `json:"limit,omitempty" jsonschema:"Max messages scanned, default 20, max 100. System/tool messages are filtered after this limit, so a page can return fewer; use next_from to continue. Not used with around; before/after control the window size on that path."`
// Around switches to a symmetric window centered on an ordinal instead
// of linear pagination; it is mutually exclusive with From/Direction.
Around *int `json:"around,omitempty" jsonschema:"Center a symmetric window on this ordinal (e.g. match_ordinal from search_content or search_sessions), instead of linear pagination. Mutually exclusive with from and direction."`
Before *int `json:"before,omitempty" jsonschema:"Messages of context before the around anchor, default 5. Requires around; replaces limit on that path."`
After *int `json:"after,omitempty" jsonschema:"Messages of context after the around anchor, default 5. Requires around; replaces limit on that path."`
Roles []string `json:"roles,omitempty" jsonschema:"Roles to include, e.g. tool. Default: user and assistant only. System messages are always excluded."`
MaxCharsPerMessage int `json:"max_chars_per_message,omitempty" jsonschema:"Truncate each message to this many characters, default 2000, max 20000."`
}
type messageOut struct {
Ordinal int `json:"ordinal"`
Role string `json:"role"`
Content string `json:"content"`
Timestamp string `json:"timestamp,omitempty"`
Model string `json:"model,omitempty"`
HasToolUse bool `json:"has_tool_use,omitempty"`
FullLength int `json:"full_length,omitempty"`
Truncated bool `json:"truncated,omitempty"`
}
type getMessagesOut struct {
Messages []messageOut `json:"messages"`
Filtered int `json:"filtered,omitempty" jsonschema:"How many of this page's scanned messages were excluded by the role/system filter (omitted when zero). Summed across a full pagination sweep, filtered plus the returned messages adds up to the session's message_count."`
// NextFrom is set off the last scanned ordinal (not the last visible
// one), so paging stays reliable even though filtering can make a page
// return fewer than the limit.
NextFrom *int `json:"next_from,omitempty" jsonschema:"Anchor for the next page's from parameter when more messages may remain; absent means the end. Filtering can make a page return fewer than limit messages, so keep paging until next_from is absent, not until a page comes back short."`
}
// filterAndMapMessage applies the get_messages role/system contract to one
// message -- always drop system content (IsSystem or a legacy system
// content prefix), then require its role to be in roles (nil/empty roles
// falls back to user+assistant via roleAllowed) -- and shapes the survivor
// into a messageOut truncated to maxChars. ok is false when the message was
// filtered, so the caller can count it.
func filterAndMapMessage(m db.Message, roles []string, maxChars int) (messageOut, bool) {
if isSystemMessage(m) || !roleAllowed(m.Role, roles) {
return messageOut{}, false
}
content, cut := truncate(m.Content, maxChars)
mo := messageOut{
Ordinal: m.Ordinal,
Role: m.Role,
Content: content,
Timestamp: m.Timestamp,
Model: m.Model,
HasToolUse: m.HasToolUse,
Truncated: cut,
}
if cut {
mo.FullLength = m.ContentLength
}
return mo, true
}
func (t *toolset) getMessages(
ctx context.Context, _ *mcp.CallToolRequest, in getMessagesIn,
) (*mcp.CallToolResult, getMessagesOut, error) {
if in.Around != nil {
return t.getMessagesAround(ctx, in)
}
// From is a *int so an explicit ordinal 0 (a valid match_ordinal)
// anchors at the first message rather than being mistaken for
// "omitted". A nil From lets the service default: desc to
// newest-first, asc to oldest-first. Before/After are forwarded
// unused so the service can reject them with "before/after require
// around" when set without Around.
limit := clampLimit(in.Limit, defaultMessageLimit, maxMessageLimit)
res, err := t.svc.Messages(ctx, in.SessionID, service.MessageFilter{
From: in.From,
Direction: in.Direction,
Limit: limit,
Before: in.Before,
After: in.After,
})
if err != nil {
return nil, getMessagesOut{}, err
}
maxChars := clampLimit(
in.MaxCharsPerMessage, defaultMaxCharsPerMessage, maxMaxCharsPerMessage)
out := getMessagesOut{Messages: make([]messageOut, 0, len(res.Messages))}
for _, m := range res.Messages {
mo, ok := filterAndMapMessage(m, in.Roles, maxChars)
if !ok {
out.Filtered++
continue
}
out.Messages = append(out.Messages, mo)
}
// A full raw page means more rows may remain. Anchor next_from just past
// the last scanned ordinal in the scan direction (asc moves up, desc
// moves down) so the caller can continue; the From anchor is inclusive,
// so +/-1 avoids re-returning the boundary message.
if limit > 0 && len(res.Messages) == limit {
last := res.Messages[len(res.Messages)-1].Ordinal
next := last + 1
if in.Direction == "desc" {
next = last - 1
}
if next >= 0 {
out.NextFrom = &next
}
}
return nil, out, nil
}
// getMessagesAround handles the symmetric-window retrieval path (Around
// set). Unlike the linear path, an empty Roles must be translated to the
// MCP default (user, assistant) before it reaches the service: an empty
// service.MessageFilter.Roles means "all roles" there, not "the MCP
// default", and would leak tool/system-role dumps into the window. From and
// Direction are forwarded unused so the service can reject them as mutually
// exclusive with Around. The service's around window always includes the
// anchor row regardless of role, so it is subject to the same
// filterAndMapMessage post-filter as every other message; a suppressed
// anchor is counted in Filtered like any other dropped message. next_from
// is anchored on the last returned ordinal (there is no scan direction in a
// symmetric window), and Limit/Before/After select the window, not the
// linear path's Limit.
func (t *toolset) getMessagesAround(
ctx context.Context, in getMessagesIn,
) (*mcp.CallToolResult, getMessagesOut, error) {
roles := in.Roles
if len(roles) == 0 {
roles = []string{"user", "assistant"}
}
res, err := t.svc.Messages(ctx, in.SessionID, service.MessageFilter{
From: in.From,
Direction: in.Direction,
Around: in.Around,
Before: in.Before,
After: in.After,
Roles: roles,
})
if err != nil {
return nil, getMessagesOut{}, err
}
maxChars := clampLimit(
in.MaxCharsPerMessage, defaultMaxCharsPerMessage, maxMaxCharsPerMessage)
out := getMessagesOut{Messages: make([]messageOut, 0, len(res.Messages))}
for _, m := range res.Messages {
mo, ok := filterAndMapMessage(m, roles, maxChars)
if !ok {
out.Filtered++
continue
}
out.Messages = append(out.Messages, mo)
}
if len(out.Messages) > 0 {
next := out.Messages[len(out.Messages)-1].Ordinal + 1
out.NextFrom = &next
}
return nil, out, nil
}
// --- search_content ---
type searchContentIn struct {
Pattern string `json:"pattern" jsonschema:"Exact substring or regex to find across message text and tool inputs/results."`
Mode string `json:"mode,omitempty" jsonschema:"substring (default), regex, semantic, or hybrid."`
Scope string `json:"scope,omitempty" jsonschema:"Semantic/hybrid result scope: top, all, or subordinate (default all). Only valid with mode semantic or hybrid."`
Project string `json:"project,omitempty" jsonschema:"Restrict to one project."`
Agent string `json:"agent,omitempty" jsonschema:"Restrict to one agent."`
DateFrom string `json:"date_from,omitempty" jsonschema:"Only sessions on or after this date (YYYY-MM-DD)."`
DateTo string `json:"date_to,omitempty" jsonschema:"Only sessions on or before this date (YYYY-MM-DD)."`
Limit int `json:"limit,omitempty" jsonschema:"Max matches, default 10, max 30."`
Cursor int `json:"cursor,omitempty" jsonschema:"Pagination cursor from a previous next_cursor."`
IncludeActive bool `json:"include_active,omitempty" jsonschema:"Include matches from sessions active in the last 10 minutes. Default false: the conversation you are in right now is also recorded, so without this exclusion you would find yourself."`
Context int `json:"context,omitempty" jsonschema:"Messages of context before/after each match (max 10)."`
}
// contextMessage is a truncated view of a service-level db.Message, used
// for search_content's inline context_before/context_after.
type contextMessage struct {
Ordinal int `json:"ordinal"`
Role string `json:"role"`
Content string `json:"content"`
}
func toContextMessages(msgs []db.Message) []contextMessage {
if len(msgs) == 0 {
return nil
}
out := make([]contextMessage, 0, len(msgs))
for _, m := range msgs {
content, _ := truncate(m.Content, contextMessageMaxChars)
out = append(out, contextMessage{
Ordinal: m.Ordinal, Role: m.Role, Content: content,
})
}
return out
}
type contentMatch struct {
SessionID string `json:"session_id"`
Project string `json:"project,omitempty"`
Agent string `json:"agent"`
Location string `json:"location" jsonschema:"Where the match occurred: one of message, tool_input, or tool_result."`
Role string `json:"role,omitempty"`
Ordinal int `json:"ordinal"`
Timestamp string `json:"timestamp"`
Snippet string `json:"snippet"`
Score *float64 `json:"score,omitempty" jsonschema:"Relevance score for semantic/hybrid modes; omitted for substring/regex/fts."`
OrdinalRange [2]int `json:"ordinal_range" jsonschema:"[start, end] ordinals of the conversation unit containing this match; equal to the match ordinal for single-message units."`
Subordinate bool `json:"subordinate,omitempty" jsonschema:"True when this match belongs to a subordinate unit: a sidechain run, or a subagent/fork session."`
Relationship string `json:"relationship,omitempty" jsonschema:"The matched session's relationship to its parent (for example subagent or fork), when it has one."`
ParentSessionID string `json:"parent_session_id,omitempty" jsonschema:"The parent session ID, when the matched session has one."`
Sidechain bool `json:"is_sidechain,omitempty" jsonschema:"True when the matched message itself is flagged as a sidechain message."`
// ContextBefore/ContextAfter are populated when Context > 0: the N
// messages immediately before/after this match, content truncated to
// 500 characters.
ContextBefore []contextMessage `json:"context_before,omitempty"`
ContextAfter []contextMessage `json:"context_after,omitempty"`
}
type searchContentOut struct {
Matches []contentMatch `json:"matches"`
NextCursor *int `json:"next_cursor,omitempty"`
ExcludedActive int `json:"excluded_active,omitempty"`
}
func (t *toolset) searchContent(
ctx context.Context, _ *mcp.CallToolRequest, in searchContentIn,
) (*mcp.CallToolResult, searchContentOut, error) {
// The db layer silently ignores Scope outside semantic/hybrid, so reject
// it here with the same message the HTTP transport uses
// (internal/server/huma_routes_search.go).
if in.Scope != "" && in.Mode != "semantic" && in.Mode != "hybrid" {
return nil, searchContentOut{}, fmt.Errorf(
"scope is only supported for semantic and hybrid search modes")
}
res, err := t.svc.SearchContent(ctx, service.ContentSearchRequest{
Pattern: in.Pattern,
Mode: in.Mode,
Scope: in.Scope,
Project: in.Project,
Agent: in.Agent,
DateFrom: in.DateFrom,
DateTo: in.DateTo,
Limit: clampLimit(in.Limit, defaultSearchLimit, maxSearchLimit),
Cursor: in.Cursor,
Context: in.Context,
})
if err != nil {
return nil, searchContentOut{}, err
}
now := t.clock()
// The self-reference guard excludes matches from sessions active in the
// last 10 minutes. A match carries only its own timestamp, but a
// long-running session can match on an old message while still being
// active now, so exclude by the session's activity (ended_at, falling
// back to started_at) like search_sessions does -- not by the match
// timestamp. Activity is looked up once per session and cached.
activity := make(map[string]string, len(res.Matches))
out := searchContentOut{Matches: make([]contentMatch, 0, len(res.Matches))}
for _, m := range res.Matches {
if !in.IncludeActive {
ts, ok := t.lookupActivity(ctx, m.SessionID, activity)
if !ok {
// Lookup failed: fall back to the match timestamp so the
// guard degrades to its prior behavior rather than erroring.
ts = m.Timestamp
}
if isActiveSince(ts, now) {
out.ExcludedActive++
continue
}
}
out.Matches = append(out.Matches, contentMatch{
SessionID: m.SessionID, Project: m.Project, Agent: m.Agent,
Location: m.Location, Role: m.Role, Ordinal: m.Ordinal,
Timestamp: m.Timestamp, Snippet: m.Snippet, Score: m.Score,
OrdinalRange: m.OrdinalRange, Subordinate: m.Subordinate,
Relationship: m.Relationship, ParentSessionID: m.ParentSessionID,
Sidechain: m.Sidechain,
ContextBefore: toContextMessages(m.ContextBefore),
ContextAfter: toContextMessages(m.ContextAfter),
})
}
if res.NextCursor > 0 && len(res.Matches) > 0 {
nc := res.NextCursor
out.NextCursor = &nc
}
return nil, out, nil
}
// --- get_usage_summary ---
type usageSummaryIn struct {
From string `json:"from,omitempty" jsonschema:"Range start date (YYYY-MM-DD)."`
To string `json:"to,omitempty" jsonschema:"Range end date (YYYY-MM-DD)."`
Project string `json:"project,omitempty" jsonschema:"Filter by project."`
Agent string `json:"agent,omitempty" jsonschema:"Filter by agent."`
Machine string `json:"machine,omitempty" jsonschema:"Filter by machine."`
}
func (t *toolset) usageSummary(
ctx context.Context, _ *mcp.CallToolRequest, in usageSummaryIn,
) (*mcp.CallToolResult, *service.UsageSummaryResult, error) {
res, err := t.svc.UsageSummary(ctx, service.UsageRequest{
From: in.From,
To: in.To,
Project: in.Project,
Agent: in.Agent,
Machine: in.Machine,
// The usage summary surface counts one-shot sessions by default
// (matching the REST endpoint), since cost analysis wants every
// session, not just multi-turn ones.
IncludeOneShot: true,
})
if err != nil {
return nil, nil, err
}
return nil, res, nil
}