@dijkspicy/opencode-stats-reporter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/paths.js ADDED
@@ -0,0 +1,37 @@
1
+ import os from "node:os"
2
+ import path from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+
5
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
6
+ export const PKG_ROOT = path.resolve(HERE, "..")
7
+
8
+ export function configDir() {
9
+ const base = process.env.MONITOR_CONFIG_DIR || path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "opencode-stats-reporter")
10
+ return base
11
+ }
12
+
13
+ export function dataDir() {
14
+ const base = process.env.MONITOR_DATA_DIR || path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"), "opencode-stats-reporter")
15
+ return base
16
+ }
17
+
18
+ export function configFile() {
19
+ return path.join(configDir(), "config.json")
20
+ }
21
+
22
+ export function dbFile() {
23
+ return path.join(dataDir(), "monitor.db")
24
+ }
25
+
26
+ export function panelDir() {
27
+ return path.join(PKG_ROOT, "panel")
28
+ }
29
+
30
+ export function pluginSource() {
31
+ return path.join(PKG_ROOT, "contrib", "opencode-session-reporter", "session-usage-reporter.ts")
32
+ }
33
+
34
+ export function opencodePluginsDir() {
35
+ const base = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), ".config", "opencode")
36
+ return path.join(base, "plugins")
37
+ }
package/src/proxy.js ADDED
@@ -0,0 +1,31 @@
1
+ /** 把用量类请求反代到上游网关;鉴权只在本进程内附加,不暴露给浏览器。 */
2
+ export function createProxy({ gatewayBaseUrl, apiKey }) {
3
+ return async function proxy(req, res, url) {
4
+ const target = gatewayBaseUrl.replace(/\/+$/, "") + url.pathname + (url.search || "")
5
+ const headers = { authorization: "Bearer " + apiKey }
6
+ if (req.headers["content-type"]) headers["content-type"] = req.headers["content-type"]
7
+ const init = { method: req.method, headers }
8
+ if (req.method !== "GET" && req.method !== "HEAD") init.body = await readBody(req)
9
+ let upstream
10
+ try {
11
+ upstream = await fetch(target, init)
12
+ } catch (err) {
13
+ res.writeHead(502, { "content-type": "application/json; charset=utf-8" })
14
+ res.end(JSON.stringify({ error: { message: "上游网关不可达: " + err.message, type: "upstream_error", code: "gateway_unreachable" } }))
15
+ return
16
+ }
17
+ const buf = Buffer.from(await upstream.arrayBuffer())
18
+ const outHeaders = { "content-type": upstream.headers.get("content-type") || "application/json; charset=utf-8" }
19
+ res.writeHead(upstream.status, outHeaders)
20
+ res.end(buf)
21
+ }
22
+ }
23
+
24
+ function readBody(req) {
25
+ return new Promise((resolve, reject) => {
26
+ const chunks = []
27
+ req.on("data", (c) => chunks.push(c))
28
+ req.on("end", () => resolve(Buffer.concat(chunks)))
29
+ req.on("error", reject)
30
+ })
31
+ }
package/src/server.js ADDED
@@ -0,0 +1,164 @@
1
+ import http from "node:http"
2
+ import fs from "node:fs"
3
+ import path from "node:path"
4
+ import { ApiError, report, listSessions, sessionDetail, sessionsSummary } from "./session-api.js"
5
+ import { createProxy } from "./proxy.js"
6
+ import { createIdentityResolver } from "./identity.js"
7
+
8
+ const MAX_BODY = 1024 * 1024
9
+
10
+ const MIME = {
11
+ ".html": "text/html; charset=utf-8",
12
+ ".js": "text/javascript; charset=utf-8",
13
+ ".css": "text/css; charset=utf-8",
14
+ ".json": "application/json; charset=utf-8",
15
+ ".svg": "image/svg+xml",
16
+ ".png": "image/png",
17
+ ".ico": "image/x-icon",
18
+ }
19
+
20
+ // 本地模式:把面板运行配置指向本地源,并清掉可能覆盖它的 localStorage 键
21
+ const INJECT = `<script>(function(){try{
22
+ window.APP_CONFIG=Object.assign({},window.APP_CONFIG||{},{gatewayBaseUrl:location.origin,apiKey:"local"});
23
+ var k="blueregion-usage.config.v1";var raw=localStorage.getItem(k);
24
+ if(raw){var o=JSON.parse(raw)||{};delete o.gatewayBaseUrl;delete o.apiKey;localStorage.setItem(k,JSON.stringify(o));}
25
+ }catch(e){}})();</script>`
26
+
27
+ export function injectConfig(html) {
28
+ const marker = '<script src="config.js"></script>'
29
+ if (html.includes(marker)) return html.replace(marker, INJECT + "\n" + marker)
30
+ return html.replace("</head>", INJECT + "\n</head>")
31
+ }
32
+
33
+ function sendJson(res, status, payload) {
34
+ const body = Buffer.from(JSON.stringify(payload), "utf8")
35
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "content-length": body.length })
36
+ res.end(body)
37
+ }
38
+
39
+ function sendError(res, status, message, code, type = "invalid_request_error") {
40
+ sendJson(res, status, { error: { message, type, code } })
41
+ }
42
+
43
+ function readBody(req) {
44
+ return new Promise((resolve, reject) => {
45
+ const chunks = []
46
+ let size = 0
47
+ req.on("data", (c) => {
48
+ size += c.length
49
+ if (size > MAX_BODY) {
50
+ reject(new ApiError(413, "请求体超过上限", "body_too_large"))
51
+ req.destroy()
52
+ return
53
+ }
54
+ chunks.push(c)
55
+ })
56
+ req.on("end", () => resolve(Buffer.concat(chunks)))
57
+ req.on("error", reject)
58
+ })
59
+ }
60
+
61
+ export function createServer({ config, store, panelDir, mode = "local", consumerHeader = null }) {
62
+ const proxy = createProxy(config)
63
+ const resolveConsumer = createIdentityResolver({ mode, headerName: consumerHeader })
64
+
65
+ function serveStatic(res, urlPath) {
66
+ const rel = urlPath === "/" ? "/index.html" : urlPath
67
+ const file = path.join(panelDir, path.normalize(rel))
68
+ if (!file.startsWith(panelDir)) {
69
+ sendError(res, 403, "forbidden", "forbidden")
70
+ return
71
+ }
72
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
73
+ if (rel === "/config.js") {
74
+ const body = Buffer.from("/* 由 opencode-stats-reporter 托管 */\n")
75
+ res.writeHead(200, { "content-type": MIME[".js"], "content-length": body.length })
76
+ res.end(body)
77
+ return
78
+ }
79
+ sendError(res, 404, "not found", "not_found")
80
+ return
81
+ }
82
+ let body = fs.readFileSync(file)
83
+ const ext = path.extname(file).toLowerCase()
84
+ if (ext === ".html") body = Buffer.from(injectConfig(body.toString("utf8")), "utf8")
85
+ res.writeHead(200, { "content-type": MIME[ext] || "application/octet-stream", "content-length": body.length })
86
+ res.end(body)
87
+ }
88
+
89
+ const server = http.createServer(async (req, res) => {
90
+ const url = new URL(req.url, "http://localhost")
91
+ const p = url.pathname
92
+ try {
93
+ if (p === "/healthz") {
94
+ sendJson(res, 200, { object: "health", ok: true, mode })
95
+ return
96
+ }
97
+
98
+ // 用量类 → 反代到上游网关
99
+ if (p === "/v1/models" || p.startsWith("/v1/usage/")) {
100
+ await proxy(req, res, url)
101
+ return
102
+ }
103
+
104
+ // 会话类 → 本地存储;托管模式需网关注入身份,本地模式归入单一本地用户
105
+ const isSessionRoute = p === "/v1/session-usage/report" || p === "/v1/sessions" || p.startsWith("/v1/sessions/")
106
+ if (isSessionRoute) {
107
+ const consumer = resolveConsumer(req)
108
+ if (!consumer) {
109
+ sendError(res, 401, "缺少或非法的消费者身份", "missing_consumer", "authentication_error")
110
+ return
111
+ }
112
+ if (p === "/v1/session-usage/report" && req.method === "POST") {
113
+ let body
114
+ try {
115
+ body = JSON.parse((await readBody(req)).toString("utf8"))
116
+ } catch (e) {
117
+ if (e instanceof ApiError) {
118
+ sendError(res, e.status, e.message, e.code, e.type)
119
+ return
120
+ }
121
+ sendError(res, 400, "请求体不是合法 JSON", "invalid_body")
122
+ return
123
+ }
124
+ sendJson(res, 200, report(store, consumer, body))
125
+ return
126
+ }
127
+ if (p === "/v1/sessions" && req.method === "GET") {
128
+ sendJson(res, 200, listSessions(store, consumer, url.searchParams))
129
+ return
130
+ }
131
+ if (p === "/v1/sessions/summary" && req.method === "GET") {
132
+ sendJson(res, 200, sessionsSummary(store, consumer, url.searchParams))
133
+ return
134
+ }
135
+ if (p.startsWith("/v1/sessions/") && req.method === "GET") {
136
+ const id = decodeURIComponent(p.slice("/v1/sessions/".length))
137
+ if (!id || id.includes("/")) {
138
+ sendError(res, 404, "not found", "not_found")
139
+ return
140
+ }
141
+ sendJson(res, 200, sessionDetail(store, consumer, id, url.searchParams))
142
+ return
143
+ }
144
+ sendError(res, 404, "not found", "not_found")
145
+ return
146
+ }
147
+
148
+ if (req.method !== "GET" && req.method !== "HEAD") {
149
+ sendError(res, 405, "method not allowed", "method_not_allowed")
150
+ return
151
+ }
152
+ serveStatic(res, p)
153
+ } catch (err) {
154
+ if (err instanceof ApiError) {
155
+ sendError(res, err.status, err.message, err.code, err.type)
156
+ return
157
+ }
158
+ sendError(res, 500, "internal error", "internal", "internal_error")
159
+ console.error("[error] " + (err && err.stack ? err.stack : err))
160
+ }
161
+ })
162
+
163
+ return server
164
+ }
@@ -0,0 +1,97 @@
1
+ import { sanitizeRecord, WINDOWS } from "./store.js"
2
+
3
+ export const MAX_SESSIONS_PER_REPORT = 200
4
+ export const SUPPORTED_SCHEMA_VERSION = 1
5
+
6
+ export class ApiError extends Error {
7
+ constructor(status, message, code, type = "invalid_request_error") {
8
+ super(message)
9
+ this.status = status
10
+ this.code = code
11
+ this.type = type
12
+ }
13
+ }
14
+
15
+ const iso = (ms = Date.now()) => new Date(ms).toISOString()
16
+
17
+ const boundedInt = (raw, name, dflt, low, high) => {
18
+ if (raw === undefined || raw === null || raw === "") return dflt
19
+ const n = Number.parseInt(raw, 10)
20
+ if (!Number.isFinite(n)) throw new ApiError(400, `${name} 必须是整数`, `invalid_${name}`)
21
+ if (n < low || n > high) throw new ApiError(400, `${name} 必须在 ${low}..${high} 之间`, `invalid_${name}`)
22
+ return n
23
+ }
24
+
25
+ const encodeCursor = (c) => Buffer.from(JSON.stringify(c), "utf8").toString("base64url")
26
+ const decodeCursor = (raw) => {
27
+ try {
28
+ const v = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"))
29
+ return [Number(v[0]), String(v[1])]
30
+ } catch {
31
+ throw new ApiError(400, "cursor 非法", "invalid_cursor")
32
+ }
33
+ }
34
+
35
+ /** POST /v1/session-usage/report —— 租户由调用方解析好的 consumer 决定,body 内身份字段一律忽略 */
36
+ export function report(store, consumer, body) {
37
+ if (!body || typeof body !== "object") throw new ApiError(400, "请求体必须是 JSON 对象", "invalid_body")
38
+ if (body.schema_version !== SUPPORTED_SCHEMA_VERSION) {
39
+ throw new ApiError(400, `不支持的 schema_version: ${body.schema_version}`, "unsupported_schema")
40
+ }
41
+ if (!Array.isArray(body.sessions) || body.sessions.length === 0) {
42
+ throw new ApiError(400, "sessions 必须是非空数组", "invalid_sessions")
43
+ }
44
+ if (body.sessions.length > MAX_SESSIONS_PER_REPORT) {
45
+ throw new ApiError(400, `单请求会话数超过上限 ${MAX_SESSIONS_PER_REPORT}`, "too_many_sessions")
46
+ }
47
+ if (!body.install_id) throw new ApiError(400, "缺少 install_id", "invalid_install_id")
48
+
49
+ const installId = String(body.install_id).slice(0, 200)
50
+ const reporterVersion = body.reporter && body.reporter.version ? String(body.reporter.version).slice(0, 64) : null
51
+ const records = body.sessions.map((raw) => {
52
+ const rec = sanitizeRecord(raw)
53
+ if (!rec) throw new ApiError(400, "会话记录缺少 session_id", "invalid_session_record")
54
+ rec.install_id = installId // body 级 install_id 为准
55
+ return rec
56
+ })
57
+ const res = store.upsertBatch(consumer, records, {
58
+ source: "reporter",
59
+ schemaVersion: SUPPORTED_SCHEMA_VERSION,
60
+ reporterVersion,
61
+ })
62
+ return { object: "report_result", accepted: res.accepted, skipped: res.skipped, server_time: iso(), results: res.results }
63
+ }
64
+
65
+ /** GET /v1/sessions */
66
+ export function listSessions(store, consumer, params) {
67
+ const window = params.get("window") || undefined
68
+ if (window && !WINDOWS.includes(window)) throw new ApiError(400, "window 必须是: today, week, month", "invalid_window")
69
+ const limit = boundedInt(params.get("limit"), "limit", 50, 1, 200)
70
+ const cursorRaw = params.get("cursor")
71
+ const { data, nextCursor } = store.listSessions(consumer, {
72
+ window,
73
+ model: params.get("model") || undefined,
74
+ agent: params.get("agent") || undefined,
75
+ installId: params.get("install_id") || undefined,
76
+ limit,
77
+ cursor: cursorRaw ? decodeCursor(cursorRaw) : null,
78
+ })
79
+ return { object: "list", window: window || null, as_of: iso(), data, next_cursor: nextCursor ? encodeCursor(nextCursor) : null }
80
+ }
81
+
82
+ /** GET /v1/sessions/{id} */
83
+ export function sessionDetail(store, consumer, sessionId, params) {
84
+ const row = store.getSession(consumer, sessionId, params.get("install_id") || undefined)
85
+ if (!row) throw new ApiError(404, "not found", "not_found")
86
+ return { object: "session", data: row }
87
+ }
88
+
89
+ /** GET /v1/sessions/summary */
90
+ export function sessionsSummary(store, consumer, params) {
91
+ const window = params.get("window") || undefined
92
+ if (window && !WINDOWS.includes(window)) throw new ApiError(400, "window 必须是: today, week, month", "invalid_window")
93
+ const groupBy = params.get("group_by") || "model"
94
+ const data = store.summary(consumer, { window, groupBy })
95
+ if (data === undefined) throw new ApiError(400, "group_by 必须是: model, agent, install, day", "invalid_group_by")
96
+ return { object: "list", window: window || null, group_by: groupBy, as_of: iso(), data }
97
+ }
package/src/store.js ADDED
@@ -0,0 +1,246 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { DatabaseSync } from "node:sqlite"
4
+
5
+ export const LOCAL_CONSUMER = "local"
6
+ export const WINDOWS = ["today", "week", "month"]
7
+
8
+ const SCHEMA = `
9
+ CREATE TABLE IF NOT EXISTS session_usage (
10
+ consumer_id TEXT NOT NULL,
11
+ install_id TEXT NOT NULL,
12
+ session_id TEXT NOT NULL,
13
+ source TEXT NOT NULL DEFAULT 'reporter',
14
+ schema_version INTEGER NOT NULL DEFAULT 1,
15
+ reporter_version TEXT,
16
+ client_session_header TEXT,
17
+ model TEXT,
18
+ agent TEXT,
19
+ tokens_input INTEGER NOT NULL DEFAULT 0,
20
+ tokens_output INTEGER NOT NULL DEFAULT 0,
21
+ tokens_reasoning INTEGER NOT NULL DEFAULT 0,
22
+ tokens_cache_read INTEGER NOT NULL DEFAULT 0,
23
+ tokens_cache_write INTEGER NOT NULL DEFAULT 0,
24
+ cost REAL,
25
+ message_count INTEGER NOT NULL DEFAULT 0,
26
+ revision INTEGER NOT NULL DEFAULT 0,
27
+ session_created_at INTEGER,
28
+ last_activity_at INTEGER NOT NULL DEFAULT 0,
29
+ client_reported_at INTEGER,
30
+ first_seen_at INTEGER NOT NULL,
31
+ last_reported_at INTEGER NOT NULL,
32
+ PRIMARY KEY (consumer_id, install_id, session_id)
33
+ );
34
+ CREATE INDEX IF NOT EXISTS idx_su_consumer_activity ON session_usage(consumer_id, last_activity_at DESC);
35
+ CREATE INDEX IF NOT EXISTS idx_su_consumer_model ON session_usage(consumer_id, model);
36
+ CREATE INDEX IF NOT EXISTS idx_su_consumer_session ON session_usage(consumer_id, session_id);
37
+ `
38
+
39
+ const nowMs = () => Date.now()
40
+
41
+ /** UTC+8 日历对齐窗口起点(毫秒);未知窗口返回 null */
42
+ export function windowStartMs(window) {
43
+ if (!WINDOWS.includes(window)) return null
44
+ const d = new Date(Date.now() + 8 * 3600 * 1000) // 平移到 UTC+8 墙钟
45
+ let startUtc8
46
+ if (window === "today") {
47
+ startUtc8 = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
48
+ } else if (window === "week") {
49
+ const wd = (d.getUTCDay() + 6) % 7 // 周一=0
50
+ startUtc8 = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - wd)
51
+ } else {
52
+ startUtc8 = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)
53
+ }
54
+ return startUtc8 - 8 * 3600 * 1000
55
+ }
56
+
57
+ const int = (v, dflt = 0) => {
58
+ const n = Number.parseInt(v, 10)
59
+ return Number.isFinite(n) && n >= 0 ? n : dflt
60
+ }
61
+ const optInt = (v) => (v === undefined || v === null || v === "" ? null : int(v, 0))
62
+ const optNum = (v) => {
63
+ if (v === undefined || v === null || v === "") return null
64
+ const n = Number(v)
65
+ return Number.isFinite(n) ? n : null
66
+ }
67
+ const str = (v, max = 200) => (v === undefined || v === null ? null : String(v).slice(0, max))
68
+
69
+ /** 字段白名单:任何身份字段都在此被丢弃 */
70
+ export function sanitizeRecord(raw) {
71
+ if (!raw || typeof raw !== "object") return null
72
+ const sessionId = str(raw.session_id)
73
+ // install_id 以 body 级为准(见 session-api.report),记录级可缺省
74
+ if (!sessionId) return null
75
+ return {
76
+ session_id: sessionId,
77
+ install_id: str(raw.install_id),
78
+ model: str(raw.model),
79
+ agent: str(raw.agent),
80
+ tokens_input: int(raw.tokens_input),
81
+ tokens_output: int(raw.tokens_output),
82
+ tokens_reasoning: int(raw.tokens_reasoning),
83
+ tokens_cache_read: int(raw.tokens_cache_read),
84
+ tokens_cache_write: int(raw.tokens_cache_write),
85
+ cost: optNum(raw.cost),
86
+ message_count: int(raw.message_count),
87
+ revision: int(raw.revision),
88
+ session_created_at: optInt(raw.session_created_at),
89
+ last_activity_at: int(raw.last_activity_at),
90
+ client_reported_at: optInt(raw.client_reported_at),
91
+ client_session_header: str(raw.client_session_header),
92
+ }
93
+ }
94
+
95
+ function withDerived(row) {
96
+ const out = { ...row }
97
+ out.tokens_input_total = row.tokens_input + row.tokens_cache_read + row.tokens_cache_write
98
+ out.tokens_output_total = row.tokens_output + row.tokens_reasoning
99
+ return out
100
+ }
101
+
102
+ export class Store {
103
+ constructor(dbPath) {
104
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true })
105
+ this.db = new DatabaseSync(dbPath)
106
+ this.db.exec("PRAGMA journal_mode=WAL")
107
+ this.db.exec("PRAGMA busy_timeout=3000")
108
+ }
109
+
110
+ migrate() {
111
+ this.db.exec(SCHEMA)
112
+ }
113
+
114
+ indexes() {
115
+ return this.db
116
+ .prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='session_usage'")
117
+ .all()
118
+ .map((r) => r.name)
119
+ }
120
+
121
+ close() {
122
+ this.db.close()
123
+ }
124
+
125
+ upsertBatch(consumerId, records, { source = "reporter", schemaVersion = 1, reporterVersion = null } = {}) {
126
+ const ts = nowMs()
127
+ const results = []
128
+ let accepted = 0
129
+ let skipped = 0
130
+ const find = this.db.prepare(
131
+ "SELECT revision, last_activity_at FROM session_usage WHERE consumer_id=? AND install_id=? AND session_id=?",
132
+ )
133
+ const insert = this.db.prepare(`INSERT INTO session_usage
134
+ (consumer_id, install_id, session_id, source, schema_version, reporter_version, client_session_header,
135
+ model, agent, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
136
+ cost, message_count, revision, session_created_at, last_activity_at, client_reported_at,
137
+ first_seen_at, last_reported_at)
138
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
139
+ const update = this.db.prepare(`UPDATE session_usage SET
140
+ source=?, schema_version=?, reporter_version=?, client_session_header=?, model=?, agent=?,
141
+ tokens_input=?, tokens_output=?, tokens_reasoning=?, tokens_cache_read=?, tokens_cache_write=?,
142
+ cost=?, message_count=?, revision=?, session_created_at=?, last_activity_at=?, client_reported_at=?,
143
+ last_reported_at=?
144
+ WHERE consumer_id=? AND install_id=? AND session_id=?`)
145
+
146
+ for (const r of records) {
147
+ const prev = find.get(consumerId, r.install_id, r.session_id)
148
+ let status
149
+ if (!prev) {
150
+ status = "upserted"
151
+ insert.run(
152
+ consumerId, r.install_id, r.session_id, source, schemaVersion, reporterVersion, r.client_session_header,
153
+ r.model, r.agent, r.tokens_input, r.tokens_output, r.tokens_reasoning, r.tokens_cache_read, r.tokens_cache_write,
154
+ r.cost, r.message_count, r.revision, r.session_created_at, r.last_activity_at, r.client_reported_at, ts, ts,
155
+ )
156
+ } else if (r.revision > prev.revision || (r.revision === prev.revision && r.last_activity_at > prev.last_activity_at)) {
157
+ status = "upserted"
158
+ update.run(
159
+ source, schemaVersion, reporterVersion, r.client_session_header, r.model, r.agent,
160
+ r.tokens_input, r.tokens_output, r.tokens_reasoning, r.tokens_cache_read, r.tokens_cache_write,
161
+ r.cost, r.message_count, r.revision, r.session_created_at, r.last_activity_at, r.client_reported_at, ts,
162
+ consumerId, r.install_id, r.session_id,
163
+ )
164
+ } else if (r.revision === prev.revision && r.last_activity_at === prev.last_activity_at) {
165
+ status = "unchanged"
166
+ } else {
167
+ status = "stale"
168
+ }
169
+ if (status === "upserted" || status === "unchanged") accepted += 1
170
+ else skipped += 1
171
+ results.push({ session_id: r.session_id, install_id: r.install_id, status })
172
+ }
173
+ return { accepted, skipped, results }
174
+ }
175
+
176
+ listSessions(consumerId, { window, model, agent, installId, limit = 50, cursor = null } = {}) {
177
+ const where = ["consumer_id=?"]
178
+ const args = [consumerId]
179
+ const start = windowStartMs(window)
180
+ if (start !== null) {
181
+ where.push("last_activity_at>=?")
182
+ args.push(start)
183
+ }
184
+ if (model) {
185
+ where.push("model=?")
186
+ args.push(model)
187
+ }
188
+ if (agent) {
189
+ where.push("agent=?")
190
+ args.push(agent)
191
+ }
192
+ if (installId) {
193
+ where.push("install_id=?")
194
+ args.push(installId)
195
+ }
196
+ if (cursor) {
197
+ where.push("(last_activity_at < ? OR (last_activity_at = ? AND session_id < ?))")
198
+ args.push(cursor[0], cursor[0], cursor[1])
199
+ }
200
+ const sql = `SELECT * FROM session_usage WHERE ${where.join(" AND ")} ORDER BY last_activity_at DESC, session_id DESC LIMIT ?`
201
+ const rows = this.db.prepare(sql).all(...args, limit + 1)
202
+ const hasNext = rows.length > limit
203
+ const data = rows.slice(0, limit).map(withDerived)
204
+ const nextCursor = hasNext && data.length ? [data[data.length - 1].last_activity_at, data[data.length - 1].session_id] : null
205
+ return { data, nextCursor }
206
+ }
207
+
208
+ getSession(consumerId, sessionId, installId) {
209
+ const where = ["consumer_id=?", "session_id=?"]
210
+ const args = [consumerId, sessionId]
211
+ if (installId) {
212
+ where.push("install_id=?")
213
+ args.push(installId)
214
+ }
215
+ const row = this.db.prepare(`SELECT * FROM session_usage WHERE ${where.join(" AND ")} LIMIT 1`).get(...args)
216
+ return row ? withDerived(row) : null
217
+ }
218
+
219
+ summary(consumerId, { window, groupBy = "model" } = {}) {
220
+ const keyExpr = {
221
+ model: "model",
222
+ agent: "agent",
223
+ install: "install_id",
224
+ day: "strftime('%Y-%m-%d', last_activity_at/1000, 'unixepoch', '+8 hours')",
225
+ }[groupBy]
226
+ if (!keyExpr) return undefined
227
+ const where = ["consumer_id=?"]
228
+ const args = [consumerId]
229
+ const start = windowStartMs(window)
230
+ if (start !== null) {
231
+ where.push("last_activity_at>=?")
232
+ args.push(start)
233
+ }
234
+ const sql = `SELECT ${keyExpr} AS key, COUNT(*) AS sessions,
235
+ SUM(tokens_input) AS tokens_input, SUM(tokens_output) AS tokens_output, SUM(tokens_reasoning) AS tokens_reasoning,
236
+ SUM(tokens_cache_read) AS tokens_cache_read, SUM(tokens_cache_write) AS tokens_cache_write
237
+ FROM session_usage WHERE ${where.join(" AND ")} GROUP BY key
238
+ ORDER BY SUM(tokens_input)+SUM(tokens_output)+SUM(tokens_reasoning) DESC`
239
+ return this.db.prepare(sql).all(...args).map((r) => {
240
+ const out = { ...r }
241
+ out.tokens_input_total = out.tokens_input + out.tokens_cache_read + out.tokens_cache_write
242
+ out.tokens_output_total = out.tokens_output + out.tokens_reasoning
243
+ return out
244
+ })
245
+ }
246
+ }