@morlay/session-rdb 0.0.21-alpha.6 → 0.0.21-alpha.8
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/README.md +17 -10
- package/dist/artifact.d.mts +2 -101
- package/dist/artifact.mjs +2 -2
- package/dist/{branch-BjUR6wfe.mjs → branch-7eOh_tbO.mjs} +3 -3
- package/dist/deletion.d.mts +1 -1
- package/dist/deletion.mjs +1 -1
- package/dist/import.d.mts +1 -1
- package/dist/import.mjs +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +3 -3
- package/dist/{backend-E0bmTXpz.d.mts → log-BbleqD1M.d.mts} +171 -24
- package/dist/{log-qCNkDjh5.mjs → log-DTcvsMz7.mjs} +189 -12
- package/dist/{sqlite-zcXVyMxr.mjs → sqlite-ChM_sHAG.mjs} +320 -259
- package/dist/{src-B9X8BKyh.mjs → src-BWqePRA7.mjs} +290 -162
- package/dist/storage.d.mts +20 -7
- package/dist/storage.mjs +2 -2
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +11 -11
- package/drizzle/postgres/20260923120000_v3_usage_materialized/migration.sql +48 -0
- package/drizzle/sqlite/20260923120000_v3_usage_materialized/migration.sql +48 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +25 -23
- package/src/backend.ts +41 -17
- package/src/branch.ts +4 -4
- package/src/drizzle/postgres-v3.ts +2 -0
- package/src/drizzle/sqlite-v3.ts +2 -0
- package/src/entities/v3/event-usage.ts +8 -1
- package/src/entities/v3/index.ts +8 -0
- package/src/entities/v3/session-counts.ts +23 -0
- package/src/entities/v3/session-usage.ts +29 -0
- package/src/index.ts +109 -43
- package/src/log.ts +58 -17
- package/src/postgres.ts +248 -149
- package/src/rows.ts +2 -1
- package/src/schema.ts +4 -0
- package/src/sqlite.ts +263 -124
- package/src/storage-takeover/repository.ts +1 -4
- package/src/usage.ts +10 -6
package/src/sqlite.ts
CHANGED
|
@@ -11,18 +11,20 @@ import type { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistenc
|
|
|
11
11
|
import {
|
|
12
12
|
type Backend,
|
|
13
13
|
type BackendTx,
|
|
14
|
-
type EventCountBucket,
|
|
15
14
|
type EventInsert,
|
|
16
15
|
type EventRow,
|
|
16
|
+
type SessionCountBucket,
|
|
17
17
|
type SessionListRowRecord,
|
|
18
18
|
type SessionListRowsPage,
|
|
19
19
|
type SessionListRowsQuery,
|
|
20
20
|
type SessionRow,
|
|
21
|
+
type SessionUsageBucket,
|
|
21
22
|
} from "./backend.ts";
|
|
22
|
-
import { sessionConflictRow, sessionInsertRow, titleOfEventData
|
|
23
|
+
import { sessionConflictRow, sessionInsertRow, titleOfEventData } from "./log.ts";
|
|
24
|
+
import type { EventUsageRow } from "./log.ts";
|
|
23
25
|
import {
|
|
24
26
|
COUNTED_EVENT_TYPES,
|
|
25
|
-
|
|
27
|
+
addActivityTotals,
|
|
26
28
|
addTokenTotals,
|
|
27
29
|
addTotals,
|
|
28
30
|
emptyTotals,
|
|
@@ -63,12 +65,6 @@ interface RawBucketRow {
|
|
|
63
65
|
total_tokens: number | null;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
/** 活动计数行(派生表按事件类型存,读的时候折成四项)。 */
|
|
67
|
-
interface RawCountRow {
|
|
68
|
-
f_type: string;
|
|
69
|
-
n: number | null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
68
|
/** 「是否子代理」两组的 token 合计行。 */
|
|
73
69
|
interface RawScopeRow extends RawBucketRow {}
|
|
74
70
|
|
|
@@ -79,53 +75,69 @@ interface RawSessionTokenRow extends RawBucketRow {
|
|
|
79
75
|
archived: number;
|
|
80
76
|
}
|
|
81
77
|
|
|
78
|
+
/** `t_session_counts` 的原始行(四项计数,列名是 SQL 别名)。 */
|
|
79
|
+
interface RawCountRow {
|
|
80
|
+
turns: number | null;
|
|
81
|
+
steps: number | null;
|
|
82
|
+
user_inputs: number | null;
|
|
83
|
+
tool_calls: number | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 按会话的活动计数行。 */
|
|
82
87
|
interface RawSessionCountRow extends RawCountRow {
|
|
83
88
|
session_id: string;
|
|
84
89
|
}
|
|
85
90
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
f_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
90
|
-
f_session_id TEXT NOT NULL,
|
|
91
|
-
f_day TEXT NOT NULL,
|
|
92
|
-
f_type TEXT NOT NULL,
|
|
93
|
-
f_count INTEGER NOT NULL DEFAULT 0
|
|
94
|
-
);
|
|
95
|
-
CREATE UNIQUE INDEX IF NOT EXISTS uq_event_counts_session_day_type
|
|
96
|
-
ON t_event_counts(f_session_id, f_day, f_type);
|
|
97
|
-
CREATE INDEX IF NOT EXISTS idx_event_counts_day ON t_event_counts(f_day);`);
|
|
91
|
+
/** 活动总览行:是否子代理 + 四项计数。 */
|
|
92
|
+
interface RawActivityScopeRow extends RawCountRow {
|
|
93
|
+
subagent: number | null;
|
|
98
94
|
}
|
|
99
95
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
96
|
+
/** 用量行的「被引用」物化表达式(`%EVENT%` 换成事件 id 列)。 */
|
|
97
|
+
const EVENT_REFERENCED_SQL = `EXISTS (SELECT 1 FROM t_session_events rb
|
|
98
|
+
WHERE rb.f_event_id = %EVENT%)`;
|
|
99
|
+
|
|
100
|
+
/** 用量行的「被 subagent 会话引用」物化表达式(`%EVENT%` 换成事件 id 列)。 */
|
|
101
|
+
const EVENT_SUBAGENT_SQL = `EXISTS (SELECT 1 FROM t_session_events sb
|
|
102
|
+
JOIN t_sessions ss ON ss.f_session_id = sb.f_session_id
|
|
103
|
+
WHERE sb.f_event_id = %EVENT% AND ss.f_origin = 'subagent')`;
|
|
104
|
+
|
|
105
|
+
/** `IN (...)` 用的类型字面量(与 `COUNTED_EVENT_TYPES` 同源)。 */
|
|
106
|
+
const COUNTED_EVENT_TYPE_SQL = COUNTED_EVENT_TYPES.map((type) => `'${type}'`).join(", ");
|
|
107
|
+
|
|
108
|
+
function withEvent(expression: string, column: string): string {
|
|
109
|
+
return expression.replace("%EVENT%", column);
|
|
113
110
|
}
|
|
114
111
|
|
|
115
112
|
/**
|
|
116
|
-
*
|
|
113
|
+
* 派生统计表的回填:任一表为空即按事件表全量重算(幂等;迁移删表重建后、手动清空后都靠它自愈)。
|
|
114
|
+
* 顺序有依赖:用量行(带物化列)先落,两张会话汇总表再从它 / 事件表重算。
|
|
117
115
|
*/
|
|
116
|
+
function backfillUsageTables(db: DatabaseSync): void {
|
|
117
|
+
backfillEventUsage(db);
|
|
118
|
+
backfillSessionUsage(db);
|
|
119
|
+
backfillSessionCounts(db);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function tableIsEmpty(db: DatabaseSync, table: string): boolean {
|
|
123
|
+
const row = db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number };
|
|
124
|
+
return row.n === 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 用量行:一条 `assistant/message` 事件行一行,物化本地日与两个归属标记。 */
|
|
118
128
|
function backfillEventUsage(db: DatabaseSync): void {
|
|
119
|
-
|
|
120
|
-
if (pending.n > 0) return;
|
|
129
|
+
if (!tableIsEmpty(db, "t_event_usage")) return;
|
|
121
130
|
db.exec(`INSERT OR IGNORE INTO t_event_usage (
|
|
122
|
-
f_event_id, f_created_at, f_provider, f_model,
|
|
131
|
+
f_event_id, f_created_at, f_day, f_provider, f_model, f_referenced, f_subagent,
|
|
123
132
|
f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
|
|
124
133
|
SELECT e.f_event_id, e.f_created_at,
|
|
134
|
+
date(e.f_created_at / 1000, 'unixepoch', 'localtime'),
|
|
125
135
|
coalesce(json_extract(e.f_data, '$.data.message.source.provider'),
|
|
126
136
|
json_extract(e.f_data, '$.message.source.provider')),
|
|
127
137
|
coalesce(json_extract(e.f_data, '$.data.message.source.model'),
|
|
128
138
|
json_extract(e.f_data, '$.message.source.model')),
|
|
139
|
+
${withEvent(EVENT_REFERENCED_SQL, "e.f_event_id")},
|
|
140
|
+
${withEvent(EVENT_SUBAGENT_SQL, "e.f_event_id")},
|
|
129
141
|
coalesce(json_extract(e.f_data, '$.data.usage.inputTokens'),
|
|
130
142
|
json_extract(e.f_data, '$.usage.inputTokens'), 0),
|
|
131
143
|
coalesce(json_extract(e.f_data, '$.data.usage.outputTokens'),
|
|
@@ -142,9 +154,41 @@ function backfillEventUsage(db: DatabaseSync): void {
|
|
|
142
154
|
OR json_extract(e.f_data, '$.usage') IS NOT NULL)`);
|
|
143
155
|
}
|
|
144
156
|
|
|
157
|
+
/** 会话 × 本地日 × 模型的 token 汇总:从桥接行 × 用量行重算(含 fork 继承前缀)。 */
|
|
158
|
+
function backfillSessionUsage(db: DatabaseSync): void {
|
|
159
|
+
if (!tableIsEmpty(db, "t_session_usage")) return;
|
|
160
|
+
db.exec(`INSERT OR IGNORE INTO t_session_usage (
|
|
161
|
+
f_session_id, f_day, f_provider, f_model,
|
|
162
|
+
f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
|
|
163
|
+
SELECT b.f_session_id, u.f_day, coalesce(u.f_provider, ''), coalesce(u.f_model, ''),
|
|
164
|
+
sum(u.f_input_tokens), sum(u.f_output_tokens), sum(u.f_cache_read_tokens),
|
|
165
|
+
sum(u.f_reasoning_tokens), sum(u.f_total_tokens)
|
|
166
|
+
FROM t_session_events b
|
|
167
|
+
JOIN t_event_usage u ON u.f_event_id = b.f_event_id
|
|
168
|
+
GROUP BY b.f_session_id, u.f_day, coalesce(u.f_provider, ''), coalesce(u.f_model, '')`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 会话 × 本地日的活动计数:从桥接行 × 事件类型重算。 */
|
|
172
|
+
function backfillSessionCounts(db: DatabaseSync): void {
|
|
173
|
+
if (!tableIsEmpty(db, "t_session_counts")) return;
|
|
174
|
+
db.exec(`INSERT OR IGNORE INTO t_session_counts (
|
|
175
|
+
f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
|
|
176
|
+
SELECT b.f_session_id,
|
|
177
|
+
date(e.f_created_at / 1000, 'unixepoch', 'localtime'),
|
|
178
|
+
sum(CASE WHEN e.f_type = 'turn/start' THEN 1 ELSE 0 END),
|
|
179
|
+
sum(CASE WHEN e.f_type = 'step/start' THEN 1 ELSE 0 END),
|
|
180
|
+
sum(CASE WHEN e.f_type = 'user/message' THEN 1 ELSE 0 END),
|
|
181
|
+
sum(CASE WHEN e.f_type = 'tool/call' THEN 1 ELSE 0 END)
|
|
182
|
+
FROM t_session_events b
|
|
183
|
+
JOIN t_events e ON e.f_event_id = b.f_event_id
|
|
184
|
+
WHERE e.f_type IN (${COUNTED_EVENT_TYPE_SQL})
|
|
185
|
+
GROUP BY b.f_session_id, 2`);
|
|
186
|
+
}
|
|
187
|
+
|
|
145
188
|
function sessionListRowOf(row: Record<string, unknown>): SessionListRowRecord {
|
|
146
189
|
return {
|
|
147
|
-
sessionId:
|
|
190
|
+
sessionId:
|
|
191
|
+
typeof row["session_id"] === "string" ? row["session_id"] : String(row["session_id"]),
|
|
148
192
|
title: typeof row["title"] === "string" ? row["title"] : null,
|
|
149
193
|
origin: typeof row["origin"] === "string" ? row["origin"] : null,
|
|
150
194
|
cwd: typeof row["cwd"] === "string" ? row["cwd"] : null,
|
|
@@ -173,25 +217,31 @@ function tokenTotalsOf(row: {
|
|
|
173
217
|
};
|
|
174
218
|
}
|
|
175
219
|
|
|
220
|
+
/** `t_session_counts` 的原始行(列名是 SQL 别名)折成四项活动计数。 */
|
|
221
|
+
function activityTotalsOf(row: {
|
|
222
|
+
turns: number | null;
|
|
223
|
+
steps: number | null;
|
|
224
|
+
user_inputs: number | null;
|
|
225
|
+
tool_calls: number | null;
|
|
226
|
+
}): UsageActivityTotals {
|
|
227
|
+
return {
|
|
228
|
+
turns: row.turns ?? 0,
|
|
229
|
+
steps: row.steps ?? 0,
|
|
230
|
+
userInputs: row.user_inputs ?? 0,
|
|
231
|
+
toolCalls: row.tool_calls ?? 0,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
176
235
|
/** 一行的整套指标:token 用量 + 活动计数(轮次 / 步骤 / 用户输入 / 工具调用)。 */
|
|
177
236
|
function emptyActivity(): UsageActivityTotals {
|
|
178
237
|
return { turns: 0, steps: 0, userInputs: 0, toolCalls: 0 };
|
|
179
238
|
}
|
|
180
239
|
|
|
181
|
-
/** 有 token
|
|
240
|
+
/** 有 token 用量的会话:活动计数与列表都限定在这一批会话里,口径一致(读汇总表,几百行)。 */
|
|
182
241
|
function hasUsageSession(column: string): string {
|
|
183
|
-
return `EXISTS (SELECT 1 FROM
|
|
184
|
-
JOIN t_event_usage uu ON uu.f_event_id = ub.f_event_id
|
|
185
|
-
WHERE ub.f_session_id = ${column})`;
|
|
242
|
+
return `EXISTS (SELECT 1 FROM t_session_usage ub WHERE ub.f_session_id = ${column})`;
|
|
186
243
|
}
|
|
187
244
|
|
|
188
|
-
const SUBAGENT_OF_EVENT = `EXISTS (SELECT 1 FROM t_session_events sb
|
|
189
|
-
JOIN t_sessions ss ON ss.f_session_id = sb.f_session_id
|
|
190
|
-
WHERE sb.f_event_id = %EVENT% AND ss.f_origin = 'subagent')`;
|
|
191
|
-
|
|
192
|
-
/** `IN (...)` 用的类型字面量(与 `COUNTED_EVENT_TYPES` 同源)。 */
|
|
193
|
-
const COUNTED_EVENT_TYPE_SQL = COUNTED_EVENT_TYPES.map((type) => `'${type}'`).join(", ");
|
|
194
|
-
|
|
195
245
|
const sqliteMigrationsDir = fileURLToPath(new URL("../drizzle/sqlite/", import.meta.url));
|
|
196
246
|
|
|
197
247
|
const sqliteTxQueues = new Map<string, Promise<void>>();
|
|
@@ -236,9 +286,7 @@ export async function openDatabase(
|
|
|
236
286
|
await baselineV2(db);
|
|
237
287
|
}
|
|
238
288
|
migrate(dbx, { migrationsFolder: sqliteMigrationsDir });
|
|
239
|
-
|
|
240
|
-
backfillEventCounts(db);
|
|
241
|
-
backfillEventUsage(db);
|
|
289
|
+
backfillUsageTables(db);
|
|
242
290
|
|
|
243
291
|
dbx
|
|
244
292
|
.insert(tPersistenceState)
|
|
@@ -509,7 +557,7 @@ export class SqliteBackend implements Backend {
|
|
|
509
557
|
getHead: (id) => this.getHead(id),
|
|
510
558
|
getSeedLength: (id) => this.getSeedLength(id),
|
|
511
559
|
updateSeedLength: (id, seedLength) => this.updateSeedLength(id, seedLength),
|
|
512
|
-
insertEvents: (events) => this.insertEvents(events),
|
|
560
|
+
insertEvents: (events, usageRows) => this.insertEvents(events, usageRows),
|
|
513
561
|
insertBridges: (rows) => this.insertBridges(rows),
|
|
514
562
|
updateHead: (id, headEventId, headSequence) => this.updateHead(id, headEventId, headSequence),
|
|
515
563
|
bumpRevision: (id, lastEventAt) => this.bumpRevision(id, lastEventAt),
|
|
@@ -565,7 +613,10 @@ export class SqliteBackend implements Backend {
|
|
|
565
613
|
|
|
566
614
|
private static readonly INSERT_BATCH_ROWS = 1000;
|
|
567
615
|
|
|
568
|
-
private async insertEvents(
|
|
616
|
+
private async insertEvents(
|
|
617
|
+
events: EventInsert[],
|
|
618
|
+
usageRows: readonly EventUsageRow[],
|
|
619
|
+
): Promise<void> {
|
|
569
620
|
if (events.length === 0) return;
|
|
570
621
|
for (let i = 0; i < events.length; i += SqliteBackend.INSERT_BATCH_ROWS) {
|
|
571
622
|
this.db
|
|
@@ -573,13 +624,14 @@ export class SqliteBackend implements Backend {
|
|
|
573
624
|
.values(events.slice(i, i + SqliteBackend.INSERT_BATCH_ROWS).map((event) => ({ ...event })))
|
|
574
625
|
.run();
|
|
575
626
|
}
|
|
576
|
-
//
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
627
|
+
// 用量行顺带落 t_event_usage(本地日与引用标记已由写路径物化):统计不再解析事件 JSON、
|
|
628
|
+
// 也不回连事件表(重复事件行忽略,fork 复用不重复记)。
|
|
629
|
+
if (usageRows.length > 0) {
|
|
630
|
+
this.db
|
|
631
|
+
.insert(tEventUsage)
|
|
632
|
+
.values(usageRows.map((row) => ({ ...row })))
|
|
633
|
+
.onConflictDoNothing()
|
|
634
|
+
.run();
|
|
583
635
|
}
|
|
584
636
|
}
|
|
585
637
|
|
|
@@ -760,57 +812,64 @@ export class SqliteBackend implements Backend {
|
|
|
760
812
|
};
|
|
761
813
|
}
|
|
762
814
|
|
|
815
|
+
/**
|
|
816
|
+
* 用量聚合:只读三张派生统计表——事件级用量行给去重口径的总量与「天 × 模型」桶,
|
|
817
|
+
* `t_session_usage` / `t_session_counts` 给按会话的行(含 fork 继承前缀)。
|
|
818
|
+
* 归属、本地日与「是否被引用」都是物化列,这里不再现算、也不回连事件表。
|
|
819
|
+
* 事件级按毫秒时间戳过滤、汇总表按本地日过滤——`sinceMs` 对齐到本地零点时两者等价。
|
|
820
|
+
*/
|
|
763
821
|
async usageReport(sinceMs?: number): Promise<UsageAggregate> {
|
|
764
|
-
const usageSince = sinceMs === undefined ? "" : " AND
|
|
822
|
+
const usageSince = sinceMs === undefined ? "" : " AND f_created_at >= ?";
|
|
765
823
|
const params = sinceMs === undefined ? [] : [sinceMs];
|
|
766
|
-
|
|
767
|
-
const daySince = sinceMs === undefined ? "" : " AND c.f_day >= ?";
|
|
824
|
+
const daySince = sinceMs === undefined ? "" : " AND %COL% >= ?";
|
|
768
825
|
const dayParams = sinceMs === undefined ? [] : [localDayKey(sinceMs)];
|
|
769
|
-
const
|
|
826
|
+
const dayFilter = (column: string): string => daySince.replace("%COL%", column);
|
|
770
827
|
|
|
771
828
|
const buckets = this.db.$client
|
|
772
829
|
.prepare(
|
|
773
|
-
`SELECT
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
sum(
|
|
778
|
-
sum(
|
|
779
|
-
sum(
|
|
780
|
-
sum(
|
|
781
|
-
sum(
|
|
782
|
-
FROM t_event_usage
|
|
783
|
-
WHERE
|
|
784
|
-
GROUP BY
|
|
830
|
+
`SELECT f_day AS day,
|
|
831
|
+
f_provider AS provider,
|
|
832
|
+
f_model AS model,
|
|
833
|
+
f_subagent AS subagent,
|
|
834
|
+
sum(f_input_tokens) AS input_tokens,
|
|
835
|
+
sum(f_output_tokens) AS output_tokens,
|
|
836
|
+
sum(f_cache_read_tokens) AS cache_read_tokens,
|
|
837
|
+
sum(f_reasoning_tokens) AS reasoning_tokens,
|
|
838
|
+
sum(f_total_tokens) AS total_tokens
|
|
839
|
+
FROM t_event_usage
|
|
840
|
+
WHERE f_referenced = 1${usageSince}
|
|
841
|
+
GROUP BY f_day, f_provider, f_model, f_subagent`,
|
|
785
842
|
)
|
|
786
843
|
.all(...params) as unknown as RawBucketRow[];
|
|
787
844
|
const tokenScope = this.db.$client
|
|
788
845
|
.prepare(
|
|
789
|
-
`SELECT
|
|
790
|
-
sum(
|
|
791
|
-
sum(
|
|
792
|
-
sum(
|
|
793
|
-
sum(
|
|
794
|
-
sum(
|
|
795
|
-
FROM t_event_usage
|
|
796
|
-
WHERE
|
|
797
|
-
GROUP BY
|
|
846
|
+
`SELECT f_subagent AS subagent,
|
|
847
|
+
sum(f_input_tokens) AS input_tokens,
|
|
848
|
+
sum(f_output_tokens) AS output_tokens,
|
|
849
|
+
sum(f_cache_read_tokens) AS cache_read_tokens,
|
|
850
|
+
sum(f_reasoning_tokens) AS reasoning_tokens,
|
|
851
|
+
sum(f_total_tokens) AS total_tokens
|
|
852
|
+
FROM t_event_usage
|
|
853
|
+
WHERE f_referenced = 1${usageSince}
|
|
854
|
+
GROUP BY f_subagent`,
|
|
798
855
|
)
|
|
799
856
|
.all(...params) as unknown as RawScopeRow[];
|
|
800
857
|
const activityScope = this.db.$client
|
|
801
858
|
.prepare(
|
|
802
859
|
`SELECT (s.f_origin = 'subagent') AS subagent,
|
|
803
|
-
c.
|
|
804
|
-
sum(c.
|
|
805
|
-
|
|
860
|
+
sum(c.f_turns) AS turns,
|
|
861
|
+
sum(c.f_steps) AS steps,
|
|
862
|
+
sum(c.f_user_inputs) AS user_inputs,
|
|
863
|
+
sum(c.f_tool_calls) AS tool_calls
|
|
864
|
+
FROM t_session_counts c
|
|
806
865
|
JOIN t_sessions s ON s.f_session_id = c.f_session_id
|
|
807
|
-
WHERE ${hasUsageSession("c.f_session_id")}${
|
|
808
|
-
GROUP BY subagent
|
|
866
|
+
WHERE ${hasUsageSession("c.f_session_id")}${dayFilter("c.f_day")}
|
|
867
|
+
GROUP BY subagent`,
|
|
809
868
|
)
|
|
810
|
-
.all(...dayParams) as unknown as
|
|
869
|
+
.all(...dayParams) as unknown as RawActivityScopeRow[];
|
|
811
870
|
const tokenSessions = this.db.$client
|
|
812
871
|
.prepare(
|
|
813
|
-
`SELECT
|
|
872
|
+
`SELECT u.f_session_id AS session_id,
|
|
814
873
|
s.f_title AS title,
|
|
815
874
|
(s.f_origin = 'subagent') AS subagent,
|
|
816
875
|
(s.f_archived_at IS NOT NULL) AS archived,
|
|
@@ -819,21 +878,22 @@ export class SqliteBackend implements Backend {
|
|
|
819
878
|
sum(u.f_cache_read_tokens) AS cache_read_tokens,
|
|
820
879
|
sum(u.f_reasoning_tokens) AS reasoning_tokens,
|
|
821
880
|
sum(u.f_total_tokens) AS total_tokens
|
|
822
|
-
FROM
|
|
823
|
-
JOIN
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
GROUP BY b.f_session_id, s.f_title, s.f_origin, s.f_archived_at`,
|
|
881
|
+
FROM t_session_usage u
|
|
882
|
+
JOIN t_sessions s ON s.f_session_id = u.f_session_id
|
|
883
|
+
WHERE 1 = 1${dayFilter("u.f_day")}
|
|
884
|
+
GROUP BY u.f_session_id, s.f_title, s.f_origin, s.f_archived_at`,
|
|
827
885
|
)
|
|
828
|
-
.all(...
|
|
886
|
+
.all(...dayParams) as unknown as RawSessionTokenRow[];
|
|
829
887
|
const activitySessions = this.db.$client
|
|
830
888
|
.prepare(
|
|
831
|
-
`SELECT
|
|
832
|
-
|
|
833
|
-
sum(
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
889
|
+
`SELECT f_session_id AS session_id,
|
|
890
|
+
sum(f_turns) AS turns,
|
|
891
|
+
sum(f_steps) AS steps,
|
|
892
|
+
sum(f_user_inputs) AS user_inputs,
|
|
893
|
+
sum(f_tool_calls) AS tool_calls
|
|
894
|
+
FROM t_session_counts
|
|
895
|
+
WHERE 1 = 1${dayFilter("f_day")}
|
|
896
|
+
GROUP BY f_session_id`,
|
|
837
897
|
)
|
|
838
898
|
.all(...dayParams) as unknown as RawSessionCountRow[];
|
|
839
899
|
const human = emptyTotals();
|
|
@@ -842,15 +902,13 @@ export class SqliteBackend implements Backend {
|
|
|
842
902
|
addTokenTotals(row.subagent === 1 ? subagent : human, tokenTotalsOf(row));
|
|
843
903
|
}
|
|
844
904
|
for (const row of activityScope) {
|
|
845
|
-
|
|
905
|
+
addActivityTotals(row.subagent === 1 ? subagent : human, activityTotalsOf(row));
|
|
846
906
|
}
|
|
847
907
|
const totals = addTotals(addTotals(emptyTotals(), human), subagent);
|
|
848
908
|
|
|
849
909
|
const activityBySession = new Map<string, UsageActivityTotals>();
|
|
850
910
|
for (const row of activitySessions) {
|
|
851
|
-
|
|
852
|
-
addActivityCount(target, row.f_type, row.n ?? 0);
|
|
853
|
-
activityBySession.set(row.session_id, target);
|
|
911
|
+
activityBySession.set(row.session_id, activityTotalsOf(row));
|
|
854
912
|
}
|
|
855
913
|
return {
|
|
856
914
|
totals,
|
|
@@ -874,36 +932,117 @@ export class SqliteBackend implements Backend {
|
|
|
874
932
|
};
|
|
875
933
|
}
|
|
876
934
|
|
|
877
|
-
/**
|
|
878
|
-
async
|
|
935
|
+
/** token 用量旁路累加:派生表 `t_session_usage`,不在写事务里(失败可丢,表可销毁重建)。 */
|
|
936
|
+
async incrementSessionUsage(
|
|
937
|
+
id: SessionId,
|
|
938
|
+
buckets: readonly SessionUsageBucket[],
|
|
939
|
+
): Promise<void> {
|
|
879
940
|
if (buckets.length === 0) return;
|
|
880
941
|
const statement = this.db.$client.prepare(
|
|
881
|
-
`INSERT INTO
|
|
882
|
-
|
|
942
|
+
`INSERT INTO t_session_usage (f_session_id, f_day, f_provider, f_model,
|
|
943
|
+
f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
|
|
944
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
945
|
+
ON CONFLICT(f_session_id, f_day, f_provider, f_model) DO UPDATE SET
|
|
946
|
+
f_input_tokens = f_input_tokens + excluded.f_input_tokens,
|
|
947
|
+
f_output_tokens = f_output_tokens + excluded.f_output_tokens,
|
|
948
|
+
f_cache_read_tokens = f_cache_read_tokens + excluded.f_cache_read_tokens,
|
|
949
|
+
f_reasoning_tokens = f_reasoning_tokens + excluded.f_reasoning_tokens,
|
|
950
|
+
f_total_tokens = f_total_tokens + excluded.f_total_tokens`,
|
|
883
951
|
);
|
|
884
|
-
for (const bucket of buckets)
|
|
952
|
+
for (const bucket of buckets) {
|
|
953
|
+
statement.run(
|
|
954
|
+
id,
|
|
955
|
+
bucket.day,
|
|
956
|
+
bucket.provider ?? "",
|
|
957
|
+
bucket.model ?? "",
|
|
958
|
+
bucket.inputTokens,
|
|
959
|
+
bucket.outputTokens,
|
|
960
|
+
bucket.cacheReadTokens,
|
|
961
|
+
bucket.reasoningTokens,
|
|
962
|
+
bucket.totalTokens,
|
|
963
|
+
);
|
|
964
|
+
}
|
|
885
965
|
}
|
|
886
966
|
|
|
887
|
-
/**
|
|
888
|
-
async
|
|
889
|
-
|
|
967
|
+
/** 活动计数旁路累加:派生表 `t_session_counts`,同上(四项计数一次落)。 */
|
|
968
|
+
async incrementSessionCounts(
|
|
969
|
+
id: SessionId,
|
|
970
|
+
buckets: readonly SessionCountBucket[],
|
|
971
|
+
): Promise<void> {
|
|
972
|
+
if (buckets.length === 0) return;
|
|
973
|
+
const statement = this.db.$client.prepare(
|
|
974
|
+
`INSERT INTO t_session_counts (f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
|
|
975
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
976
|
+
ON CONFLICT(f_session_id, f_day) DO UPDATE SET
|
|
977
|
+
f_turns = f_turns + excluded.f_turns,
|
|
978
|
+
f_steps = f_steps + excluded.f_steps,
|
|
979
|
+
f_user_inputs = f_user_inputs + excluded.f_user_inputs,
|
|
980
|
+
f_tool_calls = f_tool_calls + excluded.f_tool_calls`,
|
|
981
|
+
);
|
|
982
|
+
for (const bucket of buckets) {
|
|
983
|
+
statement.run(
|
|
984
|
+
id,
|
|
985
|
+
bucket.day,
|
|
986
|
+
bucket.turns,
|
|
987
|
+
bucket.steps,
|
|
988
|
+
bucket.userInputs,
|
|
989
|
+
bucket.toolCalls,
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** 按会话重算两张汇总表(rewind / fork 之后):先删该会话的行,再从事件表重算。 */
|
|
995
|
+
async rebuildSessionStats(id: SessionId): Promise<void> {
|
|
996
|
+
this.db.$client.prepare("DELETE FROM t_session_usage WHERE f_session_id = ?").run(id);
|
|
890
997
|
this.db.$client
|
|
891
998
|
.prepare(
|
|
892
|
-
`INSERT INTO
|
|
999
|
+
`INSERT INTO t_session_usage (
|
|
1000
|
+
f_session_id, f_day, f_provider, f_model,
|
|
1001
|
+
f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
|
|
1002
|
+
SELECT b.f_session_id, u.f_day, coalesce(u.f_provider, ''), coalesce(u.f_model, ''),
|
|
1003
|
+
sum(u.f_input_tokens), sum(u.f_output_tokens), sum(u.f_cache_read_tokens),
|
|
1004
|
+
sum(u.f_reasoning_tokens), sum(u.f_total_tokens)
|
|
1005
|
+
FROM t_session_events b
|
|
1006
|
+
JOIN t_event_usage u ON u.f_event_id = b.f_event_id
|
|
1007
|
+
WHERE b.f_session_id = ?
|
|
1008
|
+
GROUP BY b.f_session_id, u.f_day, coalesce(u.f_provider, ''), coalesce(u.f_model, '')`,
|
|
1009
|
+
)
|
|
1010
|
+
.run(id);
|
|
1011
|
+
this.db.$client.prepare("DELETE FROM t_session_counts WHERE f_session_id = ?").run(id);
|
|
1012
|
+
this.db.$client
|
|
1013
|
+
.prepare(
|
|
1014
|
+
`INSERT INTO t_session_counts (
|
|
1015
|
+
f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
|
|
893
1016
|
SELECT b.f_session_id,
|
|
894
1017
|
date(e.f_created_at / 1000, 'unixepoch', 'localtime'),
|
|
895
|
-
e.f_type,
|
|
896
|
-
|
|
1018
|
+
sum(CASE WHEN e.f_type = 'turn/start' THEN 1 ELSE 0 END),
|
|
1019
|
+
sum(CASE WHEN e.f_type = 'step/start' THEN 1 ELSE 0 END),
|
|
1020
|
+
sum(CASE WHEN e.f_type = 'user/message' THEN 1 ELSE 0 END),
|
|
1021
|
+
sum(CASE WHEN e.f_type = 'tool/call' THEN 1 ELSE 0 END)
|
|
897
1022
|
FROM t_session_events b
|
|
898
1023
|
JOIN t_events e ON e.f_event_id = b.f_event_id
|
|
899
1024
|
WHERE b.f_session_id = ? AND e.f_type IN (${COUNTED_EVENT_TYPE_SQL})
|
|
900
|
-
GROUP BY
|
|
1025
|
+
GROUP BY b.f_session_id, 2`,
|
|
901
1026
|
)
|
|
902
1027
|
.run(id);
|
|
903
1028
|
}
|
|
904
1029
|
|
|
905
|
-
|
|
906
|
-
|
|
1030
|
+
/**
|
|
1031
|
+
* 全量重算用量行的引用标记:rewind 截断 / fork 复用 / 会话删除都会让「引用」跨会话变化,
|
|
1032
|
+
* 只按本会话判定不够。低频路径,一次全表 UPDATE(3 万行量级)。
|
|
1033
|
+
*/
|
|
1034
|
+
async refreshEventUsageFlags(): Promise<void> {
|
|
1035
|
+
const eventId = "t_event_usage.f_event_id";
|
|
1036
|
+
this.db.$client.exec(
|
|
1037
|
+
`UPDATE t_event_usage SET
|
|
1038
|
+
f_referenced = ${withEvent(EVENT_REFERENCED_SQL, eventId)},
|
|
1039
|
+
f_subagent = ${withEvent(EVENT_SUBAGENT_SQL, eventId)}`,
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
async deleteSessionStats(id: SessionId): Promise<void> {
|
|
1044
|
+
this.db.$client.prepare("DELETE FROM t_session_usage WHERE f_session_id = ?").run(id);
|
|
1045
|
+
this.db.$client.prepare("DELETE FROM t_session_counts WHERE f_session_id = ?").run(id);
|
|
907
1046
|
}
|
|
908
1047
|
|
|
909
1048
|
async vacuum(): Promise<void> {
|
|
@@ -277,10 +277,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
|
|
|
277
277
|
const pinned = state.pinnedSessionIds ?? [];
|
|
278
278
|
await runQuery(
|
|
279
279
|
pinned.length === 0
|
|
280
|
-
? db
|
|
281
|
-
.update(tSessions)
|
|
282
|
-
.set({ fPinnedSeq: null })
|
|
283
|
-
.where(isNotNull(tSessions.fPinnedSeq))
|
|
280
|
+
? db.update(tSessions).set({ fPinnedSeq: null }).where(isNotNull(tSessions.fPinnedSeq))
|
|
284
281
|
: db
|
|
285
282
|
.update(tSessions)
|
|
286
283
|
.set({ fPinnedSeq: null })
|
package/src/usage.ts
CHANGED
|
@@ -3,11 +3,9 @@ import type { SessionPersistenceRdb } from "./index.ts";
|
|
|
3
3
|
|
|
4
4
|
export const SESSION_USAGE_PATH = "/api/session.usage";
|
|
5
5
|
|
|
6
|
-
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
7
|
-
|
|
8
6
|
/**
|
|
9
7
|
* 时间范围的语义键:`all` 不限;`day` / `week` 是**本地自然日 / 自然周**(周一起算);
|
|
10
|
-
* `7d` / `30d` / `90d`
|
|
8
|
+
* `7d` / `30d` / `90d` 是**最近 N 个自然日**(含今天)。
|
|
11
9
|
*/
|
|
12
10
|
export type UsageRangeKey = "all" | "day" | "week" | "7d" | "30d" | "90d";
|
|
13
11
|
|
|
@@ -20,7 +18,9 @@ function localDayStart(now: number): Date {
|
|
|
20
18
|
}
|
|
21
19
|
|
|
22
20
|
/**
|
|
23
|
-
* 范围起点(含),不限时为 undefined
|
|
21
|
+
* 范围起点(含),不限时为 undefined。**所有起点都对齐到 host 本地时区的零点**——`day` / `week` 是
|
|
22
|
+
* 今天 / 本周一的零点,`7d` / `30d` / `90d` 是「今天零点往前 N-1 天」(含今天共 N 个自然日)。
|
|
23
|
+
* 对齐的意义:事件级表按毫秒过滤、会话汇总表按本地日过滤,两者因此严格等价。
|
|
24
24
|
* @param range - 语义键。
|
|
25
25
|
* @param now - 当前时刻。
|
|
26
26
|
* @returns 起点毫秒时间戳,或 undefined。
|
|
@@ -37,8 +37,12 @@ export function resolveUsageSince(range: UsageRangeKey, now: number): number | u
|
|
|
37
37
|
start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
|
|
38
38
|
return start.getTime();
|
|
39
39
|
}
|
|
40
|
-
default:
|
|
41
|
-
|
|
40
|
+
default: {
|
|
41
|
+
// 用日历日期减(不是减 N × 24h):跨夏令时切换时「N-1 天前的零点」才是本地零点。
|
|
42
|
+
const start = localDayStart(now);
|
|
43
|
+
start.setDate(start.getDate() - (ROLLING_DAYS[range] - 1));
|
|
44
|
+
return start.getTime();
|
|
45
|
+
}
|
|
42
46
|
}
|
|
43
47
|
}
|
|
44
48
|
|