// --- // summary: passive pi-event collector mapping tool, compaction, vault, skill, follow-up, and subagent activity to telemetry events. // read_when: // - changing which pi events are captured, payload-free boundaries, or event correlation. // --- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { CAUSAL_SCHEMA_REV, deriveErrorSignature, normalizeSkillName, normalizeToolName, TELEMETRY_SCHEMA_VERSION, type TelemetryEvent, } from "./events.ts"; import { appendTelemetryEvent, resolveTelemetryDir } from "./store.ts"; const MAX_INFLIGHT_TOOL_CALLS = 512; const VAULT_TOOL_PREFIX = "vault_"; const SKILL_PATH_PATTERN = /(^|\/)([^/]+)\/SKILL\.md$/u; interface InflightToolCall { tool: string; startedAt: number; profile?: string; skill?: string; path?: string; completed: boolean; } export interface TelemetryCollectorOptions { dir?: string; env?: NodeJS.ProcessEnv; now?: () => number; sessionId?: () => string | undefined; cwd?: () => string | undefined; append?: (dir: string, event: TelemetryEvent) => Promise; } export function createTelemetryCollector(options: TelemetryOptionsBound = {}): { handle: (event: CollectorEvent, ctx?: CollectorContext) => void; dir: string; } { const dir = options.dir ?? resolveTelemetryDir(options.env); const now = options.now ?? (() => Date.now()); const append = options.append ?? appendTelemetryEvent; const inflight = new Map(); let currentCtx: CollectorContext | undefined; // Compaction correlation (ADR 2026-08-24-pi-0.84.x-adoption P0-A): // composite (sessionId, compactionSeq) keying. The seq counter is scoped to // the active session context and resets whenever the observed sessionId // changes (fork, new, resume-with-new-file); uniqueness comes from the // composite key, never from seq alone. let correlatedSessionId: string | undefined; let compactionSeqCounter = 0; let openCompactionSeq: number | null = null; let failedSinceLastTerminal = false; const observedSessionId = (): string | undefined => options.sessionId?.() ?? withSessionId(currentCtx).sessionId; const resetCorrelationIfSessionChanged = (): void => { const sid = observedSessionId(); if (sid === correlatedSessionId) return; correlatedSessionId = sid; compactionSeqCounter = 0; openCompactionSeq = null; failedSinceLastTerminal = false; }; const base = (extra: Record = {}): Record => ({ v: TELEMETRY_SCHEMA_VERSION, ts: now(), ...(options.sessionId?.() ? { sessionId: options.sessionId() } : withSessionId(currentCtx)), ...(options.cwd?.() ? { cwd: options.cwd() } : readCtxCwd(currentCtx)), ...extra, }); const record = (event: TelemetryEvent): void => { void append(dir, event).catch(() => { // Telemetry must never break the host session. }); }; const handleToolCall = (event: CollectorEvent, ctx?: CollectorContext): void => { const toolCallId = event.toolCallId; if (!toolCallId) return; if (inflight.size >= MAX_INFLIGHT_TOOL_CALLS) { const oldest = inflight.keys().next().value; if (oldest !== undefined) inflight.delete(oldest); } const tool = normalizeToolName(event.toolName); const input = (event.input ?? {}) as Record; const rawPath = typeof input.path === "string" ? input.path : undefined; const skillMatch = rawPath ? SKILL_PATH_PATTERN.exec(rawPath) : null; inflight.set(toolCallId, { tool, startedAt: now(), completed: false, ...(tool === "dispatch_subagent" && typeof input.profile === "string" ? { profile: input.profile.slice(0, 40) } : {}), ...(skillMatch ? { skill: normalizeSkillName(skillMatch[2]), path: rawPath } : {}), }); void ctx; }; const handleToolCompletion = ( event: CollectorEvent & { toolCallId?: string; isError?: boolean; content?: unknown; result?: unknown; }, ): void => { const toolCallId = event.toolCallId; if (!toolCallId) return; const inflightEntry = inflight.get(toolCallId); if (!inflightEntry || inflightEntry.completed) return; inflightEntry.completed = true; inflight.delete(toolCallId); const durationMs = Math.max(0, now() - inflightEntry.startedAt); const ok = event.isError !== true; const errorSignature = ok ? undefined : deriveErrorSignature(readErrorText(event)); if (inflightEntry.skill) { record({ ...(base({ kind: "skill_load", skill: inflightEntry.skill }) as unknown as TelemetryEvent), }); } if (inflightEntry.tool.startsWith(VAULT_TOOL_PREFIX)) { record({ ...(base({ kind: "vault_query", tool: inflightEntry.tool, ok, durationMs, ...(errorSignature ? { errorSignature } : {}), }) as unknown as TelemetryEvent), }); } else { record({ ...(base({ kind: "tool_call", tool: inflightEntry.tool, ok, durationMs, ...(errorSignature ? { errorSignature } : {}), }) as unknown as TelemetryEvent), }); } if (inflightEntry.tool === "self") { const followUp = extractFollowUpOutcome(event.result); if (followUp) record({ ...(base({ kind: "follow_up", ...followUp }) as unknown as TelemetryEvent) }); } if (inflightEntry.tool === "dispatch_subagent") { record({ ...(base({ kind: "subagent", profile: inflightEntry.profile ?? "unknown", ok, durationMs, ...(errorSignature ? { errorSignature } : {}), }) as unknown as TelemetryEvent), }); } }; const handle = (event: CollectorEvent, ctx?: CollectorContext): void => { currentCtx = ctx; try { resetCorrelationIfSessionChanged(); switch (event.type) { case "tool_call": handleToolCall(event, ctx); return; case "tool_execution_end": case "tool_result": handleToolCompletion(event); return; case "turn_start": record({ ...(base({ kind: "turn", index: typeof event.turnIndex === "number" ? event.turnIndex : -1, }) as unknown as TelemetryEvent), }); return; case "session_before_compact": { compactionSeqCounter += 1; openCompactionSeq = compactionSeqCounter; // NOTE: failedSinceLastTerminal intentionally persists across a retry // begin so the eventual terminal success can be marked retriedAfterFailure. record({ ...(base({ kind: "compaction_begin", reason: typeof event.reason === "string" ? event.reason : "unknown", willRetry: event.willRetry === true, compactionSeq: compactionSeqCounter, rev: CAUSAL_SCHEMA_REV, }) as unknown as TelemetryEvent), }); return; } case "session_compact": { const entry = (event.compactionEntry ?? {}) as Record; const seq = openCompactionSeq; const retriedAfterFailure = failedSinceLastTerminal && seq !== null; openCompactionSeq = null; failedSinceLastTerminal = false; record({ ...(base({ kind: "compaction", reason: typeof event.reason === "string" ? event.reason : "unknown", willRetry: event.willRetry === true, fromExtension: event.fromExtension === true, ...(typeof entry.tokensBefore === "number" ? { tokensBefore: entry.tokensBefore } : {}), ...(typeof entry.summary === "string" ? { summaryChars: entry.summary.length } : {}), // Causal-era correlation; orphan success (no prior begin under this // session context) stays seq-less for legacy-shape compatibility. ...(seq !== null ? { compactionSeq: seq } : {}), ...(retriedAfterFailure ? { retriedAfterFailure: true } : {}), ...(seq !== null ? { rev: CAUSAL_SCHEMA_REV } : {}), }) as unknown as TelemetryEvent), }); return; } case "session_compact_failed": { const aborted = event.aborted === true; const willRetry = event.willRetry === true; const recoverable = aborted && willRetry; const orphan = openCompactionSeq === null; const seq = openCompactionSeq; openCompactionSeq = null; failedSinceLastTerminal = !orphan; const errorSignature = deriveErrorSignature( typeof event.errorMessage === "string" ? event.errorMessage : "", ) ?? (aborted ? "aborted without error text" : "unknown error"); record({ ...(base({ kind: "compaction_failure", stage: "host", errorSignature, reason: typeof event.reason === "string" ? event.reason : "unknown", aborted, orphan, ...(recoverable ? { recoverable: true } : {}), ...(seq !== null ? { compactionSeq: seq } : {}), rev: CAUSAL_SCHEMA_REV, }) as unknown as TelemetryEvent), }); return; } default: return; } } catch { // Telemetry is best-effort: never let a capture failure (including stale // host ctx proxies after session replacement) break the host session. } }; return { handle, dir }; } export interface CollectorEvent { type: string; toolName?: string; toolCallId?: string; input?: unknown; isError?: boolean; content?: unknown; result?: unknown; turnIndex?: number; reason?: string; willRetry?: boolean; fromExtension?: boolean; errorMessage?: string; aborted?: boolean; compactionEntry?: unknown; } export interface CollectorContext { cwd?: string; sessionManager?: unknown; } function readCtxCwd(ctx: CollectorContext | undefined): { cwd?: string } { try { return typeof ctx?.cwd === "string" && ctx.cwd.trim() ? { cwd: ctx.cwd } : {}; } catch { // Host ctx proxies can throw after session replacement/reload; identity is best-effort. return {}; } } function withSessionId(ctx: CollectorContext | undefined): { sessionId?: string } { try { const manager = ctx?.sessionManager as Record | undefined; const getSessionFile = manager?.getSessionFile; if (typeof getSessionFile !== "function") return {}; const file = (getSessionFile as () => unknown).call(manager); if (typeof file === "string" && file.trim()) { const base = file.split("/").pop() ?? ""; return base ? { sessionId: base } : {}; } } catch { // Session identity is best-effort; stall detection degrades to cross-session matching. // Host ctx proxies may also reject property access entirely after session replacement. } return {}; } export interface TelemetryOptionsBound { dir?: string; env?: NodeJS.ProcessEnv; now?: () => number; sessionId?: () => string | undefined; cwd?: () => string | undefined; append?: (dir: string, event: TelemetryEvent) => Promise; } interface FollowUpOutcome { sent: boolean; dispatchMode: string; blockedReason?: string; } /** * Reads the self tool's delivery outcome from its tool result details. * Payload-free: only the typed delivery booleans/modes are extracted. */ function readErrorText(event: { content?: unknown; result?: unknown }): unknown { if (event.content) return event.content; const result = event.result; if (result && typeof result === "object" && !Array.isArray(result)) { const inner = (result as { content?: unknown }).content; if (inner) return inner; } return result; } export function extractFollowUpOutcome(result: unknown): FollowUpOutcome | undefined { const data = readSelfResultData(result); if (!data) return undefined; const hasDeliveryField = data.userMessageSent === true || data.userMessageSent === false || typeof data.userMessageBlockedReason === "string"; if (!hasDeliveryField) return undefined; return { sent: data.userMessageSent === true, dispatchMode: typeof data.dispatchMode === "string" ? data.dispatchMode : "unknown", ...(typeof data.userMessageBlockedReason === "string" ? { blockedReason: data.userMessageBlockedReason } : {}), }; } function readSelfResultData(result: unknown): Record | undefined { if (!result || typeof result !== "object" || Array.isArray(result)) return undefined; const details = (result as { details?: unknown }).details; if (!details || typeof details !== "object" || Array.isArray(details)) return undefined; const data = (details as { data?: unknown }).data; if (!data || typeof data !== "object" || Array.isArray(data)) return undefined; return data as Record; } /** * Wire the collector into a live pi extension API. */ export function registerTelemetryCollector( pi: ExtensionAPI, options: TelemetryOptionsBound = {}, ): void { if (options.env?.PI_TELEMETRY_DISABLED === "1" && !options.dir) return; const collector = createTelemetryCollector(options); const handler = (event: CollectorEvent, ctx?: CollectorContext): void => collector.handle(event, ctx); pi.on("tool_call", handler as never); pi.on("tool_result", handler as never); pi.on("tool_execution_end", handler as never); pi.on("turn_start", handler as never); pi.on("session_before_compact", handler as never); pi.on("session_compact", handler as never); // Event name cast: local peer typings (0.83.0) predate session_compact_failed; // the live host (>= 0.84.3) registers it natively. pi.on("session_compact_failed" as never, handler as never); }