diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 384dab43316..74f295dfa5e 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -187,6 +187,28 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME description: "List all available models", params: ServerParams, }), + Spec.make("stats", { + description: "Show shareable usage statistics", + params: { + ...ServerParams, + days: Flag.integer("days").pipe(Flag.withDescription("Show the last N days; 0 means today"), Flag.optional), + year: Flag.integer("year").pipe(Flag.withDescription("Show a calendar year"), Flag.optional), + all: Flag.boolean("all").pipe(Flag.withDescription("Show lifetime statistics"), Flag.withDefault(false)), + project: Flag.string("project").pipe( + Flag.withDescription('Filter by project ID, or use "." for the current project'), + Flag.optional, + ), + models: Flag.boolean("models").pipe(Flag.withDescription("Show model usage"), Flag.withDefault(false)), + tools: Flag.boolean("tools").pipe(Flag.withDescription("Show tool reliability"), Flag.withDefault(false)), + cost: Flag.boolean("cost").pipe(Flag.withDescription("Show cost and token details"), Flag.withDefault(false)), + full: Flag.boolean("full").pipe(Flag.withDescription("Show every detailed section"), Flag.withDefault(false)), + limit: Flag.integer("limit").pipe( + Flag.withDescription("Number of rows in detailed sections"), + Flag.withDefault(5), + ), + json: Flag.boolean("json").pipe(Flag.withDescription("Output statistics as JSON"), Flag.withDefault(false)), + }, + }), Spec.make("export", { description: "Export session data as JSON", params: { diff --git a/packages/cli/src/commands/handlers/stats.ts b/packages/cli/src/commands/handlers/stats.ts new file mode 100644 index 00000000000..3fd2a3f8b73 --- /dev/null +++ b/packages/cli/src/commands/handlers/stats.ts @@ -0,0 +1,322 @@ +import { OpenCode, type SessionStatsInfo } from "@opencode-ai/client" +import { Service } from "@opencode-ai/client/effect/service" +import { Effect, Option } from "effect" +import { EOL } from "node:os" +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { ServerConnection } from "../../services/server-connection" + +export default Runtime.handler( + Commands.commands.stats, + Effect.fn("cli.stats")(function* (input) { + const days = Option.getOrUndefined(input.days) + const year = Option.getOrUndefined(input.year) + const project = Option.getOrUndefined(input.project) + if ([days !== undefined, year !== undefined, input.all].filter(Boolean).length > 1) + yield* Effect.fail(new Error("--days, --year, and --all cannot be combined")) + if (days !== undefined && days < 0) yield* Effect.fail(new Error("--days must be zero or greater")) + if (year !== undefined && (year < 1970 || year > 9_999)) + yield* Effect.fail(new Error("--year must be between 1970 and 9999")) + if (input.limit < 1) yield* Effect.fail(new Error("--limit must be greater than zero")) + + const server = yield* ServerConnection.resolve({ + server: Option.getOrUndefined(input.server), + standalone: input.standalone, + }) + const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }) + const range = statsRange({ days, year, all: input.all }) + const projectID = + project === "." + ? yield* Effect.promise(() => + client.location.get({ location: { directory: process.cwd() } }).then((location) => location.project.id), + ) + : project + const stats = yield* Effect.promise(() => + client.session.stats({ + from: range.from, + to: range.to, + project: projectID, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + }), + ) + const output = input.json + ? JSON.stringify(stats, null, 2) + : renderStats(stats, { + label: range.label, + models: input.models || input.full, + tools: input.tools || input.full, + cost: input.cost || input.full, + limit: input.limit, + color: process.stdout.isTTY && process.env.NO_COLOR === undefined, + }) + process.stdout.write(output + EOL) + }), +) + +type RenderOptions = { + label: string + models: boolean + tools: boolean + cost: boolean + limit: number + color: boolean +} + +const colors = terminalPalette() + +export function renderStats(stats: SessionStatsInfo, options: RenderOptions) { + const totalTokens = tokenTotal(stats.tokens) + const terminalTools = stats.tools.succeeded + stats.tools.failed + const toolRate = terminalTools === 0 ? undefined : (stats.tools.succeeded / terminalTools) * 100 + const primary = `1;${colors.primary}` + const sessionLine = [ + metricCount(stats.sessions, "session", options.color), + stats.subagents > 0 ? metricCount(stats.subagents, "subagent", options.color) : undefined, + ] + .filter((value) => value !== undefined) + .join(" · ") + const toolSummary = + toolRate === undefined ? "no tool calls" : `${style(formatPercent(toolRate), primary, options.color)} tools` + const details = options.models || options.tools || options.cost + const lines = details + ? [] + : [ + `${style("opencode stats", primary, options.color)} ${style(`· ${options.label}`, "2", options.color)}`, + "", + ...renderActivity(stats.activity, stats.range.from, stats.range.to, options.color), + "", + sessionLine, + `${metricCount(stats.prompts, "prompt", options.color)} · ${metricCount(stats.steps, "step", options.color)} · ${metricCount(totalTokens, "token", options.color)}`, + `${toolSummary} · ${metricCount(stats.activeDays, "active day", options.color)} · best streak ${style(stats.streak.toString(), primary, options.color)} day${stats.streak === 1 ? "" : "s"}`, + "", + style("opencode.ai", "2", options.color), + ] + + if (options.cost) lines.push(...renderCost(stats)) + if (options.models) lines.push(...(lines.length > 0 ? [""] : []), ...renderModels(stats, options.limit)) + if (options.tools) lines.push(...(lines.length > 0 ? [""] : []), ...renderTools(stats, options.limit)) + return lines.join(EOL) +} + +function statsRange(input: { days?: number; year?: number; all: boolean }) { + const now = new Date() + const to = now.getTime() + 1 + if (input.all) return { from: undefined, to, label: "all time" } + if (input.days !== undefined) { + const from = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + from.setDate(from.getDate() - Math.max(0, input.days - 1)) + return { + from: from.getTime(), + to, + label: input.days === 0 || input.days === 1 ? "today" : `last ${input.days} days`, + } + } + const year = input.year ?? now.getFullYear() + return { + from: new Date(year, 0, 1).getTime(), + to: year === now.getFullYear() ? to : new Date(year + 1, 0, 1).getTime(), + label: year === now.getFullYear() ? `${year} so far` : year.toString(), + } +} + +function renderActivity(activity: SessionStatsInfo["activity"], from: number, to: number, color: boolean) { + const values = new Map(activity.map((day) => [day.date, day.steps])) + const end = new Date(to - 1) + end.setHours(12, 0, 0, 0) + end.setDate(end.getDate() + (7 - mondayIndex(end) - 1)) + const start = new Date(from) + start.setHours(12, 0, 0, 0) + start.setDate(start.getDate() - mondayIndex(start)) + const latest = new Date(end) + latest.setDate(latest.getDate() - 52 * 7) + if (start < latest) start.setTime(latest.getTime()) + + const active = [...values.values()].filter((value) => value > 0) + const levels = [...new Set(active)].sort((a, b) => a - b) + const weekStarts = Array.from({ length: Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1 }, (_, week) => { + const date = new Date(start) + date.setDate(date.getDate() + week * 7) + return date + }) + const weeks = weekStarts.map((week) => + Array.from({ length: 7 }, (_, day) => { + const date = new Date(week) + date.setDate(date.getDate() + day) + return activityGlyph(values.get(dateKey(date)) ?? 0, levels, color) + }), + ) + const weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] + return [ + style("activity", `1;${colors.primary}`, color), + ` ${style(monthLabels(weekStarts), "2", color)}`, + ...weekdays.flatMap((label, day) => [ + `${style(label, "2", color)} ${weeks.map((week) => week[day]).join("")}`, + ...(day === weekdays.length - 1 ? [] : [""]), + ]), + "", + ` ${style("less", "2", color)} ${[0, 1, 2, 3, 4].map((level) => paintActivity(level, color)).join("")} ${style("more", "2", color)}`, + ] +} + +function renderCost(stats: SessionStatsInfo) { + const input = stats.tokens.input + stats.tokens.cache.read + const cached = input === 0 ? 0 : (stats.tokens.cache.read / input) * 100 + return [ + "COST & TOKENS", + row("cost", `$${stats.cost.toFixed(2)}`), + row("input", formatNumber(stats.tokens.input)), + row("output", formatNumber(stats.tokens.output)), + row("reasoning", formatNumber(stats.tokens.reasoning)), + row("cache read", formatNumber(stats.tokens.cache.read)), + row("cache write", formatNumber(stats.tokens.cache.write)), + row("cached input", formatPercent(cached)), + ] +} + +function renderModels(stats: SessionStatsInfo, limit: number) { + if (stats.models.length === 0) return ["MODELS", " no model usage"] + return [ + "MODELS", + tableHeader("model", "tokens", "steps", "cost"), + ...stats.models + .slice(0, limit) + .map((item) => + tableRow( + `${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`, + formatNumber(tokenTotal(item.tokens)), + formatNumber(item.steps), + `$${item.cost.toFixed(2)}`, + ), + ), + ] +} + +function renderTools(stats: SessionStatsInfo, limit: number) { + if (stats.toolUsage.length === 0) return ["TOOL RELIABILITY", " no tool calls"] + return [ + "TOOL RELIABILITY", + tableHeader("tool", "calls", "error", "p50"), + ...stats.toolUsage.slice(0, limit).map((tool) => { + const terminal = tool.succeeded + tool.failed + return tableRow( + tool.name, + formatNumber(tool.calls), + terminal === 0 ? "-" : formatPercent((tool.failed / terminal) * 100), + tool.durationP50 === undefined ? "-" : formatDuration(tool.durationP50), + ) + }), + "", + `${formatNumber(stats.tools.succeeded + stats.tools.failed)} terminal calls · ${formatNumber(stats.tools.unfinished)} unfinished`, + ] +} + +function row(label: string, value: string) { + return ` ${label.padEnd(20)}${value}` +} + +function tableHeader(label: string, second: string, third: string, fourth: string) { + return tableRow(label, second, third, fourth) +} + +function tableRow(label: string, second: string, third: string, fourth: string) { + return `${truncate(label, 34).padEnd(34)}${second.padStart(10)}${third.padStart(12)}${fourth.padStart(12)}` +} + +function truncate(value: string, width: number) { + return value.length <= width ? value : value.slice(0, width - 1) + "…" +} + +function tokenTotal(tokens: SessionStatsInfo["tokens"]) { + return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write +} + +function formatNumber(value: number) { + if (value >= 1_000_000_000) return `${trimDecimal(value / 1_000_000_000)}b` + if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}m` + if (value >= 1_000) return `${trimDecimal(value / 1_000)}k` + return Math.round(value).toLocaleString("en-US") +} + +function trimDecimal(value: number) { + return value.toFixed(1).replace(/\.0$/, "") +} + +function formatPercent(value: number) { + return `${value.toFixed(value >= 10 ? 1 : 2)}%` +} + +function formatDuration(value: number) { + if (value < 1_000) return `${Math.round(value)}ms` + return `${trimDecimal(value / 1_000)}s` +} + +function metricCount(value: number, noun: string, color: boolean) { + return `${style(formatNumber(value), `1;${colors.primary}`, color)} ${noun}${value === 1 ? "" : "s"}` +} + +function style(value: string, code: string, color: boolean) { + return color ? `\x1b[${code}m${value}\x1b[0m` : value +} + +function activityGlyph(value: number, levels: number[], color: boolean) { + if (value === 0) return paintActivity(0, color) + const index = levels.indexOf(value) + const level = Math.max(1, Math.ceil(((index + 1) / levels.length) * 4)) + return paintActivity(level, color) +} + +function paintActivity(level: number, color: boolean) { + const glyph = ["·", "░", "▒", "▓", "█"][level] + if (!color) return glyph + if (level === 0) return `\x1b[2m${glyph}\x1b[22m` + return `\x1b[${colors.activity[level - 1]}m${glyph}\x1b[39m` +} + +function terminalPalette() { + const background = Number(process.env.COLORFGBG?.split(";").at(-1)) + if (Number.isFinite(background) && background >= 7) + return { + primary: "38;2;59;125;216", + activity: ["38;2;153;169;192", "38;2;122;155;200", "38;2;90;140;208", "38;2;59;125;216"], + } + return { + primary: "38;2;250;178;131", + activity: ["38;2;117;99;87", "38;2;161;125;102", "38;2;206;152;116", "38;2;250;178;131"], + } +} + +function monthLabels(weeks: Date[]) { + const line: string[] = [] + weeks.reduce((previous, week, index) => { + const middle = new Date(week) + middle.setDate(middle.getDate() + 3) + const month = middle.getMonth() + if (month === previous) return previous + Intl.DateTimeFormat("en-US", { month: "short" }) + .format(middle) + .split("") + .forEach((character, offset) => { + line[index + offset] = character + }) + return month + }, -1) + return Array.from({ length: Math.max(weeks.length, line.length) }, (_, index) => line[index] ?? " ") + .join("") + .trimEnd() +} + +function mondayIndex(date: Date) { + return (date.getDay() + 6) % 7 +} + +function dateKey(date: Date) { + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, "0"), + String(date.getDate()).padStart(2, "0"), + ].join("-") +} + +function dateOrdinal(date: Date) { + return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000) +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f344f450577..49eb0162d34 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -43,6 +43,7 @@ const Handlers = Runtime.handlers(Commands, { remove: () => import("./commands/handlers/plugin/remove"), }, models: () => import("./commands/handlers/models"), + stats: () => import("./commands/handlers/stats"), export: () => import("./commands/handlers/export"), import: () => import("./commands/handlers/import"), mini: () => import("./commands/handlers/mini"), diff --git a/packages/cli/test/stats.test.ts b/packages/cli/test/stats.test.ts new file mode 100644 index 00000000000..aa3a9753014 --- /dev/null +++ b/packages/cli/test/stats.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test" +import type { SessionStatsInfo } from "@opencode-ai/client" +import { renderStats } from "../src/commands/handlers/stats" + +const stats: SessionStatsInfo = { + range: { from: Date.UTC(2026, 0, 1), to: Date.UTC(2026, 0, 8) }, + sessions: 2, + subagents: 1, + prompts: 4, + steps: 6, + tokens: { input: 10_000, output: 2_000, reasoning: 1_000, cache: { read: 5_000, write: 500 } }, + cost: 12.34, + tools: { calls: 10, succeeded: 8, failed: 2, unfinished: 0 }, + activeDays: 2, + streak: 2, + activity: [ + { date: "2026-01-02", steps: 2 }, + { date: "2026-01-03", steps: 4 }, + ], + models: [ + { + model: { providerID: "anthropic", id: "sonnet" }, + steps: 6, + tokens: { input: 10_000, output: 2_000, reasoning: 1_000, cache: { read: 5_000, write: 500 } }, + cost: 12.34, + }, + ], + toolUsage: [{ name: "private_tool", calls: 10, succeeded: 8, failed: 2, unfinished: 0, durationP50: 250 }], +} + +describe("stats rendering", () => { + test("keeps the default card shareable", () => { + const output = renderStats(stats, options()) + expect(output).toContain("opencode stats · 2026 so far") + expect(output).toContain("activity") + expect(output).toContain("Mo ··") + expect(output).toMatch(/Mo .*\n\nTu/) + expect(output).toMatch(/Su .*\n\n less/) + expect(output).toContain("less ·░▒▓█ more") + expect(output).toContain("2 sessions · 1 subagent") + expect(output).toContain("80.0% tools · 2 active days · best streak 2 days") + expect(output).not.toContain("private_tool") + expect(output).not.toContain("$12.34") + }) + + test("renders only requested detail tables", () => { + const output = renderStats(stats, options({ tools: true, cost: true })) + expect(output).toContain("COST & TOKENS") + expect(output).toContain("TOOL RELIABILITY") + expect(output).toContain("private_tool") + expect(output).toContain("tool") + expect(output).toContain("calls") + expect(output).not.toContain("opencode stats") + expect(output).not.toContain("activity") + }) + + test("uses the OpenCode palette in color mode", () => { + const output = renderStats(stats, options({ color: true })) + expect(output).toContain("\x1b[1;38;2;") + expect(output).not.toContain("38;5;45") + }) +}) + +function options(input: Partial[1]> = {}): Parameters[1] { + return { + label: "2026 so far", + models: false, + tools: false, + cost: false, + limit: 5, + color: false, + ...input, + } +} diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index dbcc071fc2c..8f2f6833d8a 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -10,6 +10,7 @@ import type { Project } from "@opencode-ai/schema/project" import type { RelativePath } from "@opencode-ai/schema/schema" import type { Brand } from "effect" import type { Model } from "@opencode-ai/schema/model" +import type { DateTime } from "effect" import type { SessionMessage } from "@opencode-ai/schema/session-message" import type { SessionInbox } from "@opencode-ai/schema/session-inbox" import type { PromptInput } from "@opencode-ai/schema/prompt-input" @@ -111,64 +112,114 @@ export type Endpoint5_0Output = { export type SessionListOperation = (input?: Endpoint5_0Input) => Effect.Effect export type Endpoint5_1Input = { + readonly from?: number | undefined + readonly to?: number | undefined + readonly project?: Project.ID | undefined + readonly timezone?: string | undefined +} +export type Endpoint5_1Output = { + readonly range: { readonly from: DateTime.Utc; readonly to: DateTime.Utc } + readonly sessions: number + readonly subagents: number + readonly prompts: number + readonly steps: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly cost: number & Brand.Brand<"Money.USD"> + readonly tools: { + readonly calls: number + readonly succeeded: number + readonly failed: number + readonly unfinished: number + } + readonly activeDays: number + readonly streak: number + readonly activity: ReadonlyArray<{ readonly date: string; readonly steps: number }> + readonly models: ReadonlyArray<{ + readonly model: Model.Ref + readonly steps: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly cost: number & Brand.Brand<"Money.USD"> + }> + readonly toolUsage: ReadonlyArray<{ + readonly name: string + readonly calls: number + readonly succeeded: number + readonly failed: number + readonly unfinished: number + readonly durationP50?: number | undefined + }> +} +export type SessionStatsOperation = (input?: Endpoint5_1Input) => Effect.Effect + +export type Endpoint5_2Input = { readonly id?: Session.ID | undefined readonly title?: string | undefined readonly agent?: Agent.ID | undefined readonly model?: Model.Ref | undefined readonly location?: Location.Ref | undefined } -export type Endpoint5_1Output = Session.Info -export type SessionCreateOperation = (input?: Endpoint5_1Input) => Effect.Effect +export type Endpoint5_2Output = Session.Info +export type SessionCreateOperation = (input?: Endpoint5_2Input) => Effect.Effect -export type Endpoint5_2Input = { +export type Endpoint5_3Input = { readonly info: Session.Info readonly messages: ReadonlyArray readonly location?: Location.Ref | undefined } -export type Endpoint5_2Output = Session.Info -export type SessionImportOperation = (input: Endpoint5_2Input) => Effect.Effect +export type Endpoint5_3Output = Session.Info +export type SessionImportOperation = (input: Endpoint5_3Input) => Effect.Effect -export type Endpoint5_3Input = { readonly sessionID: Session.ID; readonly sanitize?: boolean | undefined } -export type Endpoint5_3Output = { readonly info: Session.Info; readonly messages: ReadonlyArray } -export type SessionExportOperation = (input: Endpoint5_3Input) => Effect.Effect +export type Endpoint5_4Input = { readonly sessionID: Session.ID; readonly sanitize?: boolean | undefined } +export type Endpoint5_4Output = { readonly info: Session.Info; readonly messages: ReadonlyArray } +export type SessionExportOperation = (input: Endpoint5_4Input) => Effect.Effect -export type Endpoint5_4Output = { readonly [x: Session.ID]: { readonly type: "running" } } -export type SessionActiveOperation = () => Effect.Effect - -export type Endpoint5_5Input = { readonly sessionID: Session.ID } -export type Endpoint5_5Output = Session.Info -export type SessionGetOperation = (input: Endpoint5_5Input) => Effect.Effect +export type Endpoint5_5Output = { readonly [x: Session.ID]: { readonly type: "running" } } +export type SessionActiveOperation = () => Effect.Effect export type Endpoint5_6Input = { readonly sessionID: Session.ID } -export type Endpoint5_6Output = void -export type SessionRemoveOperation = (input: Endpoint5_6Input) => Effect.Effect +export type Endpoint5_6Output = Session.Info +export type SessionGetOperation = (input: Endpoint5_6Input) => Effect.Effect -export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary } -export type Endpoint5_7Output = Session.Info -export type SessionForkOperation = (input: Endpoint5_7Input) => Effect.Effect +export type Endpoint5_7Input = { readonly sessionID: Session.ID } +export type Endpoint5_7Output = void +export type SessionRemoveOperation = (input: Endpoint5_7Input) => Effect.Effect -export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID } -export type Endpoint5_8Output = void -export type SessionSwitchAgentOperation = (input: Endpoint5_8Input) => Effect.Effect +export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary } +export type Endpoint5_8Output = Session.Info +export type SessionForkOperation = (input: Endpoint5_8Input) => Effect.Effect -export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref } +export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID } export type Endpoint5_9Output = void -export type SessionSwitchModelOperation = (input: Endpoint5_9Input) => Effect.Effect +export type SessionSwitchAgentOperation = (input: Endpoint5_9Input) => Effect.Effect -export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string } +export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly model: Model.Ref } export type Endpoint5_10Output = void -export type SessionRenameOperation = (input: Endpoint5_10Input) => Effect.Effect +export type SessionSwitchModelOperation = (input: Endpoint5_10Input) => Effect.Effect -export type Endpoint5_11Input = { +export type Endpoint5_11Input = { readonly sessionID: Session.ID; readonly title: string } +export type Endpoint5_11Output = void +export type SessionRenameOperation = (input: Endpoint5_11Input) => Effect.Effect + +export type Endpoint5_12Input = { readonly sessionID: Session.ID readonly directory: AbsolutePath readonly workspaceID?: Workspace.ID | undefined readonly delivery?: SessionInbox.Delivery | undefined } -export type Endpoint5_11Output = void -export type SessionMoveOperation = (input: Endpoint5_11Input) => Effect.Effect +export type Endpoint5_12Output = void +export type SessionMoveOperation = (input: Endpoint5_12Input) => Effect.Effect -export type Endpoint5_12Input = { +export type Endpoint5_13Input = { readonly sessionID: Session.ID readonly id?: SessionMessage.ID | undefined readonly text: string @@ -179,10 +230,10 @@ export type Endpoint5_12Input = { readonly delivery?: SessionInbox.Delivery | undefined readonly resume?: boolean | undefined } -export type Endpoint5_12Output = SessionInbox.User -export type SessionPromptOperation = (input: Endpoint5_12Input) => Effect.Effect +export type Endpoint5_13Output = SessionInbox.User +export type SessionPromptOperation = (input: Endpoint5_13Input) => Effect.Effect -export type Endpoint5_13Input = { +export type Endpoint5_14Input = { readonly sessionID: Session.ID readonly id?: SessionMessage.ID | undefined readonly command: string @@ -195,19 +246,19 @@ export type Endpoint5_13Input = { readonly delivery?: SessionInbox.Delivery | undefined readonly resume?: boolean | undefined } -export type Endpoint5_13Output = SessionInbox.User -export type SessionCommandOperation = (input: Endpoint5_13Input) => Effect.Effect +export type Endpoint5_14Output = SessionInbox.User +export type SessionCommandOperation = (input: Endpoint5_14Input) => Effect.Effect -export type Endpoint5_14Input = { +export type Endpoint5_15Input = { readonly sessionID: Session.ID readonly id?: SessionMessage.ID | undefined readonly skill: Skill.ID readonly resume?: boolean | undefined } -export type Endpoint5_14Output = void -export type SessionSkillOperation = (input: Endpoint5_14Input) => Effect.Effect +export type Endpoint5_15Output = void +export type SessionSkillOperation = (input: Endpoint5_15Input) => Effect.Effect -export type Endpoint5_15Input = { +export type Endpoint5_16Input = { readonly sessionID: Session.ID readonly id?: SessionMessage.ID | undefined readonly text: string @@ -216,97 +267,97 @@ export type Endpoint5_15Input = { readonly delivery?: SessionInbox.Delivery | undefined readonly resume?: boolean | undefined } -export type Endpoint5_15Output = SessionInbox.Synthetic -export type SessionSyntheticOperation = (input: Endpoint5_15Input) => Effect.Effect +export type Endpoint5_16Output = SessionInbox.Synthetic +export type SessionSyntheticOperation = (input: Endpoint5_16Input) => Effect.Effect -export type Endpoint5_16Input = { +export type Endpoint5_17Input = { readonly sessionID: Session.ID readonly id?: Event.ID | undefined readonly command: string } -export type Endpoint5_16Output = void -export type SessionShellOperation = (input: Endpoint5_16Input) => Effect.Effect +export type Endpoint5_17Output = void +export type SessionShellOperation = (input: Endpoint5_17Input) => Effect.Effect -export type Endpoint5_17Input = { +export type Endpoint5_18Input = { readonly sessionID: Session.ID readonly id?: SessionMessage.ID | undefined readonly delivery?: SessionInbox.Delivery | undefined } -export type Endpoint5_17Output = SessionInbox.Compaction -export type SessionCompactOperation = (input: Endpoint5_17Input) => Effect.Effect +export type Endpoint5_18Output = SessionInbox.Compaction +export type SessionCompactOperation = (input: Endpoint5_18Input) => Effect.Effect -export type Endpoint5_18Input = { readonly sessionID: Session.ID } -export type Endpoint5_18Output = void -export type SessionWaitOperation = (input: Endpoint5_18Input) => Effect.Effect +export type Endpoint5_19Input = { readonly sessionID: Session.ID } +export type Endpoint5_19Output = void +export type SessionWaitOperation = (input: Endpoint5_19Input) => Effect.Effect -export type Endpoint5_19Input = { +export type Endpoint5_20Input = { readonly sessionID: Session.ID readonly messageID: SessionMessage.ID readonly files?: boolean | undefined } -export type Endpoint5_19Output = Session.Revert -export type SessionRevertStageOperation = (input: Endpoint5_19Input) => Effect.Effect - -export type Endpoint5_20Input = { readonly sessionID: Session.ID } -export type Endpoint5_20Output = void -export type SessionRevertClearOperation = (input: Endpoint5_20Input) => Effect.Effect +export type Endpoint5_20Output = Session.Revert +export type SessionRevertStageOperation = (input: Endpoint5_20Input) => Effect.Effect export type Endpoint5_21Input = { readonly sessionID: Session.ID } export type Endpoint5_21Output = void -export type SessionRevertCommitOperation = (input: Endpoint5_21Input) => Effect.Effect +export type SessionRevertClearOperation = (input: Endpoint5_21Input) => Effect.Effect export type Endpoint5_22Input = { readonly sessionID: Session.ID } -export type Endpoint5_22Output = ReadonlyArray -export type SessionContextOperation = (input: Endpoint5_22Input) => Effect.Effect +export type Endpoint5_22Output = void +export type SessionRevertCommitOperation = (input: Endpoint5_22Input) => Effect.Effect export type Endpoint5_23Input = { readonly sessionID: Session.ID } -export type Endpoint5_23Output = ReadonlyArray -export type SessionInboxListOperation = (input: Endpoint5_23Input) => Effect.Effect +export type Endpoint5_23Output = ReadonlyArray +export type SessionContextOperation = (input: Endpoint5_23Input) => Effect.Effect -export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID } -export type Endpoint5_24Output = void -export type SessionInboxCancelOperation = (input: Endpoint5_24Input) => Effect.Effect +export type Endpoint5_24Input = { readonly sessionID: Session.ID } +export type Endpoint5_24Output = ReadonlyArray +export type SessionInboxListOperation = (input: Endpoint5_24Input) => Effect.Effect export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID } export type Endpoint5_25Output = void -export type SessionInboxSteerOperation = (input: Endpoint5_25Input) => Effect.Effect +export type SessionInboxCancelOperation = (input: Endpoint5_25Input) => Effect.Effect export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID } export type Endpoint5_26Output = void -export type SessionInboxQueueOperation = (input: Endpoint5_26Input) => Effect.Effect +export type SessionInboxSteerOperation = (input: Endpoint5_26Input) => Effect.Effect -export type Endpoint5_27Input = { readonly sessionID: Session.ID } -export type Endpoint5_27Output = ReadonlyArray +export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID } +export type Endpoint5_27Output = void +export type SessionInboxQueueOperation = (input: Endpoint5_27Input) => Effect.Effect + +export type Endpoint5_28Input = { readonly sessionID: Session.ID } +export type Endpoint5_28Output = ReadonlyArray export type SessionInstructionsEntryListOperation = ( - input: Endpoint5_27Input, -) => Effect.Effect + input: Endpoint5_28Input, +) => Effect.Effect -export type Endpoint5_28Input = { +export type Endpoint5_29Input = { readonly sessionID: Session.ID readonly key: InstructionEntry.Key readonly value: Schema.Json } -export type Endpoint5_28Output = void -export type SessionInstructionsEntryPutOperation = ( - input: Endpoint5_28Input, -) => Effect.Effect - -export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key } export type Endpoint5_29Output = void -export type SessionInstructionsEntryRemoveOperation = ( +export type SessionInstructionsEntryPutOperation = ( input: Endpoint5_29Input, ) => Effect.Effect -export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string } -export type Endpoint5_30Output = { readonly text: string } -export type SessionGenerateOperation = (input: Endpoint5_30Input) => Effect.Effect +export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key } +export type Endpoint5_30Output = void +export type SessionInstructionsEntryRemoveOperation = ( + input: Endpoint5_30Input, +) => Effect.Effect -export type Endpoint5_31Input = { +export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly prompt: string } +export type Endpoint5_31Output = { readonly text: string } +export type SessionGenerateOperation = (input: Endpoint5_31Input) => Effect.Effect + +export type Endpoint5_32Input = { readonly sessionID: Session.ID readonly after?: Event.Seq | undefined readonly follow?: boolean | undefined } -export type Endpoint5_31Output = +export type Endpoint5_32Output = | ( | { readonly id: Event.ID @@ -900,26 +951,27 @@ export type Endpoint5_31Output = } ) | EventLog.Synced -export type SessionLogOperation = (input: Endpoint5_31Input) => Stream.Stream +export type SessionLogOperation = (input: Endpoint5_32Input) => Stream.Stream -export type Endpoint5_32Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined } -export type Endpoint5_32Output = void -export type SessionInterruptOperation = (input: Endpoint5_32Input) => Effect.Effect - -export type Endpoint5_33Input = { readonly sessionID: Session.ID } +export type Endpoint5_33Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined } export type Endpoint5_33Output = void -export type SessionBackgroundOperation = (input: Endpoint5_33Input) => Effect.Effect +export type SessionInterruptOperation = (input: Endpoint5_33Input) => Effect.Effect -export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID } -export type Endpoint5_34Output = SessionMessage.Info -export type SessionMessageOperation = (input: Endpoint5_34Input) => Effect.Effect +export type Endpoint5_34Input = { readonly sessionID: Session.ID } +export type Endpoint5_34Output = void +export type SessionBackgroundOperation = (input: Endpoint5_34Input) => Effect.Effect -export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly variables: { readonly [x: string]: string } } -export type Endpoint5_35Output = void -export type SessionEnvironmentOperation = (input: Endpoint5_35Input) => Effect.Effect +export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID } +export type Endpoint5_35Output = SessionMessage.Info +export type SessionMessageOperation = (input: Endpoint5_35Input) => Effect.Effect + +export type Endpoint5_36Input = { readonly sessionID: Session.ID; readonly variables: { readonly [x: string]: string } } +export type Endpoint5_36Output = void +export type SessionEnvironmentOperation = (input: Endpoint5_36Input) => Effect.Effect export interface SessionApi { readonly list: SessionListOperation + readonly stats: SessionStatsOperation readonly create: SessionCreateOperation readonly import: SessionImportOperation readonly export: SessionExportOperation diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index dccea27fa09..b20ffabb3f9 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -23,8 +23,8 @@ import type { Endpoint5_2Output, Endpoint5_3Input, Endpoint5_3Output, + Endpoint5_4Input, Endpoint5_4Output, - Endpoint5_5Input, Endpoint5_5Output, Endpoint5_6Input, Endpoint5_6Output, @@ -86,6 +86,8 @@ import type { Endpoint5_34Output, Endpoint5_35Input, Endpoint5_35Output, + Endpoint5_36Input, + Endpoint5_36Output, Endpoint6_0Input, Endpoint6_0Output, Endpoint7_0Input, @@ -300,6 +302,16 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1Input) => preserveEffect()( + raw["session.stats"]({ + query: { from: input?.["from"], to: input?.["to"], project: input?.["project"], timezone: input?.["timezone"] }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ), + ) + +const Endpoint5_2 = (raw: RawClient["server.session"]) => (input?: Endpoint5_2Input) => + preserveEffect()( raw["session.create"]({ payload: { id: input?.["id"], @@ -314,8 +326,8 @@ const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1In ), ) -const Endpoint5_2 = (raw: RawClient["server.session"]) => (input: Endpoint5_2Input) => - preserveEffect()( +const Endpoint5_3 = (raw: RawClient["server.session"]) => (input: Endpoint5_3Input) => + preserveEffect()( raw["session.import"]({ payload: { info: input["info"], messages: input["messages"], location: input["location"] }, }).pipe( @@ -324,25 +336,17 @@ const Endpoint5_2 = (raw: RawClient["server.session"]) => (input: Endpoint5_2Inp ), ) -const Endpoint5_3 = (raw: RawClient["server.session"]) => (input: Endpoint5_3Input) => - preserveEffect()( +const Endpoint5_4 = (raw: RawClient["server.session"]) => (input: Endpoint5_4Input) => + preserveEffect()( raw["session.export"]({ params: { sessionID: input["sessionID"] }, query: { sanitize: input["sanitize"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ), ) -const Endpoint5_4 = (raw: RawClient["server.session"]) => () => - preserveEffect()( - raw["session.active"]({}).pipe( - Effect.mapError(mapClientError), - Effect.map((value) => value.data), - ), - ) - -const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) => +const Endpoint5_5 = (raw: RawClient["server.session"]) => () => preserveEffect()( - raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe( + raw["session.active"]({}).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ), @@ -350,48 +354,56 @@ const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Inp const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) => preserveEffect()( - raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), + raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ), ) const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) => preserveEffect()( + raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), + ) + +const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) => + preserveEffect()( raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ), ) -const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) => - preserveEffect()( - raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe( - Effect.mapError(mapClientError), - ), - ) - const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) => preserveEffect()( - raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe( + raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe( Effect.mapError(mapClientError), ), ) const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) => preserveEffect()( - raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe( + raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe( Effect.mapError(mapClientError), ), ) const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) => preserveEffect()( + raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe( + Effect.mapError(mapClientError), + ), + ) + +const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) => + preserveEffect()( raw["session.move"]({ params: { sessionID: input["sessionID"] }, payload: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] }, }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) => - preserveEffect()( +const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) => + preserveEffect()( raw["session.prompt"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -410,8 +422,8 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I ), ) -const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) => - preserveEffect()( +const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) => + preserveEffect()( raw["session.command"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -432,16 +444,16 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I ), ) -const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) => - preserveEffect()( +const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) => + preserveEffect()( raw["session.skill"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], skill: input["skill"], resume: input["resume"] }, }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) => - preserveEffect()( +const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) => + preserveEffect()( raw["session.synthetic"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -458,16 +470,16 @@ const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15I ), ) -const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) => - preserveEffect()( +const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) => + preserveEffect()( raw["session.shell"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], command: input["command"] }, }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) => - preserveEffect()( +const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) => + preserveEffect()( raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], delivery: input["delivery"] }, @@ -477,13 +489,13 @@ const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17I ), ) -const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) => - preserveEffect()( +const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) => + preserveEffect()( raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) => - preserveEffect()( +const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) => + preserveEffect()( raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -493,27 +505,19 @@ const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19I ), ) -const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) => - preserveEffect()( - raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), - ) - const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) => preserveEffect()( - raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), ) const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) => preserveEffect()( - raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( - Effect.mapError(mapClientError), - Effect.map((value) => value.data), - ), + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), ) const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) => preserveEffect()( - raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe( + raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ), @@ -521,58 +525,66 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) => preserveEffect()( - raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe( + raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), + Effect.map((value) => value.data), ), ) const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) => preserveEffect()( - raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe( + raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe( Effect.mapError(mapClientError), ), ) const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) => preserveEffect()( - raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe( + raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe( Effect.mapError(mapClientError), ), ) const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) => preserveEffect()( + raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe( + Effect.mapError(mapClientError), + ), + ) + +const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) => + preserveEffect()( raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ), ) -const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) => - preserveEffect()( +const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) => + preserveEffect()( raw["session.instructions.entry.put"]({ params: { sessionID: input["sessionID"], key: input["key"] }, payload: { value: input["value"] }, }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) => - preserveEffect()( +const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) => + preserveEffect()( raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( Effect.mapError(mapClientError), ), ) -const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) => - preserveEffect()( +const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) => + preserveEffect()( raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ), ) -const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) => - preserveStream()( +const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) => + preserveStream()( Stream.unwrap( raw["session.log"]({ params: { sessionID: input["sessionID"] }, @@ -584,29 +596,29 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I ), ) -const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) => - preserveEffect()( +const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) => + preserveEffect()( raw["session.interrupt"]({ params: { sessionID: input["sessionID"] }, query: { continue: input["continue"] }, }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) => - preserveEffect()( +const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) => + preserveEffect()( raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) => - preserveEffect()( +const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) => + preserveEffect()( raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ), ) -const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) => - preserveEffect()( +const Endpoint5_36 = (raw: RawClient["server.session"]) => (input: Endpoint5_36Input) => + preserveEffect()( raw["session.environment"]({ params: { sessionID: input["sessionID"] }, payload: { variables: input["variables"] }, @@ -615,34 +627,35 @@ const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35I const adaptGroup5 = (raw: RawClient["server.session"]) => ({ list: Endpoint5_0(raw), - create: Endpoint5_1(raw), - import: Endpoint5_2(raw), - export: Endpoint5_3(raw), - active: Endpoint5_4(raw), - get: Endpoint5_5(raw), - remove: Endpoint5_6(raw), - fork: Endpoint5_7(raw), - switchAgent: Endpoint5_8(raw), - switchModel: Endpoint5_9(raw), - rename: Endpoint5_10(raw), - move: Endpoint5_11(raw), - prompt: Endpoint5_12(raw), - command: Endpoint5_13(raw), - skill: Endpoint5_14(raw), - synthetic: Endpoint5_15(raw), - shell: Endpoint5_16(raw), - compact: Endpoint5_17(raw), - wait: Endpoint5_18(raw), - revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) }, - context: Endpoint5_22(raw), - inbox: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) }, - instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } }, - generate: Endpoint5_30(raw), - log: Endpoint5_31(raw), - interrupt: Endpoint5_32(raw), - background: Endpoint5_33(raw), - message: Endpoint5_34(raw), - environment: Endpoint5_35(raw), + stats: Endpoint5_1(raw), + create: Endpoint5_2(raw), + import: Endpoint5_3(raw), + export: Endpoint5_4(raw), + active: Endpoint5_5(raw), + get: Endpoint5_6(raw), + remove: Endpoint5_7(raw), + fork: Endpoint5_8(raw), + switchAgent: Endpoint5_9(raw), + switchModel: Endpoint5_10(raw), + rename: Endpoint5_11(raw), + move: Endpoint5_12(raw), + prompt: Endpoint5_13(raw), + command: Endpoint5_14(raw), + skill: Endpoint5_15(raw), + synthetic: Endpoint5_16(raw), + shell: Endpoint5_17(raw), + compact: Endpoint5_18(raw), + wait: Endpoint5_19(raw), + revert: { stage: Endpoint5_20(raw), clear: Endpoint5_21(raw), commit: Endpoint5_22(raw) }, + context: Endpoint5_23(raw), + inbox: { list: Endpoint5_24(raw), cancel: Endpoint5_25(raw), steer: Endpoint5_26(raw), queue: Endpoint5_27(raw) }, + instructions: { entry: { list: Endpoint5_28(raw), put: Endpoint5_29(raw), remove: Endpoint5_30(raw) } }, + generate: Endpoint5_31(raw), + log: Endpoint5_32(raw), + interrupt: Endpoint5_33(raw), + background: Endpoint5_34(raw), + message: Endpoint5_35(raw), + environment: Endpoint5_36(raw), }) const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) => diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 3d5b02b21e0..b5da01690bf 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -11,6 +11,8 @@ import type { PluginListOutput, SessionListInput, SessionListOutput, + SessionStatsInput, + SessionStatsOutput, SessionCreateInput, SessionCreateOutput, SessionImportInput, @@ -450,6 +452,23 @@ export function make(options: ClientOptions) { }, requestOptions, ), + stats: (input?: SessionStatsInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionStatsOutput }>( + { + method: "GET", + path: `/api/session/stats`, + query: { + from: input?.["from"], + to: input?.["to"], + project: input?.["project"], + timezone: input?.["timezone"], + }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), create: (input?: SessionCreateInput, requestOptions?: RequestOptions) => request<{ readonly data: SessionCreateOutput }>( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 3595110af77..2ae23c8ca00 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -30,6 +30,17 @@ export type FileDiffInfo = { status: "added" | "deleted" | "modified" } +export type SessionStatsActivity = { date: string; steps: number } + +export type SessionStatsToolUsage = { + name: string + calls: number + succeeded: number + failed: number + unfinished: number + durationP50?: number +} + export type PromptBase64 = string export type PromptFileSource = { type: "inline" } | { type: "uri"; uri: string } @@ -1097,6 +1108,8 @@ export type VcsInfo = { branch: VcsBranch } export type PermissionRuleset = Array +export type SessionStatsModelUsage = { model: ModelRef; steps: number; tokens: TokenUsageInfo; cost: MoneyUSD } + export type SessionStepEnded = { id: string created: number @@ -1648,6 +1661,22 @@ export type ConfigEntry = | { type: "agents"; path: string } | { type: "claude"; path: string } +export type SessionStatsInfo = { + range: { from: number; to: number } + sessions: number + subagents: number + prompts: number + steps: number + tokens: TokenUsageInfo + cost: MoneyUSD + tools: { calls: number; succeeded: number; failed: number; unfinished: number } + activeDays: number + streak: number + activity: Array + models: Array + toolUsage: Array +} + export type SessionInfo = { id: string parentID?: string @@ -2438,6 +2467,35 @@ export type SessionListInput = { export type SessionListOutput = SessionsResponse +export type SessionStatsInput = { + readonly from?: { + readonly from?: number | undefined + readonly to?: number | undefined + readonly project?: string | undefined + readonly timezone?: string | undefined + }["from"] + readonly to?: { + readonly from?: number | undefined + readonly to?: number | undefined + readonly project?: string | undefined + readonly timezone?: string | undefined + }["to"] + readonly project?: { + readonly from?: number | undefined + readonly to?: number | undefined + readonly project?: string | undefined + readonly timezone?: string | undefined + }["project"] + readonly timezone?: { + readonly from?: number | undefined + readonly to?: number | undefined + readonly project?: string | undefined + readonly timezone?: string | undefined + }["timezone"] +} + +export type SessionStatsOutput = { data: SessionStatsInfo }["data"] + export type SessionCreateInput = { readonly id?: { readonly id?: string | null diff --git a/packages/core/src/session/stats.ts b/packages/core/src/session/stats.ts new file mode 100644 index 00000000000..3d71d8d33e4 --- /dev/null +++ b/packages/core/src/session/stats.ts @@ -0,0 +1,261 @@ +export * as SessionStats from "./stats.js" + +import { DateTime, Effect, Option, Schema } from "effect" +import { and, eq, gte, inArray, lt } from "drizzle-orm" +import { Money } from "@opencode-ai/schema/money" +import { Project } from "@opencode-ai/schema/project" +import { SessionEvent } from "@opencode-ai/schema/session-event" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Database } from "../database/database.js" +import { EventTable } from "../event/sql.js" +import { SessionMessageTable, SessionTable } from "./sql.js" + +type Input = { + readonly from?: number + readonly to?: number + readonly projectID?: Project.ID + readonly timezone?: string +} + +type Tokens = { + input: number + output: number + reasoning: number + cache: { read: number; write: number } +} + +type ModelAggregate = { + model: SessionMessage.Assistant["model"] + steps: number + tokens: Tokens + cost: number +} + +type ToolAggregate = { + name: string + calls: number + succeeded: number + failed: number + unfinished: number + durations: number[] +} + +const decodeMessage = Schema.decodeUnknownOption(SessionMessage.Info) +const decodeUsage = Schema.decodeUnknownOption(SessionEvent.UsageRecorded.data) + +export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { + const db = (yield* Database.Service).db + const rows = yield* db + .select({ + id: SessionMessageTable.id, + sessionID: SessionMessageTable.session_id, + parentID: SessionTable.parent_id, + type: SessionMessageTable.type, + data: SessionMessageTable.data, + timeCreated: SessionMessageTable.time_created, + }) + .from(SessionMessageTable) + .innerJoin(SessionTable, eq(SessionMessageTable.session_id, SessionTable.id)) + .where( + and( + inArray(SessionMessageTable.type, ["user", "assistant"]), + input.from === undefined ? undefined : gte(SessionMessageTable.time_created, input.from), + input.to === undefined ? undefined : lt(SessionMessageTable.time_created, input.to), + input.projectID === undefined ? undefined : eq(SessionTable.project_id, input.projectID), + ), + ) + .all() + .pipe(Effect.orDie) + const sessionIDs = [...new Set(rows.map((row) => row.sessionID))] + const events = (yield* Effect.forEach( + Array.from({ length: Math.ceil(sessionIDs.length / 500) }, (_, index) => + sessionIDs.slice(index * 500, (index + 1) * 500), + ), + (batch) => + db + .select({ + created: EventTable.created, + data: EventTable.data, + }) + .from(EventTable) + .where( + and( + inArray(EventTable.aggregate_id, batch), + eq(EventTable.type, SessionEvent.UsageRecorded.type), + input.from === undefined ? undefined : gte(EventTable.created, input.from), + input.to === undefined ? undefined : lt(EventTable.created, input.to), + ), + ) + .all() + .pipe(Effect.orDie), + { concurrency: 4 }, + )).flat() + + const sessions = new Set() + const subagents = new Set() + const activity = new Map() + const models = new Map() + const tools = new Map() + const totals = { + prompts: 0, + steps: 0, + tokens: emptyTokens(), + cost: 0, + tools: { calls: 0, succeeded: 0, failed: 0, unfinished: 0 }, + } + const dateKey = makeDateKey(input.timezone) + + rows.forEach((row) => { + const decoded = decodeMessage({ ...row.data, id: row.id, type: row.type }) + if (Option.isNone(decoded)) return + const message = decoded.value + if (row.parentID) subagents.add(row.sessionID) + else sessions.add(row.sessionID) + + if (message.type === "user") { + if (!row.parentID) totals.prompts++ + return + } + if (message.type !== "assistant") return + + totals.steps++ + const tokens = message.tokens ?? emptyTokens() + const cost = message.cost ?? 0 + addTokens(totals.tokens, tokens) + totals.cost += cost + const day = dateKey(DateTime.toEpochMillis(message.time.created)) + activity.set(day, (activity.get(day) ?? 0) + 1) + + const modelKey = `${message.model.providerID}/${message.model.id}#${message.model.variant ?? ""}` + const model = models.get(modelKey) ?? { model: message.model, steps: 0, tokens: emptyTokens(), cost: 0 } + models.set(modelKey, model) + model.steps++ + model.cost += cost + addTokens(model.tokens, tokens) + + message.content + .filter((content): content is SessionMessage.AssistantTool => content.type === "tool") + .forEach((content) => { + const tool = tools.get(content.name) ?? { + name: content.name, + calls: 0, + succeeded: 0, + failed: 0, + unfinished: 0, + durations: [], + } + tools.set(content.name, tool) + tool.calls++ + totals.tools.calls++ + if (content.state.status === "completed") { + tool.succeeded++ + totals.tools.succeeded++ + } else if (content.state.status === "error") { + tool.failed++ + totals.tools.failed++ + } else { + tool.unfinished++ + totals.tools.unfinished++ + } + if (content.time.completed === undefined) return + tool.durations.push( + DateTime.toEpochMillis(content.time.completed) - + DateTime.toEpochMillis(content.time.ran ?? content.time.created), + ) + }) + }) + + events.forEach((row) => { + const decoded = decodeUsage(row.data) + if (Option.isNone(decoded)) return + addTokens(totals.tokens, decoded.value.tokens) + totals.cost += decoded.value.cost + }) + + const days = [...activity.entries()].sort(([a], [b]) => a.localeCompare(b)) + const now = Date.now() + const fallback = input.to ?? now + const earliestMessage = rows.reduce((earliest, row) => Math.min(earliest, row.timeCreated), fallback) + const earliest = events.reduce((value, event) => Math.min(value, event.created), earliestMessage) + const from = input.from ?? earliest + const to = input.to ?? now + + return { + range: { from: DateTime.makeUnsafe(from), to: DateTime.makeUnsafe(to) }, + sessions: sessions.size, + subagents: subagents.size, + prompts: totals.prompts, + steps: totals.steps, + tokens: totals.tokens, + cost: Money.USD.make(totals.cost), + tools: totals.tools, + activeDays: days.length, + streak: longestStreak(days.map(([date]) => date)), + activity: days.map(([date, steps]) => ({ date, steps })), + models: [...models.values()] + .sort((a, b) => tokenTotal(b.tokens) - tokenTotal(a.tokens)) + .map((model) => ({ ...model, cost: Money.USD.make(model.cost) })), + toolUsage: [...tools.values()] + .sort((a, b) => b.calls - a.calls) + .map((tool) => ({ + name: tool.name, + calls: tool.calls, + succeeded: tool.succeeded, + failed: tool.failed, + unfinished: tool.unfinished, + durationP50: median(tool.durations), + })), + } +}) + +function emptyTokens(): Tokens { + return { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } +} + +function addTokens(target: Tokens, source: Tokens) { + target.input += source.input + target.output += source.output + target.reasoning += source.reasoning + target.cache.read += source.cache.read + target.cache.write += source.cache.write +} + +function tokenTotal(tokens: Tokens) { + return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write +} + +function makeDateKey(timezone = "UTC") { + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + return (time: number) => { + const parts = Object.fromEntries(formatter.formatToParts(time).map((part) => [part.type, part.value])) + return `${parts.year}-${parts.month}-${parts.day}` + } +} + +function longestStreak(days: string[]) { + return days.reduce( + (result, day, index) => { + const previous = days[index - 1] + const current = previous && dayOrdinal(day) - dayOrdinal(previous) === 1 ? result.current + 1 : 1 + return { current, longest: Math.max(result.longest, current) } + }, + { current: 0, longest: 0 }, + ).longest +} + +function dayOrdinal(value: string) { + const [year, month, day] = value.split("-").map(Number) + return Math.floor(Date.UTC(year, month - 1, day) / 86_400_000) +} + +function median(values: number[]) { + if (values.length === 0) return undefined + const sorted = values.toSorted((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} diff --git a/packages/core/test/session-stats.test.ts b/packages/core/test/session-stats.test.ts new file mode 100644 index 00000000000..783ed4a4832 --- /dev/null +++ b/packages/core/test/session-stats.test.ts @@ -0,0 +1,185 @@ +import { describe, expect } from "bun:test" +import { Agent } from "@opencode-ai/schema/agent" +import { Event } from "@opencode-ai/schema/event" +import { Model } from "@opencode-ai/schema/model" +import { Money } from "@opencode-ai/schema/money" +import { Project } from "@opencode-ai/schema/project" +import { Provider } from "@opencode-ai/schema/provider" +import { Session } from "@opencode-ai/schema/session" +import { SessionEvent } from "@opencode-ai/schema/session-event" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Database } from "@opencode-ai/core/database/database" +import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { SessionStats } from "@opencode-ai/core/session/stats" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { DateTime, Effect, Schema } from "effect" +import { testEffect } from "./lib/effect" + +const it = testEffect(AppNodeBuilder.build(Database.node)) +const projectID = Project.ID.make("stats-project") +const sessionID = Session.ID.make("ses_stats_root") +const childID = Session.ID.make("ses_stats_child") +const encodeMessage = Schema.encodeSync(SessionMessage.Info) +const encodeUsage = Schema.encodeSync(SessionEvent.UsageRecorded.data) + +describe("SessionStats", () => { + it.effect("aggregates activity and tool reliability without reading message payloads outside the range", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + yield* db + .insert(ProjectTable) + .values({ id: projectID, worktree: AbsolutePath.make("/stats"), name: "stats", sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values([ + { id: sessionID, project_id: projectID, slug: "root", directory: "/stats", version: "test" }, + { + id: childID, + project_id: projectID, + parent_id: sessionID, + slug: "child", + directory: "/stats", + version: "test", + }, + ]) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionMessageTable) + .values([ + messageRow( + sessionID, + 1, + SessionMessage.User.make({ + id: SessionMessage.ID.make("msg_stats_user"), + type: "user", + text: "hello", + time: { created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 9)) }, + }), + ), + messageRow( + sessionID, + 2, + assistant("msg_stats_assistant", Date.UTC(2026, 0, 2, 10), [ + SessionMessage.AssistantTool.make({ + type: "tool", + id: "call_read", + name: "read", + state: SessionMessage.ToolStateCompleted.make({ + status: "completed", + input: {}, + content: [{ type: "text", text: "ok" }], + }), + time: { + created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10)), + ran: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10, 0, 1)), + completed: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10, 0, 1, 250)), + }, + }), + SessionMessage.AssistantTool.make({ + type: "tool", + id: "call_edit", + name: "edit", + state: SessionMessage.ToolStateError.make({ + status: "error", + input: {}, + error: { type: "tool", message: "failed" }, + }), + time: { + created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10)), + completed: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10, 0, 2)), + }, + }), + ]), + ), + messageRow(childID, 1, assistant("msg_stats_child", Date.UTC(2026, 0, 3, 10), [], "large", 2)), + messageRow(sessionID, 3, assistant("msg_stats_outside", Date.UTC(2025, 11, 31, 10), [])), + ]) + .run() + .pipe(Effect.orDie) + yield* db + .insert(EventSequenceTable) + .values([ + { aggregate_id: sessionID, seq: 0 }, + { aggregate_id: childID, seq: 0 }, + ]) + .run() + .pipe(Effect.orDie) + yield* db + .insert(EventTable) + .values({ + id: Event.ID.make("evt_stats_usage"), + aggregate_id: sessionID, + seq: 0, + created: Date.UTC(2026, 0, 2, 10, 0, 3), + type: SessionEvent.UsageRecorded.type, + data: encodeUsage({ + sessionID, + source: "title", + cost: Money.USD.make(0.5), + tokens: { input: 1, output: 1, reasoning: 1, cache: { read: 1, write: 1 } }, + }), + }) + .run() + .pipe(Effect.orDie) + + const stats = yield* SessionStats.get({ + from: Date.UTC(2026, 0, 1), + to: Date.UTC(2026, 1, 1), + timezone: "UTC", + }) + + expect(stats.sessions).toBe(1) + expect(stats.subagents).toBe(1) + expect(stats.prompts).toBe(1) + expect(stats.steps).toBe(2) + expect(stats.tokens).toEqual({ input: 31, output: 16, reasoning: 7, cache: { read: 13, write: 4 } }) + expect(stats.cost).toBe(Money.USD.make(5)) + expect(stats.tools).toEqual({ calls: 2, succeeded: 1, failed: 1, unfinished: 0 }) + expect(stats.activity).toEqual([ + { date: "2026-01-02", steps: 1 }, + { date: "2026-01-03", steps: 1 }, + ]) + expect(stats.streak).toBe(2) + expect(stats.models.map((model) => String(model.model.id))).toEqual(["large", "sonnet"]) + expect(stats.toolUsage).toMatchObject([ + { name: "read", calls: 1, succeeded: 1, failed: 0, durationP50: 250 }, + { name: "edit", calls: 1, succeeded: 0, failed: 1, durationP50: 2_000 }, + ]) + }), + ) +}) + +function assistant( + id: string, + created: number, + content: SessionMessage.AssistantContent[], + model = "sonnet", + scale = 1, +) { + return SessionMessage.Assistant.make({ + id: SessionMessage.ID.make(id), + type: "assistant", + agent: Agent.ID.make("build"), + model: { id: Model.ID.make(model), providerID: Provider.ID.make("anthropic") }, + content, + cost: Money.USD.make(1.5 * scale), + tokens: { input: 10 * scale, output: 5 * scale, reasoning: 2 * scale, cache: { read: 4 * scale, write: scale } }, + time: { created: DateTime.makeUnsafe(created), completed: DateTime.makeUnsafe(created + 2_000) }, + }) +} + +function messageRow( + sessionID: Session.ID, + seq: number, + message: SessionMessage.Info, +): typeof SessionMessageTable.$inferInsert { + const encoded = encodeMessage(message) + const { id, type, ...data } = encoded + return { id: SessionMessage.ID.make(id), session_id: sessionID, type, seq, time_created: encoded.time.created, data } +} diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 7b37a8ca31b..d4cdba8f0f6 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -657,6 +657,123 @@ } } }, + "/api/session/stats": { + "get": { + "tags": ["session"], + "operationId": "v2.session.stats", + "parameters": [ + { + "name": "from", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "to", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "project", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + }, + { + "name": "timezone", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionStats.Info" + } + }, + "required": ["data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Aggregate local session activity, usage, and tool reliability for a time range.", + "summary": "Get session statistics" + } + }, "/api/session/import": { "post": { "tags": ["session"], @@ -10256,6 +10373,238 @@ "required": ["created"], "additionalProperties": false }, + "SessionStats.Activity": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "steps": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": ["date", "steps"], + "additionalProperties": false + }, + "SessionStats.ModelUsage": { + "type": "object", + "properties": { + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "steps": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" + }, + "cost": { + "$ref": "#/components/schemas/Money.USD" + } + }, + "required": ["model", "steps", "tokens", "cost"], + "additionalProperties": false + }, + "SessionStats.ToolUsage": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "calls": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "succeeded": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "failed": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "unfinished": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "durationP50": { + "type": "number" + } + }, + "required": ["name", "calls", "succeeded", "failed", "unfinished"], + "additionalProperties": false + }, + "SessionStats.Info": { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "from": { + "type": "number" + }, + "to": { + "type": "number" + } + }, + "required": ["from", "to"], + "additionalProperties": false + }, + "sessions": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "subagents": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "prompts": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "steps": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" + }, + "cost": { + "$ref": "#/components/schemas/Money.USD" + }, + "tools": { + "type": "object", + "properties": { + "calls": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "succeeded": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "failed": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "unfinished": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": ["calls", "succeeded", "failed", "unfinished"], + "additionalProperties": false + }, + "activeDays": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "streak": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "activity": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionStats.Activity" + } + }, + "models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionStats.ModelUsage" + } + }, + "toolUsage": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionStats.ToolUsage" + } + } + }, + "required": [ + "range", + "sessions", + "subagents", + "prompts", + "steps", + "tokens", + "cost", + "tools", + "activeDays", + "streak", + "activity", + "models", + "toolUsage" + ], + "additionalProperties": false + }, "Session.Message.AgentSelected": { "type": "object", "properties": { diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 17aad57cf1d..13dc0e821f6 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -3,6 +3,7 @@ import { SessionTransfer } from "@opencode-ai/schema/session-transfer" import { SessionInbox } from "@opencode-ai/schema/session-inbox" import { PromptInput } from "@opencode-ai/schema/prompt-input" import { Session } from "@opencode-ai/schema/session" +import { SessionStats } from "@opencode-ai/schema/session-stats" import { InstructionEntry } from "@opencode-ai/schema/instruction-entry" import { Project } from "@opencode-ai/schema/project" import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema" @@ -146,6 +147,24 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.get("session.stats", "/api/session/stats", { + query: Schema.Struct({ + from: Schema.NumberFromString.pipe(Schema.optional), + to: Schema.NumberFromString.pipe(Schema.optional), + project: Project.ID.pipe(Schema.optional), + timezone: Schema.String.pipe(Schema.optional), + }), + success: Schema.Struct({ data: SessionStats.Info }), + error: InvalidRequestError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.stats", + summary: "Get session statistics", + description: "Aggregate local session activity, usage, and tool reliability for a time range.", + }), + ), + ) .add( HttpApiEndpoint.post("session.create", "/api/session", { payload: Schema.Struct({ diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index ec554688ac5..767687e747e 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -24,6 +24,7 @@ export { Vcs } from "./vcs.js" export { SessionInbox } from "./session-inbox.js" export { SessionError } from "./session-error.js" export { SessionMessage } from "./session-message.js" +export { SessionStats } from "./session-stats.js" export { SessionTransfer } from "./session-transfer.js" export { Snapshot } from "./snapshot.js" export { Shell } from "./shell.js" diff --git a/packages/schema/src/session-stats.ts b/packages/schema/src/session-stats.ts new file mode 100644 index 00000000000..532e59d78c3 --- /dev/null +++ b/packages/schema/src/session-stats.ts @@ -0,0 +1,56 @@ +export * as SessionStats from "./session-stats.js" + +import { Schema } from "effect" +import { Model } from "./model.js" +import { Money } from "./money.js" +import { DateTimeUtcFromMillis, NonNegativeInt, optional } from "./schema.js" +import { TokenUsage } from "./token-usage.js" + +export const Activity = Schema.Struct({ + date: Schema.String, + steps: NonNegativeInt, +}).annotate({ identifier: "SessionStats.Activity" }) +export type Activity = typeof Activity.Type + +export const ModelUsage = Schema.Struct({ + model: Model.Ref, + steps: NonNegativeInt, + tokens: TokenUsage.Info, + cost: Money.USD, +}).annotate({ identifier: "SessionStats.ModelUsage" }) +export type ModelUsage = typeof ModelUsage.Type + +export const ToolUsage = Schema.Struct({ + name: Schema.String, + calls: NonNegativeInt, + succeeded: NonNegativeInt, + failed: NonNegativeInt, + unfinished: NonNegativeInt, + durationP50: Schema.Finite.pipe(optional), +}).annotate({ identifier: "SessionStats.ToolUsage" }) +export type ToolUsage = typeof ToolUsage.Type + +export const Info = Schema.Struct({ + range: Schema.Struct({ + from: DateTimeUtcFromMillis, + to: DateTimeUtcFromMillis, + }), + sessions: NonNegativeInt, + subagents: NonNegativeInt, + prompts: NonNegativeInt, + steps: NonNegativeInt, + tokens: TokenUsage.Info, + cost: Money.USD, + tools: Schema.Struct({ + calls: NonNegativeInt, + succeeded: NonNegativeInt, + failed: NonNegativeInt, + unfinished: NonNegativeInt, + }), + activeDays: NonNegativeInt, + streak: NonNegativeInt, + activity: Schema.Array(Activity), + models: Schema.Array(ModelUsage), + toolUsage: Schema.Array(ToolUsage), +}).annotate({ identifier: "SessionStats.Info" }) +export type Info = typeof Info.Type diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index e5e71eef312..5ab070a1d81 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,4 +1,5 @@ import { Session } from "@opencode-ai/core/session" +import { SessionStats } from "@opencode-ai/core/session/stats" import { SessionTransfer } from "@opencode-ai/core/session/transfer" import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry" import { DateTime, Effect, Stream } from "effect" @@ -88,6 +89,26 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.stats", + Effect.fn(function* (ctx) { + if (ctx.query.from !== undefined && ctx.query.to !== undefined && ctx.query.from >= ctx.query.to) + return yield* new InvalidRequestError({ message: "Stats range must end after it starts" }) + const timezone = ctx.query.timezone ?? "UTC" + yield* Effect.try({ + try: () => new Intl.DateTimeFormat("en-US", { timeZone: timezone }), + catch: () => new InvalidRequestError({ message: `Invalid time zone: ${timezone}` }), + }) + return { + data: yield* SessionStats.get({ + from: ctx.query.from, + to: ctx.query.to, + projectID: ctx.query.project, + timezone, + }), + } + }), + ) .handle( "session.create", Effect.fn(function* (ctx) {