diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 71c45e9c8..0890bfa10 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -74,4 +74,18 @@ export { type TurnStartEvent, type WorkingIndicatorOptions, } from "./extensions/index.ts"; +export { + hashSessionAnalyticsString, + type ProjectSessionAnalyticsOptions, + type ProjectSessionHeaderAnalyticsOptions, + projectSessionEntryForAnalytics, + projectSessionForAnalytics, + projectSessionHeaderForAnalytics, + SESSION_ANALYTICS_SCHEMA_VERSION, + type SessionAnalyticsContentStats, + type SessionAnalyticsEntryRecord, + type SessionAnalyticsRecord, + type SessionAnalyticsSessionRecord, + type SessionAnalyticsUsage, +} from "./session-analytics.ts"; export { createSyntheticSourceInfo } from "./source-info.ts"; diff --git a/packages/coding-agent/src/core/session-analytics.ts b/packages/coding-agent/src/core/session-analytics.ts new file mode 100644 index 000000000..84378d93a --- /dev/null +++ b/packages/coding-agent/src/core/session-analytics.ts @@ -0,0 +1,337 @@ +import { createHash } from "crypto"; +import type { SessionEntry, SessionHeader } from "./session-manager.ts"; + +export const SESSION_ANALYTICS_SCHEMA_VERSION = 1; + +export interface SessionAnalyticsUsage { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + costInput: number; + costOutput: number; + costCacheRead: number; + costCacheWrite: number; + costTotal: number; +} + +export interface SessionAnalyticsContentStats { + stringContent: boolean; + textBlocks: number; + imageBlocks: number; + thinkingBlocks: number; + redactedThinkingBlocks: number; + toolCallBlocks: number; + otherBlocks: number; +} + +export interface SessionAnalyticsSessionRecord { + recordType: "session"; + schemaVersion: typeof SESSION_ANALYTICS_SCHEMA_VERSION; + sessionId: string; + version?: number; + createdAt?: string; + modifiedAt?: string; + parentSessionHash?: string; + cwd?: string; + cwdHash?: string; + hasCwd: boolean; +} + +export interface SessionAnalyticsEntryRecord { + recordType: "entry"; + schemaVersion: typeof SESSION_ANALYTICS_SCHEMA_VERSION; + sessionId: string; + entryId: string; + parentEntryId: string | null; + entryType: string; + timestamp: string; + + // Message-level metadata. Raw content, tool arguments, thinking text, and errors are intentionally omitted. + role?: string; + api?: string; + provider?: string; + model?: string; + responseModel?: string; + stopReason?: string; + hasError?: boolean; + usage?: SessionAnalyticsUsage; + contentStats?: SessionAnalyticsContentStats; + isError?: boolean; + exitCode?: number; + cancelled?: boolean; + truncated?: boolean; + excludeFromContext?: boolean; + + // Non-message entry metadata. Raw summaries, labels, names, custom data, and details are intentionally omitted. + modelId?: string; + thinkingLevel?: string; + activeToolCount?: number; + firstKeptEntryId?: string; + tokensBefore?: number; + fromHook?: boolean; + hasDetails?: boolean; + fromId?: string; + customType?: string; + display?: boolean; + hasData?: boolean; + targetId?: string | null; + hasLabel?: boolean; + hasName?: boolean; +} + +export type SessionAnalyticsRecord = SessionAnalyticsSessionRecord | SessionAnalyticsEntryRecord; + +export interface ProjectSessionHeaderAnalyticsOptions { + modifiedAt?: Date | string; + hashString?: (value: string) => string; +} + +export interface ProjectSessionAnalyticsOptions extends ProjectSessionHeaderAnalyticsOptions {} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function maybeString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function maybeNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function asNumber(value: unknown): number { + return maybeNumber(value) ?? 0; +} + +function modifiedAtToString(value: Date | string | undefined): string | undefined { + if (value instanceof Date) return value.toISOString(); + return maybeString(value); +} + +function cleanRecord(record: T): T { + const mutableRecord = record as Record; + for (const key of Object.keys(mutableRecord)) { + if (mutableRecord[key] === undefined) delete mutableRecord[key]; + } + return record; +} + +export function hashSessionAnalyticsString(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function projectSessionHeaderForAnalytics( + header: SessionHeader, + options: ProjectSessionHeaderAnalyticsOptions = {}, +): SessionAnalyticsSessionRecord { + const hash = options.hashString ?? hashSessionAnalyticsString; + return cleanRecord({ + recordType: "session", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: header.id, + version: header.version, + createdAt: maybeString(header.timestamp), + modifiedAt: modifiedAtToString(options.modifiedAt), + parentSessionHash: header.parentSession ? hash(header.parentSession) : undefined, + cwd: maybeString(header.cwd), + cwdHash: header.cwd ? hash(header.cwd) : undefined, + hasCwd: Boolean(header.cwd), + }); +} + +function projectUsageForAnalytics(usage: unknown): SessionAnalyticsUsage | undefined { + if (!isRecord(usage)) return undefined; + const cost = isRecord(usage.cost) ? usage.cost : undefined; + return { + input: asNumber(usage.input), + output: asNumber(usage.output), + cacheRead: asNumber(usage.cacheRead), + cacheWrite: asNumber(usage.cacheWrite), + totalTokens: asNumber(usage.totalTokens), + costInput: asNumber(cost?.input), + costOutput: asNumber(cost?.output), + costCacheRead: asNumber(cost?.cacheRead), + costCacheWrite: asNumber(cost?.cacheWrite), + costTotal: asNumber(cost?.total), + }; +} + +function projectContentStatsForAnalytics(content: unknown): SessionAnalyticsContentStats { + const stats: SessionAnalyticsContentStats = { + stringContent: false, + textBlocks: 0, + imageBlocks: 0, + thinkingBlocks: 0, + redactedThinkingBlocks: 0, + toolCallBlocks: 0, + otherBlocks: 0, + }; + + if (typeof content === "string") { + stats.stringContent = true; + if (content.length > 0) stats.textBlocks = 1; + return stats; + } + + if (!Array.isArray(content)) { + stats.otherBlocks = 1; + return stats; + } + + for (const block of content) { + if (!isRecord(block)) { + stats.otherBlocks++; + continue; + } + switch (block.type) { + case "text": + stats.textBlocks++; + break; + case "image": + stats.imageBlocks++; + break; + case "thinking": + stats.thinkingBlocks++; + if (block.redacted === true) stats.redactedThinkingBlocks++; + break; + case "toolCall": + stats.toolCallBlocks++; + break; + default: + stats.otherBlocks++; + break; + } + } + + return stats; +} + +function createEntryBase(sessionId: string, entry: SessionEntry): SessionAnalyticsEntryRecord { + return { + recordType: "entry", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId, + entryId: entry.id, + parentEntryId: entry.parentId, + entryType: entry.type, + timestamp: entry.timestamp, + }; +} + +function projectMessageEntryForAnalytics(sessionId: string, entry: Extract) { + const base = createEntryBase(sessionId, entry); + const message = isRecord(entry.message) ? entry.message : undefined; + const role = maybeString(message?.role) ?? "unknown"; + + if (role === "assistant") { + return cleanRecord({ + ...base, + role, + api: maybeString(message?.api), + provider: maybeString(message?.provider), + model: maybeString(message?.model), + responseModel: maybeString(message?.responseModel), + stopReason: maybeString(message?.stopReason), + hasError: + Boolean(message?.errorMessage) || message?.stopReason === "error" || message?.stopReason === "aborted", + usage: projectUsageForAnalytics(message?.usage), + contentStats: projectContentStatsForAnalytics(message?.content), + }); + } + + if (role === "user") return { ...base, role, contentStats: projectContentStatsForAnalytics(message?.content) }; + + if (role === "toolResult") { + return { + ...base, + role, + isError: message?.isError === true, + contentStats: projectContentStatsForAnalytics(message?.content), + }; + } + + if (role === "bashExecution") { + return cleanRecord({ + ...base, + role, + exitCode: maybeNumber(message?.exitCode), + cancelled: message?.cancelled === true, + truncated: message?.truncated === true, + excludeFromContext: message?.excludeFromContext === true, + }); + } + + if (role === "custom") { + return cleanRecord({ + ...base, + role, + customType: maybeString(message?.customType), + display: typeof message?.display === "boolean" ? message.display : undefined, + contentStats: projectContentStatsForAnalytics(message?.content), + }); + } + + return { ...base, role }; +} + +export function projectSessionEntryForAnalytics(sessionId: string, entry: SessionEntry): SessionAnalyticsEntryRecord { + switch (entry.type) { + case "message": + return projectMessageEntryForAnalytics(sessionId, entry); + case "model_change": + return { ...createEntryBase(sessionId, entry), provider: entry.provider, modelId: entry.modelId }; + case "thinking_level_change": + return { ...createEntryBase(sessionId, entry), thinkingLevel: entry.thinkingLevel }; + case "compaction": + return cleanRecord({ + ...createEntryBase(sessionId, entry), + firstKeptEntryId: entry.firstKeptEntryId, + tokensBefore: entry.tokensBefore, + fromHook: entry.fromHook, + hasDetails: entry.details !== undefined, + }); + case "branch_summary": + return cleanRecord({ + ...createEntryBase(sessionId, entry), + fromId: entry.fromId, + fromHook: entry.fromHook, + hasDetails: entry.details !== undefined, + }); + case "custom": + return cleanRecord({ + ...createEntryBase(sessionId, entry), + customType: entry.customType, + hasData: entry.data !== undefined, + }); + case "custom_message": + return cleanRecord({ + ...createEntryBase(sessionId, entry), + customType: entry.customType, + display: entry.display, + hasDetails: entry.details !== undefined, + contentStats: projectContentStatsForAnalytics(entry.content), + }); + case "label": + return cleanRecord({ + ...createEntryBase(sessionId, entry), + targetId: entry.targetId, + hasLabel: typeof entry.label === "string" && entry.label.length > 0, + }); + case "session_info": + return { ...createEntryBase(sessionId, entry), hasName: Boolean(entry.name?.trim()) }; + default: + return createEntryBase(sessionId, entry); + } +} + +export function projectSessionForAnalytics( + header: SessionHeader, + entries: SessionEntry[], + options: ProjectSessionAnalyticsOptions = {}, +): SessionAnalyticsRecord[] { + const session = projectSessionHeaderForAnalytics(header, options); + return [session, ...entries.map((entry) => projectSessionEntryForAnalytics(session.sessionId, entry))]; +} diff --git a/packages/coding-agent/test/session-analytics.test.ts b/packages/coding-agent/test/session-analytics.test.ts new file mode 100644 index 000000000..9fbb5e9c9 --- /dev/null +++ b/packages/coding-agent/test/session-analytics.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from "vitest"; +import { + projectSessionForAnalytics, + SESSION_ANALYTICS_SCHEMA_VERSION, + type SessionAnalyticsRecord, +} from "../src/core/session-analytics.ts"; +import type { SessionEntry, SessionHeader } from "../src/core/session-manager.ts"; + +const header: SessionHeader = { + type: "session", + version: 3, + id: "session-1", + timestamp: "2026-01-02T03:04:05.000Z", + cwd: "/tmp/project", + parentSession: "/tmp/parent.jsonl", +}; + +const entries: SessionEntry[] = [ + { + type: "model_change", + id: "model-1", + parentId: null, + timestamp: "2026-01-02T03:04:06.000Z", + provider: "anthropic", + modelId: "claude-sonnet-4-5", + }, + { + type: "message", + id: "user-1", + parentId: "model-1", + timestamp: "2026-01-02T03:04:07.000Z", + message: { + role: "user", + content: [ + { type: "text", text: "secret user prompt" }, + { type: "image", data: "secret-image-data", mimeType: "image/png" }, + ], + timestamp: 1767323047000, + }, + }, + { + type: "message", + id: "assistant-1", + parentId: "user-1", + timestamp: "2026-01-02T03:04:08.000Z", + message: { + role: "assistant", + content: [ + { type: "text", text: "secret assistant answer" }, + { type: "thinking", thinking: "secret reasoning" }, + { type: "thinking", thinking: "", thinkingSignature: "secret-signature", redacted: true }, + { type: "toolCall", id: "call-1", name: "read", arguments: { path: "secret/path.ts" } }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + responseModel: "claude-sonnet-4-5-20260101", + responseId: "secret-response-id", + usage: { + input: 100, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 190, + cost: { + input: 0.1, + output: 0.2, + cacheRead: 0.03, + cacheWrite: 0.04, + total: 0.37, + }, + }, + stopReason: "toolUse", + timestamp: 1767323048000, + }, + }, + { + type: "message", + id: "tool-result-1", + parentId: "assistant-1", + timestamp: "2026-01-02T03:04:09.000Z", + message: { + role: "toolResult", + toolCallId: "call-1", + toolName: "read", + content: [{ type: "text", text: "secret tool result" }], + isError: true, + timestamp: 1767323049000, + }, + }, + { + type: "compaction", + id: "compaction-1", + parentId: "tool-result-1", + timestamp: "2026-01-02T03:04:10.000Z", + summary: "secret compaction summary", + firstKeptEntryId: "user-1", + tokensBefore: 1234, + details: { secret: "compaction details" }, + fromHook: true, + }, + { + type: "label", + id: "label-1", + parentId: "compaction-1", + timestamp: "2026-01-02T03:04:11.000Z", + targetId: "assistant-1", + label: "secret label", + }, + { + type: "session_info", + id: "session-info-1", + parentId: "label-1", + timestamp: "2026-01-02T03:04:12.000Z", + name: "secret session name", + }, +]; + +describe("projectSessionForAnalytics", () => { + it("projects a complete session into ordered analytics records", () => { + const records = projectSessionForAnalytics(header, entries, { + modifiedAt: "2026-01-03T04:05:06.000Z", + hashString: (value) => `hashed:${value}`, + }); + + expect(records).toEqual([ + { + recordType: "session", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: "session-1", + version: 3, + createdAt: "2026-01-02T03:04:05.000Z", + modifiedAt: "2026-01-03T04:05:06.000Z", + parentSessionHash: "hashed:/tmp/parent.jsonl", + cwd: "/tmp/project", + cwdHash: "hashed:/tmp/project", + hasCwd: true, + }, + { + recordType: "entry", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: "session-1", + entryId: "model-1", + parentEntryId: null, + entryType: "model_change", + timestamp: "2026-01-02T03:04:06.000Z", + provider: "anthropic", + modelId: "claude-sonnet-4-5", + }, + { + recordType: "entry", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: "session-1", + entryId: "user-1", + parentEntryId: "model-1", + entryType: "message", + timestamp: "2026-01-02T03:04:07.000Z", + role: "user", + contentStats: { + stringContent: false, + textBlocks: 1, + imageBlocks: 1, + thinkingBlocks: 0, + redactedThinkingBlocks: 0, + toolCallBlocks: 0, + otherBlocks: 0, + }, + }, + { + recordType: "entry", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: "session-1", + entryId: "assistant-1", + parentEntryId: "user-1", + entryType: "message", + timestamp: "2026-01-02T03:04:08.000Z", + role: "assistant", + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + responseModel: "claude-sonnet-4-5-20260101", + stopReason: "toolUse", + hasError: false, + usage: { + input: 100, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 190, + costInput: 0.1, + costOutput: 0.2, + costCacheRead: 0.03, + costCacheWrite: 0.04, + costTotal: 0.37, + }, + contentStats: { + stringContent: false, + textBlocks: 1, + imageBlocks: 0, + thinkingBlocks: 2, + redactedThinkingBlocks: 1, + toolCallBlocks: 1, + otherBlocks: 0, + }, + }, + { + recordType: "entry", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: "session-1", + entryId: "tool-result-1", + parentEntryId: "assistant-1", + entryType: "message", + timestamp: "2026-01-02T03:04:09.000Z", + role: "toolResult", + isError: true, + contentStats: { + stringContent: false, + textBlocks: 1, + imageBlocks: 0, + thinkingBlocks: 0, + redactedThinkingBlocks: 0, + toolCallBlocks: 0, + otherBlocks: 0, + }, + }, + { + recordType: "entry", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: "session-1", + entryId: "compaction-1", + parentEntryId: "tool-result-1", + entryType: "compaction", + timestamp: "2026-01-02T03:04:10.000Z", + firstKeptEntryId: "user-1", + tokensBefore: 1234, + fromHook: true, + hasDetails: true, + }, + { + recordType: "entry", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: "session-1", + entryId: "label-1", + parentEntryId: "compaction-1", + entryType: "label", + timestamp: "2026-01-02T03:04:11.000Z", + targetId: "assistant-1", + hasLabel: true, + }, + { + recordType: "entry", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: "session-1", + entryId: "session-info-1", + parentEntryId: "label-1", + entryType: "session_info", + timestamp: "2026-01-02T03:04:12.000Z", + hasName: true, + }, + ] satisfies SessionAnalyticsRecord[]); + }); + + it("omits raw private payload fields", () => { + const serialized = JSON.stringify(projectSessionForAnalytics(header, entries)); + + expect(serialized).not.toContain("secret user prompt"); + expect(serialized).not.toContain("secret assistant answer"); + expect(serialized).not.toContain("secret reasoning"); + expect(serialized).not.toContain("secret-image-data"); + expect(serialized).not.toContain("secret/path.ts"); + expect(serialized).not.toContain("secret tool result"); + expect(serialized).not.toContain("secret compaction summary"); + expect(serialized).not.toContain("compaction details"); + expect(serialized).not.toContain("secret label"); + expect(serialized).not.toContain("secret session name"); + expect(serialized).not.toContain("secret-response-id"); + }); +});