Files
pulse/internal/telemetry/pulse_intelligence_evidence.go
T
rcourtman 3420fcdee4 Add schema v17 Patrol provider, cost, autonomy, and outcome telemetry
The 2026-09-01 Patrol assessment could not answer four questions from
telemetry: what share of Patrol installs run local versus cloud models,
what Patrol costs an install per month (issue 1789 estimates ~104k input
tokens per full run at ~5.5 runs a day, above the Pro fee on non-Flash
tiers), how the 240 of 255 fleet investigations that produced no plan
actually ended, and which effective Patrol mode an install runs.

Schema v17 adds closed buckets only. ai_provider_class classifies the
Patrol model route (none, local, cloud_byok, cloud_subscription,
hosted_quickstart, unknown) with a syntactic private-host check on custom
OpenAI-compatible endpoints; it never resolves DNS or carries a provider,
model, endpoint, or account. pulse_intelligence_patrol_autonomy_level is
the effective level after licence and Autopilot gating. The two token
fields bucket the existing local usage ledger's Patrol events, so exact
counts and prices stay on the install. Thirteen investigation outcome
counters partition the findings already counted as investigated, one
bucket per finding, adding no finding, resource, or session identity.

The strings are never omitted, so an empty value can only mean a pre-v17
sender; the receiver stores those as unknown. PRIVACY.md, its shipped
mirror, the Settings preview interface, and the security-privacy and
api-contracts subsystem notes carry the disclosure, and the coverage gap
is registered in status.json.

The agent-lifecycle and storage-recovery contracts name internal/api under
their extension points, so both record that this telemetry is adjacent
adoption analytics and never agent or storage state. The stable E2E tier
pins the public schema version, so the disclosure spec moves to 17, and
the Settings preview interface change carries a browser receipt from a
scratch build of this tree at 1280x800 and 390x844. status.json also drops
an identical duplicate of the ai-provider-guided-setup coverage-gap record
that two merges each appended, which the private governance audit rejects.

The bucket for a legacy config still pointing at the retired Pulse-hosted
route is named hosted_legacy: the shipped privacy document discloses the
vocabulary verbatim and the frontend copy contract keeps that document
free of the retired hosted quickstart wording, so the telemetry package
now pins the vocabulary at the source.
2026-09-02 11:09:25 +01:00

173 lines
5.9 KiB
Go

package telemetry
import (
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
// PulseIntelligenceAIUsageEvidence is the content-free subset of AI usage
// history used to prove Pulse Intelligence loop collaboration.
type PulseIntelligenceAIUsageEvidence struct {
AssistantAICalls int
AssistantContextAICalls int
AssistantToolCalls int
PatrolAICalls int
// PatrolInputTokens and PatrolOutputTokens are the exact local Patrol
// token totals for the window. They are bucketed before export; the
// exact figures never leave the install.
PatrolInputTokens int64
PatrolOutputTokens int64
}
// PulseIntelligenceAIUsageEvidenceFromHistory projects AI usage history into
// the same content-free Pulse Intelligence evidence used by telemetry and the
// agent operations-loop status endpoint.
func PulseIntelligenceAIUsageEvidenceFromHistory(history *config.AIUsageHistoryData, since time.Time) PulseIntelligenceAIUsageEvidence {
var evidence PulseIntelligenceAIUsageEvidence
if history == nil {
return evidence
}
for _, event := range history.Events {
if event.Timestamp.IsZero() || event.Timestamp.Before(since) {
continue
}
switch strings.ToLower(strings.TrimSpace(event.UseCase)) {
case "chat":
evidence.AssistantAICalls++
if PulseIntelligenceAssistantContextEvent(event) {
evidence.AssistantContextAICalls++
}
if event.ToolCallCount > 0 {
evidence.AssistantToolCalls += event.ToolCallCount
}
case "patrol":
evidence.PatrolAICalls++
if event.InputTokens > 0 {
evidence.PatrolInputTokens += int64(event.InputTokens)
}
if event.OutputTokens > 0 {
evidence.PatrolOutputTokens += int64(event.OutputTokens)
}
}
}
return evidence
}
// PulseIntelligenceAssistantContextEvent reports whether a chat usage event
// carried governed context. It deliberately ignores prompt text and responses.
func PulseIntelligenceAssistantContextEvent(event config.AIUsageEventRecord) bool {
return strings.TrimSpace(event.ContextScope) != "" ||
strings.TrimSpace(event.TargetType) != "" ||
strings.TrimSpace(event.TargetID) != "" ||
strings.TrimSpace(event.FindingID) != ""
}
// PulseIntelligenceExternalAgentEvidence is the content-free subset of
// authenticated agent/MCP route activity used to prove external collaboration.
//
// Interpretation constraint: the agent_api surface counts any suitably scoped
// API-token request to manifest-listed routes — including plain REST consumers
// like scripts and dashboards hitting GET /api/config/nodes or
// /api/ai/patrol/findings — so Used does NOT imply a BYO AI agent. Only
// MCPAdapterUsed (the pulse_mcp surface header) certainly identifies an MCP
// client. Do not read Used-vs-MCPAdapterUsed as an adoption funnel of the
// same population.
type PulseIntelligenceExternalAgentEvidence struct {
Used bool
MCPAdapterUsed bool
ContextRequests int
EventStreamRequests int
ProvisioningRequests int
OperatorStateRequests int
FindingRequests int
ActionRequests int
}
// CollaborationActive reports whether an external agent or MCP adapter used a
// manifest-published Pulse Intelligence capability in the evidence window.
func (e PulseIntelligenceExternalAgentEvidence) CollaborationActive() bool {
return e.Used ||
e.MCPAdapterUsed ||
e.ContextRequests > 0 ||
e.EventStreamRequests > 0 ||
e.ProvisioningRequests > 0 ||
e.OperatorStateRequests > 0 ||
e.FindingRequests > 0 ||
e.ActionRequests > 0
}
// CollaborationCount returns a coarse activity count for step-level rollups.
func (e PulseIntelligenceExternalAgentEvidence) CollaborationCount() int {
count := e.ContextRequests +
e.EventStreamRequests +
e.ProvisioningRequests +
e.OperatorStateRequests +
e.FindingRequests +
e.ActionRequests
if count > 0 {
return count
}
if e.Used || e.MCPAdapterUsed {
return 1
}
return 0
}
// PulseIntelligenceExternalAgentEvidenceFromHistory projects external-agent
// activity history into content-free collaboration evidence.
func PulseIntelligenceExternalAgentEvidenceFromHistory(history *config.ExternalAgentActivityHistoryData, since time.Time) PulseIntelligenceExternalAgentEvidence {
var evidence PulseIntelligenceExternalAgentEvidence
if history == nil {
return evidence
}
for _, event := range history.Events {
if event.Timestamp.IsZero() || event.Timestamp.Before(since) {
continue
}
if !PulseIntelligenceExternalAgentActivitySurface(event.Surface) {
continue
}
evidence.Used = true
if strings.TrimSpace(event.Surface) == config.ExternalAgentActivitySurfacePulseMCP {
evidence.MCPAdapterUsed = true
}
evidence.ApplyActivity(event.Activity)
}
return evidence
}
// PulseIntelligenceExternalAgentActivitySurface reports whether a persisted
// activity surface is part of the governed external-agent contract.
func PulseIntelligenceExternalAgentActivitySurface(surface string) bool {
switch strings.TrimSpace(surface) {
case config.ExternalAgentActivitySurfaceAgentAPI, config.ExternalAgentActivitySurfacePulseMCP:
return true
default:
return false
}
}
// ApplyActivity increments the matching content-free external-agent activity
// bucket.
func (e *PulseIntelligenceExternalAgentEvidence) ApplyActivity(activity string) {
if e == nil {
return
}
switch strings.TrimSpace(activity) {
case config.ExternalAgentActivityResourceContext, config.ExternalAgentActivityFleetContext:
e.ContextRequests++
case config.ExternalAgentActivityEventStream:
e.EventStreamRequests++
case config.ExternalAgentActivityProvisioning:
e.ProvisioningRequests++
case config.ExternalAgentActivityOperatorState:
e.OperatorStateRequests++
case config.ExternalAgentActivityFindingList, config.ExternalAgentActivityFindingDecision:
e.FindingRequests++
case config.ExternalAgentActivityActionPlan, config.ExternalAgentActivityActionDecision, config.ExternalAgentActivityActionExecute:
e.ActionRequests++
}
}