feat(cli): add shareable stats command (#43653)

This commit is contained in:
Shoubhit Dash 2026-08-24 16:47:23 +05:30 committed by GitHub
parent 3b8949b1ee
commit 2e1d7c84ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 11368 additions and 3660 deletions

View file

@ -1,4 +1,5 @@
import { Argument, Flag } from "effect/unstable/cli"
import { Schema } from "effect"
import { Spec } from "../framework/spec"
declare const OPENCODE_CLI_NAME: string | undefined
@ -187,6 +188,37 @@ 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.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
Flag.withDescription("Show the last N days; 0 means today"),
Flag.optional,
),
year: Flag.integer("year").pipe(
Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1970, maximum: 9_999 }))),
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.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
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: {

View file

@ -0,0 +1,402 @@
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"
import { errorMessage } from "../../util/error"
const handler = Effect.fn("cli.stats")(function* (input: Runtime.Input<typeof Commands.commands.stats>) {
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"))
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* request(server.endpoint.url, (signal) =>
client.location
.get({ location: { directory: process.cwd() } }, { signal })
.then((location) => location.project.id),
)
: project
const details = input.models || input.tools || input.cost || input.full
const stats = yield* request(server.endpoint.url, (signal) =>
client.session.stats(
{
from: range.from,
to: range.to,
project: projectID,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
models: input.json || input.models || input.full,
tools: input.json || input.tools || input.full,
toolSummary: input.json || input.tools || input.full || !details,
},
{ signal },
),
)
const output = input.json
? JSON.stringify(stats, null, 2)
: renderStats(stats, {
label: range.label,
scope: project === undefined ? "all projects" : project === "." ? "current project" : "selected project",
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,
width: process.stdout.columns ?? 80,
})
process.stdout.write(output + EOL)
})
export default Runtime.handler(Commands.commands.stats, (input) =>
handler(input).pipe(
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(errorMessage(error) + EOL)
process.exitCode = 1
}),
),
),
)
function request<A>(url: string, run: (signal: AbortSignal) => Promise<A>) {
return Effect.tryPromise({
try: () => run(AbortSignal.timeout(30_000)),
catch: (cause) => new Error(`Could not reach server at ${url}`, { cause }),
})
}
type RenderOptions = {
label: string
scope: string
models: boolean
tools: boolean
cost: boolean
limit: number
color: boolean
width: number
}
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)} tool success`
const details = options.models || options.tools || options.cost
const empty = stats.sessions === 0 && stats.prompts === 0 && stats.steps === 0
const heading = `${style("opencode stats", primary, options.color)} ${style(`· ${options.label} · ${options.scope}`, "2", options.color)}`
const lines = details
? [style(`${options.label} · ${options.scope}`, "2", options.color)]
: empty
? [
heading,
"",
style("no activity in this range", "2", options.color),
"",
style("opencode.ai", "2", options.color),
]
: [
heading,
"",
...renderActivity(stats.activity, stats.range.from, stats.range.to, options.color, options.width),
"",
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(...(lines.length > 0 ? [""] : []), ...renderCost(stats))
if (options.models)
lines.push(...(lines.length > 0 ? [""] : []), ...renderModels(stats, options.limit, options.width))
if (options.tools) lines.push(...(lines.length > 0 ? [""] : []), ...renderTools(stats, options.limit, options.width))
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,
width: number,
) {
const values = new Map(activity.map((day) => [day.date, day.steps]))
const rangeStart = dateOrdinal(new Date(from))
const rangeEnd = dateOrdinal(new Date(to - 1))
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 maxWeeks = Math.max(1, Math.min(53, width - 4))
const totalWeeks = Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1
const latest = new Date(end)
latest.setDate(latest.getDate() - (maxWeeks - 1) * 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)
const ordinal = dateOrdinal(date)
if (ordinal < rangeStart || ordinal > rangeEnd) return " "
return activityGlyph(values.get(dateKey(date)) ?? 0, levels, color)
}),
)
const weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
return [
style(totalWeeks > maxWeeks ? `activity · last ${maxWeeks} weeks` : "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 + stats.tokens.cache.write
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, width: number) {
if (stats.models.length === 0) return ["MODELS", " no model usage"]
const models = stats.models.slice(0, limit)
const more = stats.models.length - models.length
if (width < 68)
return [
"MODELS",
...models.flatMap((item) => [
truncate(
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
width,
),
` ${formatNumber(tokenTotal(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
]),
...(more > 0 ? ["", `+${more.toLocaleString("en-US")} more model${more === 1 ? "" : "s"}`] : []),
]
return [
"MODELS",
tableHeader("model", "tokens", "steps", "cost"),
...models.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)}`,
),
),
...(more > 0 ? ["", `+${more.toLocaleString("en-US")} more model${more === 1 ? "" : "s"}`] : []),
]
}
function renderTools(stats: SessionStatsInfo, limit: number, width: number) {
if (stats.toolUsage.length === 0) return ["TOOL RELIABILITY", " no tool calls"]
const tools = stats.toolUsage.slice(0, limit)
const more = stats.toolUsage.length - tools.length
if (width < 68)
return [
"TOOL RELIABILITY",
...tools.flatMap((tool) => {
const terminal = tool.succeeded + tool.failed
return [
truncate(tool.name, width),
` ${formatNumber(tool.calls)} calls · ${terminal === 0 ? "-" : formatPercent((tool.failed / terminal) * 100)} error · ${tool.durationP50 === undefined ? "-" : formatDuration(tool.durationP50)} p50`,
]
}),
"",
`${formatNumber(stats.tools.succeeded + stats.tools.failed)} finished calls · ${formatNumber(stats.tools.unfinished)} unfinished`,
...(more > 0 ? [`+${more.toLocaleString("en-US")} more tool${more === 1 ? "" : "s"}`] : []),
]
return [
"TOOL RELIABILITY",
tableHeader("tool", "calls", "error", "p50"),
...tools.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)} finished calls · ${formatNumber(stats.tools.unfinished)} unfinished`,
...(more > 0 ? [`+${more.toLocaleString("en-US")} more tool${more === 1 ? "" : "s"}`] : []),
]
}
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"],
}
if (Number.isFinite(background))
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"],
}
return { primary: "36", activity: ["2;36", "36", "1;36", "1;96"] }
}
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)
}

View file

@ -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"),

View file

@ -0,0 +1,124 @@
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 · all projects")
expect(output).toContain("activity")
expect(output).toMatch(/Mo .*(?:\r?\n){2}Tu/)
expect(output).toMatch(/Su .*(?:\r?\n){2} less/)
expect(output).toContain("less ·░▒▓█ more")
expect(output).toContain("2 sessions · 1 subagent")
expect(output).toContain("80.0% tool success · 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).toContain("cached input 32.3%")
expect(output).not.toContain("opencode stats")
expect(output).not.toContain("activity")
})
test("shows when detail tables omit rows", () => {
const output = renderStats(
{
...stats,
models: [
...stats.models,
{
model: { providerID: "anthropic", id: "haiku" },
steps: 2,
tokens: { input: 2_000, output: 500, reasoning: 0, cache: { read: 1_000, write: 0 } },
cost: 1.25,
},
],
toolUsage: [
...stats.toolUsage,
{ name: "grep", calls: 4, succeeded: 4, failed: 0, unfinished: 0, durationP50: 20 },
],
},
options({ models: true, tools: true, limit: 1 }),
)
expect(output).toContain("+1 more model")
expect(output).toContain("+1 more tool")
})
test("uses the OpenCode palette in color mode", () => {
const output = renderStats(stats, options({ color: true }))
expect(output).toContain("\x1b[1;36m")
expect(output).not.toContain("38;5;45")
})
test("uses compact layouts in narrow terminals", () => {
const output = renderStats(stats, options({ models: true, width: 48 }))
expect(output).toContain("anthropic/sonnet")
expect(output).toContain("18.5k tokens · 6 steps · $12.34")
expect(output.split(/\r?\n/).every((line) => line.length <= 48)).toBe(true)
})
test("renders a concise empty state", () => {
const output = renderStats(
{ ...stats, sessions: 0, subagents: 0, prompts: 0, steps: 0, activeDays: 0, streak: 0, activity: [] },
options(),
)
expect(output).toContain("no activity in this range")
expect(output).not.toContain("less ·░▒▓█ more")
})
test("labels activity when terminal width truncates the requested range", () => {
const output = renderStats(
{ ...stats, range: { from: Date.UTC(2020, 0, 1), to: Date.UTC(2026, 0, 8) } },
options({ width: 20 }),
)
expect(output).toContain("activity · last 16 weeks")
})
})
function options(input: Partial<Parameters<typeof renderStats>[1]> = {}): Parameters<typeof renderStats>[1] {
return {
label: "2026 so far",
scope: "all projects",
models: false,
tools: false,
cost: false,
limit: 5,
color: false,
width: 80,
...input,
}
}

View file

@ -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,6 +112,59 @@ export type SessionListOutput = {
}
export type SessionListOperation<E = never> = (input?: SessionListInput) => Effect.Effect<SessionListOutput, E>
export type SessionStatsInput = {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: Project.ID | undefined
readonly timezone?: string | undefined
readonly models?: boolean | undefined
readonly tools?: boolean | undefined
readonly toolSummary?: boolean | undefined
}
export type SessionStatsOutput = {
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<E = never> = (input?: SessionStatsInput) => Effect.Effect<SessionStatsOutput, E>
export type SessionCreateInput = {
readonly id?: Session.ID | undefined
readonly title?: string | undefined
@ -966,6 +1020,7 @@ export type SessionViewOperation<E = never> = (input: SessionViewInput) => Effec
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
readonly stats: SessionStatsOperation<E>
readonly create: SessionCreateOperation<E>
readonly import: SessionImportOperation<E>
readonly export: SessionExportOperation<E>

View file

@ -17,6 +17,8 @@ import type {
PluginListOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
SessionStatsOutput,
SessionCreateInput,
SessionCreateOutput,
SessionImportInput,
@ -305,6 +307,24 @@ const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: Sessi
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointSessionStats = (raw: RawClient["server.session"]) => (input?: SessionStatsInput) =>
preserveEffect<SessionStatsOutput>()(
raw["session.stats"]({
query: {
from: input?.["from"],
to: input?.["to"],
project: input?.["project"],
timezone: input?.["timezone"],
models: input?.["models"],
tools: input?.["tools"],
toolSummary: input?.["toolSummary"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: SessionCreateInput) =>
preserveEffect<SessionCreateOutput>()(
raw["session.create"]({
@ -632,6 +652,7 @@ const EndpointSessionView = (raw: RawClient["server.session"]) => (input: Sessio
const adaptGroupSession = (raw: RawClient["server.session"]) => ({
list: EndpointSessionList(raw),
stats: EndpointSessionStats(raw),
create: EndpointSessionCreate(raw),
import: EndpointSessionImport(raw),
export: EndpointSessionExport(raw),

View file

@ -11,6 +11,8 @@ import type {
PluginListOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
SessionStatsOutput,
SessionCreateInput,
SessionCreateOutput,
SessionImportInput,
@ -454,6 +456,26 @@ 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"],
models: input?.["models"],
tools: input?.["tools"],
toolSummary: input?.["toolSummary"],
},
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input?: SessionCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionCreateOutput }>(
{

View file

@ -37,6 +37,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 SessionMessageAgentSelected = {
id: string
metadata?: { [x: string]: JsonValue }
@ -422,6 +433,8 @@ export type V2EventServerConnected = {
export type SessionRevert = { messageID: string; partID?: string; snapshot?: string; files?: Array<FileDiffInfo> }
export type SessionStatsModelUsage = { model: ModelRef; steps: number; tokens: TokenUsageInfo; cost: MoneyUSD }
export type SessionMessageModelSelected = {
id: string
metadata?: { [x: string]: JsonValue }
@ -1538,6 +1551,22 @@ export type SessionRevertStaged = {
data: { sessionID: string; revert: SessionRevert }
}
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<SessionStatsActivity>
models: Array<SessionStatsModelUsage>
toolUsage: Array<SessionStatsToolUsage>
}
export type SessionMessageUser = {
id: string
metadata?: { [x: string]: JsonValue }
@ -2446,6 +2475,74 @@ 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
readonly models?: boolean | undefined
readonly tools?: boolean | undefined
readonly toolSummary?: boolean | undefined
}["from"]
readonly to?: {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: string | undefined
readonly timezone?: string | undefined
readonly models?: boolean | undefined
readonly tools?: boolean | undefined
readonly toolSummary?: boolean | undefined
}["to"]
readonly project?: {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: string | undefined
readonly timezone?: string | undefined
readonly models?: boolean | undefined
readonly tools?: boolean | undefined
readonly toolSummary?: boolean | undefined
}["project"]
readonly timezone?: {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: string | undefined
readonly timezone?: string | undefined
readonly models?: boolean | undefined
readonly tools?: boolean | undefined
readonly toolSummary?: boolean | undefined
}["timezone"]
readonly models?: {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: string | undefined
readonly timezone?: string | undefined
readonly models?: boolean | undefined
readonly tools?: boolean | undefined
readonly toolSummary?: boolean | undefined
}["models"]
readonly tools?: {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: string | undefined
readonly timezone?: string | undefined
readonly models?: boolean | undefined
readonly tools?: boolean | undefined
readonly toolSummary?: boolean | undefined
}["tools"]
readonly toolSummary?: {
readonly from?: number | undefined
readonly to?: number | undefined
readonly project?: string | undefined
readonly timezone?: string | undefined
readonly models?: boolean | undefined
readonly tools?: boolean | undefined
readonly toolSummary?: boolean | undefined
}["toolSummary"]
}
export type SessionStatsOutput = { data: SessionStatsInfo }["data"]
export type SessionCreateInput = {
readonly id?: {
readonly id?: string | null

View file

@ -0,0 +1,387 @@
export * as SessionStats from "./stats.js"
import { DateTime, Effect, Option, Schema } from "effect"
import { and, eq, gte, inArray, lt, sql } from "drizzle-orm"
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 { SessionEvent } from "@opencode-ai/schema/session-event"
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
readonly models?: boolean
readonly tools?: boolean
readonly toolSummary?: boolean
}
type Tokens = {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
type MessageRow = {
sessionID: string
parentID: string | null
type: "user" | "assistant"
timeCreated: number
providerID: string | null
modelID: string | null
variant: string | null
input: number | null
output: number | null
reasoning: number | null
cacheRead: number | null
cacheWrite: number | null
cost: number | null
}
type ToolRow = {
name: string | null
status: string | null
duration: number | null
}
type ToolSummaryRow = { status: string | null; count: number }
type ModelAggregate = {
model: Model.Ref
steps: number
tokens: Tokens
cost: number
}
type ToolAggregate = {
name: string
calls: number
succeeded: number
failed: number
unfinished: number
durations: number[]
}
const decodeUsage = Schema.decodeUnknownOption(SessionEvent.UsageRecorded.data)
const Window = 31 * 24 * 60 * 60 * 1_000
export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
const db = (yield* Database.Service).db
const now = Date.now()
const to = input.to ?? now
const earliest =
input.from ??
(yield* db
.get<{ time: number | null }>(sql`SELECT min(time_created) AS time FROM ${SessionMessageTable}`)
.pipe(Effect.orDie))?.time ??
to
const ranges = windows(earliest, to)
const sessions = new Set<string>()
const subagents = new Set<string>()
const sessionIDs = new Set<string>()
const activity = new Map<string, number>()
const models = new Map<string, ModelAggregate>()
const tools = new Map<string, ToolAggregate>()
const totals = {
prompts: 0,
steps: 0,
tokens: emptyTokens(),
cost: 0,
tools: { calls: 0, succeeded: 0, failed: 0, unfinished: 0 },
}
const dateKey = makeDateKey(input.timezone)
const project = input.projectID === undefined ? sql`` : sql`AND session.project_id = ${input.projectID}`
yield* Effect.forEach(
ranges,
(range) =>
db
.all<MessageRow>(
sql`
SELECT
message.session_id AS sessionID,
session.parent_id AS parentID,
message.type AS type,
message.time_created AS timeCreated,
json_extract(message.data, '$.model.providerID') AS providerID,
json_extract(message.data, '$.model.id') AS modelID,
json_extract(message.data, '$.model.variant') AS variant,
json_extract(message.data, '$.tokens.input') AS input,
json_extract(message.data, '$.tokens.output') AS output,
json_extract(message.data, '$.tokens.reasoning') AS reasoning,
json_extract(message.data, '$.tokens.cache.read') AS cacheRead,
json_extract(message.data, '$.tokens.cache.write') AS cacheWrite,
json_extract(message.data, '$.cost') AS cost
FROM ${SessionMessageTable} AS message
JOIN ${SessionTable} AS session ON session.id = message.session_id
WHERE message.type IN ('user', 'assistant')
AND message.time_created >= ${range.from}
AND message.time_created < ${range.to}
AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created)
${project}
`,
)
.pipe(
Effect.orDie,
Effect.tap((rows) =>
Effect.sync(() => {
rows.forEach((row) => {
sessionIDs.add(row.sessionID)
if (row.parentID === null) sessions.add(row.sessionID)
else subagents.add(row.sessionID)
if (row.type === "user") {
if (row.parentID === null) totals.prompts++
return
}
totals.steps++
const tokens = rowTokens(row)
addTokens(totals.tokens, tokens)
totals.cost += row.cost ?? 0
const day = dateKey(row.timeCreated)
activity.set(day, (activity.get(day) ?? 0) + 1)
if (!input.models || !row.providerID || !row.modelID) return
const key = `${row.providerID}/${row.modelID}#${row.variant ?? ""}`
const model = models.get(key) ?? {
model: {
providerID: Provider.ID.make(row.providerID),
id: Model.ID.make(row.modelID),
variant: row.variant ? Model.VariantID.make(row.variant) : undefined,
},
steps: 0,
tokens: emptyTokens(),
cost: 0,
}
models.set(key, model)
model.steps++
model.cost += row.cost ?? 0
addTokens(model.tokens, tokens)
})
}),
),
),
{ concurrency: 1, discard: true },
)
if (input.tools || input.toolSummary)
yield* Effect.forEach(
ranges,
(range) => {
if (!input.tools)
return db
.all<ToolSummaryRow>(
sql`
SELECT json_extract(content.value, '$.state.status') AS status, count(*) AS count
FROM ${SessionMessageTable} AS message
JOIN ${SessionTable} AS session ON session.id = message.session_id,
json_each(message.data, '$.content') AS content
WHERE message.type = 'assistant'
AND message.time_created >= ${range.from}
AND message.time_created < ${range.to}
AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created)
AND json_extract(content.value, '$.type') = 'tool'
${project}
GROUP BY status
`,
)
.pipe(
Effect.orDie,
Effect.tap((rows) =>
Effect.sync(() => rows.forEach((row) => addToolStatus(totals.tools, row.status, row.count))),
),
Effect.asVoid,
)
return db
.all<ToolRow>(
sql`
SELECT
json_extract(content.value, '$.name') AS name,
json_extract(content.value, '$.state.status') AS status,
CASE
WHEN json_extract(content.value, '$.time.completed') IS NULL THEN NULL
ELSE json_extract(content.value, '$.time.completed')
- coalesce(json_extract(content.value, '$.time.ran'), json_extract(content.value, '$.time.created'))
END AS duration
FROM ${SessionMessageTable} AS message
JOIN ${SessionTable} AS session ON session.id = message.session_id,
json_each(message.data, '$.content') AS content
WHERE message.type = 'assistant'
AND message.time_created >= ${range.from}
AND message.time_created < ${range.to}
AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created)
AND json_extract(content.value, '$.type') = 'tool'
${project}
`,
)
.pipe(
Effect.orDie,
Effect.tap((rows) =>
Effect.sync(() => {
rows.forEach((row) => {
addToolStatus(totals.tools, row.status, 1)
if (!row.name) return
const tool = tools.get(row.name) ?? {
name: row.name,
calls: 0,
succeeded: 0,
failed: 0,
unfinished: 0,
durations: [],
}
tools.set(row.name, tool)
addToolStatus(tool, row.status, 1)
if (row.duration !== null) tool.durations.push(row.duration)
})
}),
),
Effect.asVoid,
)
},
{ concurrency: 1, discard: true },
)
const ids = [...sessionIDs]
const events = (yield* Effect.forEach(
Array.from({ length: Math.ceil(ids.length / 500) }, (_, index) => ids.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()
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))
return {
range: { from: DateTime.makeUnsafe(earliest), 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 windows(from: number, to: number) {
return Array.from({ length: Math.max(1, Math.ceil((to - from) / Window)) }, (_, index) => ({
from: from + index * Window,
to: Math.min(to, from + (index + 1) * Window),
}))
}
function rowTokens(row: MessageRow): Tokens {
return {
input: row.input ?? 0,
output: row.output ?? 0,
reasoning: row.reasoning ?? 0,
cache: { read: row.cacheRead ?? 0, write: row.cacheWrite ?? 0 },
}
}
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 addToolStatus(
target: { calls: number; succeeded: number; failed: number; unfinished: number },
status: string | null,
count: number,
) {
target.calls += count
if (status === "completed") {
target.succeeded += count
return
}
if (status === "error") {
target.failed += count
return
}
target.unfinished += count
}
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]
}

View file

@ -0,0 +1,239 @@
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 forkID = Session.ID.make("ses_stats_fork")
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",
},
{
id: forkID,
project_id: projectID,
fork_session_id: sessionID,
slug: "fork",
directory: "/stats",
version: "test",
time_created: Date.UTC(2026, 0, 4),
},
])
.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(
forkID,
1,
SessionMessage.User.make({
id: SessionMessage.ID.make("msg_stats_fork_copied_user"),
type: "user",
text: "copied",
time: { created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 9)) },
}),
),
messageRow(
forkID,
2,
assistant("msg_stats_fork_copied_assistant", Date.UTC(2026, 0, 2, 10), [
SessionMessage.AssistantTool.make({
type: "tool",
id: "call_copied",
name: "copied",
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: {},
content: [{ type: "text", text: "copied" }],
}),
time: {
created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10)),
completed: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10, 0, 1)),
},
}),
]),
),
messageRow(forkID, 3, assistant("msg_stats_fork_new", Date.UTC(2026, 0, 5, 10), [], "fork-new")),
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",
models: true,
tools: true,
})
expect(stats.sessions).toBe(2)
expect(stats.subagents).toBe(1)
expect(stats.prompts).toBe(1)
expect(stats.steps).toBe(3)
expect(stats.tokens).toEqual({ input: 41, output: 21, reasoning: 9, cache: { read: 17, write: 5 } })
expect(stats.cost).toBe(Money.USD.make(6.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 },
{ date: "2026-01-05", steps: 1 },
])
expect(stats.streak).toBe(2)
expect(stats.models.map((model) => String(model.model.id))).toEqual(["large", "sonnet", "fork-new"])
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 },
])
const summary = yield* SessionStats.get({
from: Date.UTC(2026, 0, 1),
to: Date.UTC(2026, 1, 1),
timezone: "UTC",
toolSummary: true,
})
expect(summary.models).toEqual([])
expect(summary.toolUsage).toEqual([])
expect(summary.tools).toEqual({ calls: 2, succeeded: 1, failed: 1, unfinished: 0 })
}),
)
})
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 }
}

File diff suppressed because it is too large Load diff

View file

@ -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, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
@ -146,6 +147,27 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(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),
models: BooleanFromString.pipe(Schema.optional),
tools: BooleanFromString.pipe(Schema.optional),
toolSummary: BooleanFromString.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({

View file

@ -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"

View file

@ -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

View file

@ -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"
@ -86,6 +87,29 @@ 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,
models: ctx.query.models,
tools: ctx.query.tools,
toolSummary: ctx.query.toolSummary,
}),
}
}),
)
.handle(
"session.create",
Effect.fn(function* (ctx) {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff