@dijkspicy/opencode-stats-reporter 0.1.2 → 0.1.4
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/contrib/opencode-session-reporter/README.md +4 -2
- package/contrib/opencode-session-reporter/package.json +1 -1
- package/contrib/opencode-session-reporter/reporter.test.ts +73 -0
- package/contrib/opencode-session-reporter/session-usage-reporter.ts +29 -8
- package/package.json +1 -1
- package/panel/css/style.css +45 -4
- package/panel/index.html +1 -1
- package/panel/js/format.js +10 -0
- package/panel/js/sessions.js +80 -11
- package/src/store.js +43 -6
|
@@ -65,7 +65,7 @@ config.json 不存在、JSON 非法、字段缺失或 URL 非法时一律静默
|
|
|
65
65
|
{
|
|
66
66
|
"schema_version": 1,
|
|
67
67
|
"install_id": "0b3f7e6a-....",
|
|
68
|
-
"reporter": { "client": "opencode", "version": "0.1.
|
|
68
|
+
"reporter": { "client": "opencode", "version": "0.1.4" },
|
|
69
69
|
"sessions": [
|
|
70
70
|
{
|
|
71
71
|
"session_id": "ses_...",
|
|
@@ -79,13 +79,15 @@ config.json 不存在、JSON 非法、字段缺失或 URL 非法时一律静默
|
|
|
79
79
|
"tokens_cache_write": 1320,
|
|
80
80
|
"message_count": 12,
|
|
81
81
|
"session_created_at": 1759000000000,
|
|
82
|
+
"session_title": "每30s自动刷新功能",
|
|
83
|
+
"session_directory": "/opt/projects/BlueRegionUsage",
|
|
82
84
|
"last_activity_at": 1759003600123
|
|
83
85
|
}
|
|
84
86
|
]
|
|
85
87
|
}
|
|
86
88
|
```
|
|
87
89
|
|
|
88
|
-
`model` 取该会话最后一条已完成助手消息的 `providerID/modelID`;`agent` 取其 `mode`(无则为 `null
|
|
90
|
+
`model` 取该会话最后一条已完成助手消息的 `providerID/modelID`;`agent` 取其 `mode`(无则为 `null`)。`session_title` / `session_directory` 取自**同一次** `session.get` 调用(不新增 API 请求),取不到时为 `null`(不是空字符串)。载荷中**不存在** consumer/identity 类字段。
|
|
89
91
|
|
|
90
92
|
## 测试
|
|
91
93
|
|
|
@@ -77,12 +77,25 @@ const assistantMessage = (over: Partial<NonNullable<UsageMessage["info"]>> = {})
|
|
|
77
77
|
const makeClient = (
|
|
78
78
|
messages: Record<string, UsageMessage[]>,
|
|
79
79
|
children: Record<string, string[]> = {},
|
|
80
|
+
/** session.get 的返回元数据;不传则 client 不含 session.get */
|
|
81
|
+
meta?: { created?: number | null; title?: string | null; directory?: string | null; throws?: boolean },
|
|
80
82
|
): ReporterClient => ({
|
|
81
83
|
session: {
|
|
82
84
|
messages: async (args: { path: { id: string } }) => ({ data: messages[args.path.id] ?? [] }),
|
|
83
85
|
children: async (args: { path: { id: string } }) => ({
|
|
84
86
|
data: (children[args.path.id] ?? []).map((id) => ({ id })),
|
|
85
87
|
}),
|
|
88
|
+
...(meta
|
|
89
|
+
? {
|
|
90
|
+
get: meta.throws
|
|
91
|
+
? async () => {
|
|
92
|
+
throw new Error("boom: session.get failed")
|
|
93
|
+
}
|
|
94
|
+
: async () => ({
|
|
95
|
+
data: { time: { created: meta.created ?? 1_700_000_000_000 }, title: meta.title ?? null, directory: meta.directory ?? null },
|
|
96
|
+
}),
|
|
97
|
+
}
|
|
98
|
+
: {}),
|
|
86
99
|
},
|
|
87
100
|
})
|
|
88
101
|
|
|
@@ -122,6 +135,8 @@ const snap = (sessionID: string, over: Partial<SessionSnapshot> = {}): SessionSn
|
|
|
122
135
|
tokens_cache_write: 0,
|
|
123
136
|
message_count: 1,
|
|
124
137
|
session_created_at: null,
|
|
138
|
+
session_title: null,
|
|
139
|
+
session_directory: null,
|
|
125
140
|
last_activity_at: 1_000 + snapSeq,
|
|
126
141
|
...over,
|
|
127
142
|
}
|
|
@@ -136,7 +151,9 @@ const SESSION_KEYS = [
|
|
|
136
151
|
"model",
|
|
137
152
|
"revision",
|
|
138
153
|
"session_created_at",
|
|
154
|
+
"session_directory",
|
|
139
155
|
"session_id",
|
|
156
|
+
"session_title",
|
|
140
157
|
"tokens_cache_read",
|
|
141
158
|
"tokens_cache_write",
|
|
142
159
|
"tokens_input",
|
|
@@ -481,6 +498,62 @@ await runTest("插件未设 SESSION_USAGE_ENDPOINT 时依然激活(返回 even
|
|
|
481
498
|
}
|
|
482
499
|
})
|
|
483
500
|
|
|
501
|
+
await runTest("session.get 返回 title/directory → 快照与载荷带上,且复用同一次调用", async () => {
|
|
502
|
+
const client = makeClient(
|
|
503
|
+
{ ses_meta: [assistantMessage()] },
|
|
504
|
+
{},
|
|
505
|
+
{ created: 1_759_000_000_000, title: "每30s自动刷新功能", directory: "/opt/projects/BlueRegionUsage" },
|
|
506
|
+
)
|
|
507
|
+
let getCalls = 0
|
|
508
|
+
const origGet = client.session.get!
|
|
509
|
+
client.session.get = (async (args: { path: { id: string } }) => {
|
|
510
|
+
getCalls += 1
|
|
511
|
+
return origGet(args)
|
|
512
|
+
}) as typeof client.session.get
|
|
513
|
+
|
|
514
|
+
const { fetchFn, calls } = makeFetch("ok")
|
|
515
|
+
const { core } = makeCore(client, { endpoint: "http://e", fetchFn })
|
|
516
|
+
await core.collectSession("ses_meta")
|
|
517
|
+
|
|
518
|
+
const snapshot = core.queue.drainAll().find((s) => s.session_id === "ses_meta")
|
|
519
|
+
assert(!!snapshot, "应产出快照")
|
|
520
|
+
assertEqual(snapshot!.session_title, "每30s自动刷新功能", "快照 title")
|
|
521
|
+
assertEqual(snapshot!.session_directory, "/opt/projects/BlueRegionUsage", "快照 directory")
|
|
522
|
+
assertEqual(snapshot!.session_created_at, 1_759_000_000_000, "快照 created")
|
|
523
|
+
assertEqual(getCalls, 1, "单会话恰好一次 session.get(不新增 API 请求)")
|
|
524
|
+
|
|
525
|
+
// 载荷层面
|
|
526
|
+
const q = createQueue(50)
|
|
527
|
+
q.push(snapshot!)
|
|
528
|
+
const core2 = makeCore(makeClient({}), { endpoint: "http://e", queue: q, fetchFn }).core
|
|
529
|
+
await core2.flushOnce()
|
|
530
|
+
const body = JSON.parse(calls[0]!.init.body ?? "{}")
|
|
531
|
+
assertEqual(body.sessions[0].session_title, "每30s自动刷新功能", "载荷 title")
|
|
532
|
+
assertEqual(body.sessions[0].session_directory, "/opt/projects/BlueRegionUsage", "载荷 directory")
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
await runTest("session.get 抛错 → title/directory/created 为 null 而非崩溃", async () => {
|
|
536
|
+
const client = makeClient({ ses_err: [assistantMessage()] }, {}, { throws: true })
|
|
537
|
+
const { core } = makeCore(client, { endpoint: "http://e" })
|
|
538
|
+
await core.collectSession("ses_err")
|
|
539
|
+
const snapshot = core.queue.drainAll().find((s) => s.session_id === "ses_err")
|
|
540
|
+
assert(!!snapshot, "采集主流程不受影响,快照仍产生")
|
|
541
|
+
assertEqual(snapshot!.session_title, null, "title → null")
|
|
542
|
+
assertEqual(snapshot!.session_directory, null, "directory → null")
|
|
543
|
+
assertEqual(snapshot!.session_created_at, null, "created → null")
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
await runTest("session.get 不存在 → 三个元数据字段均为 null", async () => {
|
|
547
|
+
const client = makeClient({ ses_noget: [assistantMessage()] }) // 无 session.get
|
|
548
|
+
const { core } = makeCore(client, { endpoint: "http://e" })
|
|
549
|
+
await core.collectSession("ses_noget")
|
|
550
|
+
const snapshot = core.queue.drainAll().find((s) => s.session_id === "ses_noget")
|
|
551
|
+
assert(!!snapshot, "应产出快照")
|
|
552
|
+
assertEqual(snapshot!.session_title, null, "title → null")
|
|
553
|
+
assertEqual(snapshot!.session_directory, null, "directory → null")
|
|
554
|
+
assertEqual(snapshot!.session_created_at, null, "created → null")
|
|
555
|
+
})
|
|
556
|
+
|
|
484
557
|
// ---------------------------------------------------------------------------
|
|
485
558
|
// 汇总
|
|
486
559
|
// ---------------------------------------------------------------------------
|
|
@@ -43,7 +43,7 @@ import { dirname, join } from "node:path"
|
|
|
43
43
|
// ---------------------------------------------------------------------------
|
|
44
44
|
|
|
45
45
|
const SCHEMA_VERSION = 1
|
|
46
|
-
const PLUGIN_VERSION = "0.1.
|
|
46
|
+
const PLUGIN_VERSION = "0.1.4"
|
|
47
47
|
/** 队列容量:最多积压 500 条会话快照,溢出丢最旧 */
|
|
48
48
|
const QUEUE_CAP = 500
|
|
49
49
|
/** 单次上报请求超时(AbortSignal.timeout) */
|
|
@@ -111,6 +111,8 @@ export type SessionSnapshot = {
|
|
|
111
111
|
tokens_cache_write: number
|
|
112
112
|
message_count: number
|
|
113
113
|
session_created_at: number | null
|
|
114
|
+
session_title: string | null
|
|
115
|
+
session_directory: string | null
|
|
114
116
|
last_activity_at: number | null
|
|
115
117
|
}
|
|
116
118
|
|
|
@@ -256,6 +258,8 @@ const aggregateSessionImpl = (sessionID: string, messages: readonly UsageMessage
|
|
|
256
258
|
tokens_cache_write: cacheWrite,
|
|
257
259
|
message_count: count,
|
|
258
260
|
session_created_at: null, // 由采集层补充
|
|
261
|
+
session_title: null, // 由采集层补充(session.get)
|
|
262
|
+
session_directory: null, // 由采集层补充(session.get)
|
|
259
263
|
last_activity_at: lastActivity,
|
|
260
264
|
}
|
|
261
265
|
}
|
|
@@ -513,16 +517,29 @@ const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
|
|
|
513
517
|
return ids
|
|
514
518
|
}
|
|
515
519
|
|
|
516
|
-
const
|
|
520
|
+
const readSessionMeta = async (
|
|
521
|
+
sessionID: string,
|
|
522
|
+
): Promise<{ created: number | null; title: string | null; directory: string | null }> => {
|
|
517
523
|
try {
|
|
518
524
|
const getSession = options.client.session.get
|
|
519
|
-
if (typeof getSession !== "function") return null
|
|
525
|
+
if (typeof getSession !== "function") return { created: null, title: null, directory: null }
|
|
520
526
|
const response = await getSession.call(options.client.session, { path: { id: sessionID }, query: query() })
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
527
|
+
const data = (
|
|
528
|
+
response as
|
|
529
|
+
| { data?: { time?: { created?: unknown }; title?: unknown; directory?: unknown } }
|
|
530
|
+
| undefined
|
|
531
|
+
)?.data
|
|
532
|
+
const created = data?.time?.created
|
|
533
|
+
const title = data?.title
|
|
534
|
+
const directory = data?.directory
|
|
535
|
+
return {
|
|
536
|
+
created: typeof created === "number" ? created : null,
|
|
537
|
+
title: typeof title === "string" && title ? title : null,
|
|
538
|
+
directory: typeof directory === "string" && directory ? directory : null,
|
|
539
|
+
}
|
|
524
540
|
} catch {
|
|
525
|
-
|
|
541
|
+
// session.get 抛错 / 形状异常:元数据缺省为 null,绝不影响采集主流程
|
|
542
|
+
return { created: null, title: null, directory: null }
|
|
526
543
|
}
|
|
527
544
|
}
|
|
528
545
|
|
|
@@ -537,7 +554,11 @@ const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
|
|
|
537
554
|
visited.add(sessionID)
|
|
538
555
|
const snapshot = aggregateSessionImpl(sessionID, await readMessages(sessionID))
|
|
539
556
|
if (snapshot) {
|
|
540
|
-
|
|
557
|
+
// 复用同一次 session.get:created / title / directory 一并取得,不新增 API 请求
|
|
558
|
+
const meta = await readSessionMeta(sessionID)
|
|
559
|
+
snapshot.session_created_at = meta.created
|
|
560
|
+
snapshot.session_title = meta.title
|
|
561
|
+
snapshot.session_directory = meta.directory
|
|
541
562
|
out.push(snapshot)
|
|
542
563
|
}
|
|
543
564
|
for (const childID of await readChildIDs(sessionID)) {
|
package/package.json
CHANGED
package/panel/css/style.css
CHANGED
|
@@ -527,22 +527,63 @@ body {
|
|
|
527
527
|
#session-table tbody tr:hover { background: rgba(127,127,127,.06); }
|
|
528
528
|
#session-table tbody tr:last-child td { border-bottom: none; }
|
|
529
529
|
|
|
530
|
-
/*
|
|
531
|
-
.sess-
|
|
530
|
+
/* 会话首列:两行结构(主行标题 / 次行目录+ID),次要信息弱化不喧宾夺主 */
|
|
531
|
+
.sess-title-line { min-width: 0; }
|
|
532
|
+
.sess-title {
|
|
532
533
|
display: inline-block;
|
|
534
|
+
max-width: 340px;
|
|
535
|
+
overflow: hidden;
|
|
536
|
+
text-overflow: ellipsis;
|
|
537
|
+
white-space: nowrap;
|
|
538
|
+
vertical-align: bottom;
|
|
539
|
+
font-weight: 600;
|
|
540
|
+
cursor: pointer;
|
|
541
|
+
}
|
|
542
|
+
.sess-title:hover { color: var(--accent-hover); text-decoration: underline; }
|
|
543
|
+
.sess-sub-line {
|
|
544
|
+
display: flex;
|
|
545
|
+
align-items: center;
|
|
546
|
+
gap: 6px;
|
|
547
|
+
margin-top: 2px;
|
|
548
|
+
font-size: 11px;
|
|
549
|
+
color: var(--text-secondary);
|
|
550
|
+
}
|
|
551
|
+
.sess-sub-sep { flex: none; }
|
|
552
|
+
.sess-dir {
|
|
533
553
|
max-width: 200px;
|
|
534
554
|
overflow: hidden;
|
|
535
555
|
text-overflow: ellipsis;
|
|
536
556
|
white-space: nowrap;
|
|
557
|
+
font-size: 11px;
|
|
558
|
+
cursor: default;
|
|
559
|
+
}
|
|
560
|
+
@media (max-width: 767px) {
|
|
561
|
+
.sess-title { max-width: 180px; }
|
|
562
|
+
.sess-dir { max-width: 120px; }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/* 会话 ID:默认弱化(有标题时),等宽头尾截断,完整值在 title,点击进详情 */
|
|
566
|
+
.sess-id-btn {
|
|
567
|
+
display: inline-block;
|
|
568
|
+
max-width: 130px;
|
|
569
|
+
overflow: hidden;
|
|
570
|
+
text-overflow: ellipsis;
|
|
571
|
+
white-space: nowrap;
|
|
537
572
|
vertical-align: bottom;
|
|
538
573
|
padding: 0;
|
|
539
574
|
border: none;
|
|
540
575
|
background: transparent;
|
|
541
|
-
color: var(--
|
|
576
|
+
color: var(--text-secondary);
|
|
542
577
|
font-family: Consolas, monospace;
|
|
543
|
-
font-size:
|
|
578
|
+
font-size: 11px;
|
|
544
579
|
cursor: pointer;
|
|
545
580
|
}
|
|
581
|
+
/* 无标题时的降级主标识:恢复高亮,保证一眼可点 */
|
|
582
|
+
.sess-id-btn.is-primary {
|
|
583
|
+
max-width: 200px;
|
|
584
|
+
color: var(--accent);
|
|
585
|
+
font-size: 12px;
|
|
586
|
+
}
|
|
546
587
|
.sess-id-btn:hover { color: var(--accent-hover); text-decoration: underline; }
|
|
547
588
|
.sess-id-btn:focus-visible { outline: 1px solid var(--accent); }
|
|
548
589
|
|
package/panel/index.html
CHANGED
package/panel/js/format.js
CHANGED
|
@@ -106,6 +106,15 @@
|
|
|
106
106
|
' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** 毫秒时间戳 -> 'MM-DD HH:MM'(当年,会话列表"最后活动"列);跨年回退完整格式,完整值另放 title */
|
|
110
|
+
function shortDateTime(ms) {
|
|
111
|
+
if (ms === null || ms === undefined || !isFinite(Number(ms))) return '--';
|
|
112
|
+
var d = new Date(Number(ms));
|
|
113
|
+
if (d.getFullYear() !== new Date().getFullYear()) return dateTime(ms);
|
|
114
|
+
function p(x) { return (x < 10 ? '0' : '') + x; }
|
|
115
|
+
return p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
/**
|
|
110
119
|
* 环比变化(比值):当前值与上一周期值比较,返回 { text, dir }
|
|
111
120
|
* dir: 1 升 / -1 降 / 0 持平;基准为 0 或空值时返回 null(不展示)
|
|
@@ -149,6 +158,7 @@
|
|
|
149
158
|
todayStr: todayStr,
|
|
150
159
|
hourLabel: hourLabel,
|
|
151
160
|
dateTime: dateTime,
|
|
161
|
+
shortDateTime: shortDateTime,
|
|
152
162
|
pctChange: pctChange,
|
|
153
163
|
ppChange: ppChange,
|
|
154
164
|
maskKey: maskKey
|
package/panel/js/sessions.js
CHANGED
|
@@ -196,18 +196,84 @@
|
|
|
196
196
|
st.rows.forEach(function (r) { tbody.appendChild(sessionRow(r)); });
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/** 空值守卫:非字符串或空白串返回 null,避免把 null/undefined/空串渲染到页面 */
|
|
200
|
+
function nonEmpty(v) {
|
|
201
|
+
return (typeof v === 'string' && v.trim()) ? v.trim() : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 会话 ID 头尾截断:ses_k2X9fjQ3mT…89xQ,头尾是最有辨识度的部分;完整值在 title 与详情 */
|
|
205
|
+
function shortId(id) {
|
|
206
|
+
return id.length > 20 ? id.slice(0, 14) + '…' + id.slice(-4) : id;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 目录压缩:取末两级,如 /opt/projects/BlueRegionUsage -> …/projects/BlueRegionUsage;完整路径放 title */
|
|
210
|
+
function shortDir(dir) {
|
|
211
|
+
var parts = String(dir).replace(/\\/g, '/').split('/').filter(function (s) { return s !== ''; });
|
|
212
|
+
if (!parts.length) return dir;
|
|
213
|
+
if (parts.length <= 2) return '/' + parts.join('/');
|
|
214
|
+
return '…/' + parts.slice(-2).join('/');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 会话 ID 按钮:等宽展示(头尾截断),完整值放 title,点击进详情;primary = 无标题时作为降级主标识 */
|
|
218
|
+
function idButton(sid, primary) {
|
|
219
|
+
var b = document.createElement('button');
|
|
220
|
+
b.type = 'button';
|
|
221
|
+
b.className = 'sess-id-btn mono' + (primary ? ' is-primary' : '');
|
|
222
|
+
b.textContent = shortId(sid);
|
|
223
|
+
b.title = sid + '\n点击查看详情';
|
|
224
|
+
b.addEventListener('click', function () { openDetail(sid); });
|
|
225
|
+
return b;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 首列「会话」:两行结构
|
|
230
|
+
* 主行:会话标题(加粗);无标题时降级为会话 ID(等宽、高亮、可点)
|
|
231
|
+
* 次行:项目目录(末两级压缩,悬浮显全路径)· 会话 ID(弱化;ID 已作主标识时不重复)
|
|
232
|
+
*/
|
|
199
233
|
function sessionRow(r) {
|
|
200
234
|
var tr = document.createElement('tr');
|
|
201
|
-
|
|
202
|
-
var
|
|
203
|
-
var
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
235
|
+
var title = nonEmpty(r.session_title);
|
|
236
|
+
var dir = nonEmpty(r.session_directory);
|
|
237
|
+
var sid = nonEmpty(r.session_id) || '--';
|
|
238
|
+
|
|
239
|
+
var cell = document.createElement('td');
|
|
240
|
+
cell.className = 'sess-cell';
|
|
241
|
+
|
|
242
|
+
var line1 = document.createElement('div');
|
|
243
|
+
line1.className = 'sess-title-line';
|
|
244
|
+
if (title) {
|
|
245
|
+
var t = document.createElement('span');
|
|
246
|
+
t.className = 'sess-title';
|
|
247
|
+
t.textContent = title;
|
|
248
|
+
t.title = title + '\n点击查看详情';
|
|
249
|
+
t.addEventListener('click', function () { openDetail(sid); });
|
|
250
|
+
line1.appendChild(t);
|
|
251
|
+
} else {
|
|
252
|
+
line1.appendChild(idButton(sid, true));
|
|
253
|
+
}
|
|
254
|
+
cell.appendChild(line1);
|
|
255
|
+
|
|
256
|
+
var line2 = document.createElement('div');
|
|
257
|
+
line2.className = 'sess-sub-line';
|
|
258
|
+
if (dir) {
|
|
259
|
+
var d = document.createElement('span');
|
|
260
|
+
d.className = 'sess-dir mono';
|
|
261
|
+
d.textContent = shortDir(dir);
|
|
262
|
+
d.title = '目录:' + dir;
|
|
263
|
+
line2.appendChild(d);
|
|
264
|
+
}
|
|
265
|
+
if (title) {
|
|
266
|
+
if (dir) {
|
|
267
|
+
var sep = document.createElement('span');
|
|
268
|
+
sep.className = 'sess-sub-sep';
|
|
269
|
+
sep.textContent = '·';
|
|
270
|
+
line2.appendChild(sep);
|
|
271
|
+
}
|
|
272
|
+
line2.appendChild(idButton(sid, false));
|
|
273
|
+
}
|
|
274
|
+
if (line2.childNodes.length) cell.appendChild(line2);
|
|
275
|
+
|
|
276
|
+
tr.appendChild(cell);
|
|
211
277
|
|
|
212
278
|
tr.appendChild(td(r.model || '--'));
|
|
213
279
|
tr.appendChild(td(r.agent || '--'));
|
|
@@ -217,7 +283,8 @@
|
|
|
217
283
|
tr.appendChild(td(F.fullNumber(r.tokens_cache_read), 'num'));
|
|
218
284
|
tr.appendChild(td(F.fullNumber(r.tokens_cache_write), 'num'));
|
|
219
285
|
tr.appendChild(td(F.fullNumber(r.message_count), 'num'));
|
|
220
|
-
|
|
286
|
+
var lastFull = F.dateTime(r.last_activity_at);
|
|
287
|
+
tr.appendChild(td(F.shortDateTime(r.last_activity_at), null, lastFull === '--' ? '' : lastFull));
|
|
221
288
|
return tr;
|
|
222
289
|
}
|
|
223
290
|
|
|
@@ -312,6 +379,8 @@
|
|
|
312
379
|
]));
|
|
313
380
|
|
|
314
381
|
body.appendChild(group('会话信息', [
|
|
382
|
+
item('会话标题', nonEmpty(d.session_title) || '--'),
|
|
383
|
+
item('项目目录', nonEmpty(d.session_directory) || '--', true),
|
|
315
384
|
item('模型', d.model || '--'),
|
|
316
385
|
item('代理', d.agent || '--'),
|
|
317
386
|
item('安装标识', d.install_id || '--', true),
|
package/src/store.js
CHANGED
|
@@ -29,6 +29,8 @@ CREATE TABLE IF NOT EXISTS session_usage (
|
|
|
29
29
|
client_reported_at INTEGER,
|
|
30
30
|
first_seen_at INTEGER NOT NULL,
|
|
31
31
|
last_reported_at INTEGER NOT NULL,
|
|
32
|
+
session_title TEXT,
|
|
33
|
+
session_directory TEXT,
|
|
32
34
|
PRIMARY KEY (consumer_id, install_id, session_id)
|
|
33
35
|
);
|
|
34
36
|
CREATE INDEX IF NOT EXISTS idx_su_consumer_activity ON session_usage(consumer_id, last_activity_at DESC);
|
|
@@ -89,6 +91,8 @@ export function sanitizeRecord(raw) {
|
|
|
89
91
|
last_activity_at: int(raw.last_activity_at),
|
|
90
92
|
client_reported_at: optInt(raw.client_reported_at),
|
|
91
93
|
client_session_header: str(raw.client_session_header),
|
|
94
|
+
session_title: str(raw.session_title, 300),
|
|
95
|
+
session_directory: str(raw.session_directory, 512),
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
98
|
|
|
@@ -109,6 +113,23 @@ export class Store {
|
|
|
109
113
|
|
|
110
114
|
migrate() {
|
|
111
115
|
this.db.exec(SCHEMA)
|
|
116
|
+
this.ensureColumns()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 幂等迁移:为已存在的旧库补齐后增列。
|
|
121
|
+
* SCHEMA 只在表不存在时建表;旧库需用 PRAGMA table_info 检测缺列后 ALTER TABLE ADD COLUMN
|
|
122
|
+
* (可空 TEXT 列,不写默认值 → 旧行自动为 NULL,既有数据不受影响;重复调用安全)。
|
|
123
|
+
*/
|
|
124
|
+
ensureColumns() {
|
|
125
|
+
const existing = new Set(
|
|
126
|
+
this.db.prepare("PRAGMA table_info(session_usage)").all().map((r) => r.name),
|
|
127
|
+
)
|
|
128
|
+
for (const [name, type] of [["session_title", "TEXT"], ["session_directory", "TEXT"]]) {
|
|
129
|
+
if (!existing.has(name)) {
|
|
130
|
+
this.db.exec(`ALTER TABLE session_usage ADD COLUMN ${name} ${type}`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
112
133
|
}
|
|
113
134
|
|
|
114
135
|
indexes() {
|
|
@@ -128,19 +149,22 @@ export class Store {
|
|
|
128
149
|
let accepted = 0
|
|
129
150
|
let skipped = 0
|
|
130
151
|
const find = this.db.prepare(
|
|
131
|
-
"SELECT revision, last_activity_at FROM session_usage WHERE consumer_id=? AND install_id=? AND session_id=?",
|
|
152
|
+
"SELECT revision, last_activity_at, session_title, session_directory FROM session_usage WHERE consumer_id=? AND install_id=? AND session_id=?",
|
|
153
|
+
)
|
|
154
|
+
const backfill = this.db.prepare(
|
|
155
|
+
"UPDATE session_usage SET session_title=?, session_directory=?, last_reported_at=? WHERE consumer_id=? AND install_id=? AND session_id=?",
|
|
132
156
|
)
|
|
133
157
|
const insert = this.db.prepare(`INSERT INTO session_usage
|
|
134
158
|
(consumer_id, install_id, session_id, source, schema_version, reporter_version, client_session_header,
|
|
135
159
|
model, agent, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
|
136
160
|
cost, message_count, revision, session_created_at, last_activity_at, client_reported_at,
|
|
137
|
-
first_seen_at, last_reported_at)
|
|
138
|
-
VALUES (
|
|
161
|
+
first_seen_at, last_reported_at, session_title, session_directory)
|
|
162
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
139
163
|
const update = this.db.prepare(`UPDATE session_usage SET
|
|
140
164
|
source=?, schema_version=?, reporter_version=?, client_session_header=?, model=?, agent=?,
|
|
141
165
|
tokens_input=?, tokens_output=?, tokens_reasoning=?, tokens_cache_read=?, tokens_cache_write=?,
|
|
142
166
|
cost=?, message_count=?, revision=?, session_created_at=?, last_activity_at=?, client_reported_at=?,
|
|
143
|
-
last_reported_at=?
|
|
167
|
+
last_reported_at=?, session_title=?, session_directory=?
|
|
144
168
|
WHERE consumer_id=? AND install_id=? AND session_id=?`)
|
|
145
169
|
|
|
146
170
|
for (const r of records) {
|
|
@@ -152,6 +176,7 @@ export class Store {
|
|
|
152
176
|
consumerId, r.install_id, r.session_id, source, schemaVersion, reporterVersion, r.client_session_header,
|
|
153
177
|
r.model, r.agent, r.tokens_input, r.tokens_output, r.tokens_reasoning, r.tokens_cache_read, r.tokens_cache_write,
|
|
154
178
|
r.cost, r.message_count, r.revision, r.session_created_at, r.last_activity_at, r.client_reported_at, ts, ts,
|
|
179
|
+
r.session_title, r.session_directory,
|
|
155
180
|
)
|
|
156
181
|
} else if (r.revision > prev.revision || (r.revision === prev.revision && r.last_activity_at > prev.last_activity_at)) {
|
|
157
182
|
status = "upserted"
|
|
@@ -159,14 +184,26 @@ export class Store {
|
|
|
159
184
|
source, schemaVersion, reporterVersion, r.client_session_header, r.model, r.agent,
|
|
160
185
|
r.tokens_input, r.tokens_output, r.tokens_reasoning, r.tokens_cache_read, r.tokens_cache_write,
|
|
161
186
|
r.cost, r.message_count, r.revision, r.session_created_at, r.last_activity_at, r.client_reported_at, ts,
|
|
187
|
+
r.session_title, r.session_directory,
|
|
162
188
|
consumerId, r.install_id, r.session_id,
|
|
163
189
|
)
|
|
164
190
|
} else if (r.revision === prev.revision && r.last_activity_at === prev.last_activity_at) {
|
|
165
|
-
|
|
191
|
+
// 单调守卫内(revision 与活动时间均未推进):仅当元数据变化时做「窄更新」回填,
|
|
192
|
+
// 只写 session_title / session_directory / last_reported_at,不触碰 token 类字段、
|
|
193
|
+
// revision、last_activity_at、source、reporter_version 等计量与守卫字段。
|
|
194
|
+
const metaChanged =
|
|
195
|
+
(r.session_title ?? null) !== (prev.session_title ?? null) ||
|
|
196
|
+
(r.session_directory ?? null) !== (prev.session_directory ?? null)
|
|
197
|
+
if (metaChanged) {
|
|
198
|
+
status = "backfilled"
|
|
199
|
+
backfill.run(r.session_title ?? null, r.session_directory ?? null, ts, consumerId, r.install_id, r.session_id)
|
|
200
|
+
} else {
|
|
201
|
+
status = "unchanged"
|
|
202
|
+
}
|
|
166
203
|
} else {
|
|
167
204
|
status = "stale"
|
|
168
205
|
}
|
|
169
|
-
if (status === "upserted" || status === "unchanged") accepted += 1
|
|
206
|
+
if (status === "upserted" || status === "unchanged" || status === "backfilled") accepted += 1
|
|
170
207
|
else skipped += 1
|
|
171
208
|
results.push({ session_id: r.session_id, install_id: r.install_id, status })
|
|
172
209
|
}
|