@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.
@@ -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.2" },
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`)。载荷中**不存在** consumer/identity 类字段。
90
+ `model` 取该会话最后一条已完成助手消息的 `providerID/modelID`;`agent` 取其 `mode`(无则为 `null`)。`session_title` / `session_directory` 取自**同一次** `session.get` 调用(不新增 API 请求),取不到时为 `null`(不是空字符串)。载荷中**不存在** consumer/identity 类字段。
89
91
 
90
92
  ## 测试
91
93
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-session-reporter",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "opencode 客户端插件:在会话空闲时聚合各会话(含子代理会话)的 token 用量快照,经有界队列 + 指数退避非阻塞上报;载荷不含消费者身份字段。",
@@ -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.2"
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 readSessionCreatedAt = async (sessionID: string): Promise<number | null> => {
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 created = (response as { data?: { time?: { created?: unknown } } | undefined } | undefined)?.data?.time
522
- ?.created
523
- return typeof created === "number" ? created : null
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
- return null
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
- snapshot.session_created_at = await readSessionCreatedAt(sessionID)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dijkspicy/opencode-stats-reporter",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "本地一体化 MaaS 用量看板:托管静态面板、反代用量接口、本地存储会话用量",
5
5
  "type": "module",
6
6
  "main": "./contrib/opencode-session-reporter/plugin-server.ts",
@@ -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
- /* 会话 ID:等宽截断,完整值在 title,点击进详情 */
531
- .sess-id-btn {
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(--accent);
576
+ color: var(--text-secondary);
542
577
  font-family: Consolas, monospace;
543
- font-size: 12px;
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
@@ -296,7 +296,7 @@
296
296
  <table id="session-table" class="session-table">
297
297
  <thead>
298
298
  <tr>
299
- <th>会话</th>
299
+ <th title="优先显示会话标题,无标题时显示会话 ID;下方小字为会话所属项目目录,悬浮可看完整路径">会话</th>
300
300
  <th>模型</th>
301
301
  <th>代理</th>
302
302
  <th class="num" title="输入总量 = 输入 + 缓存读 + 缓存写">输入总量</th>
@@ -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
@@ -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
- // 会话 ID:等宽截断展示,完整值放 title,点击查看详情
202
- var sidTd = document.createElement('td');
203
- var sidBtn = document.createElement('button');
204
- sidBtn.type = 'button';
205
- sidBtn.className = 'sess-id-btn mono';
206
- sidBtn.textContent = r.session_id || '--';
207
- sidBtn.title = (r.session_id || '') + '\n点击查看详情';
208
- sidBtn.addEventListener('click', function () { openDetail(r.session_id); });
209
- sidTd.appendChild(sidBtn);
210
- tr.appendChild(sidTd);
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
- tr.appendChild(td(F.dateTime(r.last_activity_at)));
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
- status = "unchanged"
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
  }