dsh-key-usage 0.1.1 → 0.2.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/lib/index.js CHANGED
@@ -37,6 +37,9 @@ export function apply(ctx) {
37
37
  },
38
38
  });
39
39
  ctx.inject(['webServer'], (host) => {
40
- host.effect(() => mountKeyUsageRoutes(host.webServer, sessionSource, liveQuota), 'dsh-key-usage: http routes');
40
+ host.effect(() => mountKeyUsageRoutes(host.webServer, sessionSource, liveQuota, {
41
+ // workspaceRegistry 用来读官方归档集合(拿不到就回退本地 storages/workspace.json)
42
+ get: (serviceName) => (typeof ctx.get === 'function' ? ctx.get(serviceName) : undefined),
43
+ }), 'dsh-key-usage: http routes');
41
44
  });
42
45
  }
package/lib/routes.js CHANGED
@@ -20,6 +20,7 @@ import { join } from 'node:path';
20
20
  import { aggregateRange, buildPriceTable, estimateCost, readLedger, } from './ledger.js';
21
21
  import { classifyChannels, costModeLabel, extractChannels, quotaWindowLabel, readProviderSnapshots, } from './collect.js';
22
22
  import { hoursOfDate, projectRowsOf, sessionRowsOf, } from './sessions.js';
23
+ import { createArchiveSource, createTitleSource } from './session-meta.js';
23
24
  import { mergeDailySeries, sessionDayRows, summarizeMergedByProvider, totalsOfMerged, } from './merge.js';
24
25
  /** 无会话服务时的兜底数据源,保证台账相关端点照常工作。 */
25
26
  export const unavailableSessionSource = {
@@ -62,9 +63,13 @@ function channelPayload(channel) {
62
63
  quotaWindowLabel: channel.quotaWindowKey === null ? null : quotaWindowLabel(channel.quotaWindowKey),
63
64
  };
64
65
  }
65
- export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessionSource, liveQuota = disabledLiveQuota) {
66
+ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessionSource, liveQuota = disabledLiveQuota, hostServices = { get: () => undefined }) {
66
67
  const ledgerPath = join(dshHomePath(), 'dsh-usage', 'usage-ledger.json');
67
68
  const snapshotPath = join(dshHomePath(), 'dsh-usage', 'provider-snapshots.json');
69
+ const titleSource = createTitleSource(join(dshHomePath(), 'storages', 'session_projcache.json'));
70
+ const archiveSource = createArchiveSource(hostServices, join(dshHomePath(), 'storages', 'workspace.json'));
71
+ /** 会话标题与归档状态:宿主服务/缓存文件都拿不到时返回空集合,界面自行回落。 */
72
+ const sessionMeta = () => ({ titles: titleSource.titles(), archived: archiveSource.archived() });
68
73
  const loadLedger = () => readLedger(ledgerPath, (path) => {
69
74
  try {
70
75
  return readFileSync(path, 'utf8');
@@ -260,7 +265,8 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
260
265
  const costModeOf = new Map(channels.map((channel) => [channel.provider, channel.costMode]));
261
266
  const { records, coverage } = await sessionSource.collect();
262
267
  const priceTable = buildPriceTable(loadLedger());
263
- const rows = sessionRowsOf(records, knownProviders).slice(0, limit).map((row) => ({
268
+ const meta = sessionMeta();
269
+ const rows = sessionRowsOf(records, knownProviders, meta).slice(0, limit).map((row) => ({
264
270
  ...row,
265
271
  providerLabel: channels.find((channel) => channel.provider === row.provider)?.displayName ?? row.provider,
266
272
  costMode: row.provider === null ? null : costModeOf.get(row.provider) ?? null,
@@ -281,7 +287,7 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
281
287
  const knownProviders = channels.map((channel) => channel.provider);
282
288
  const { records, coverage } = await sessionSource.collect();
283
289
  const priceTable = buildPriceTable(loadLedger());
284
- const rows = projectRowsOf(records, knownProviders).slice(0, limit).map((row) => ({
290
+ const rows = projectRowsOf(records, knownProviders, sessionMeta()).slice(0, limit).map((row) => ({
285
291
  ...row,
286
292
  dominantProvider: Object.entries(row.providers).sort((first, second) => second[1] - first[1])[0]?.[0] ?? null,
287
293
  cost: estimateCost(row.models, priceTable),
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 会话标题与归档状态的本地来源。
3
+ *
4
+ * - **标题**:宿主把「会话标题」存在投影缓存 `$DSH_HOME/storages/session_projcache.json`
5
+ * 的 `tables.sessions.<id>.rows.title.val`;只有被宿主投影过的会话才有,
6
+ * 其余由 `session-usage.ts` 折叠出的「首条用户提问」兜底。
7
+ * - **归档**:优先读宿主只读服务 `workspaceRegistry.archivedSessionIds`(官方接口);
8
+ * 服务不可用时回退 `$DSH_HOME/storages/workspace.json` 的 `global.archivedSessionIds`。
9
+ *
10
+ * 两个文件都是普通 JSON,只读、不写;解析失败一律退化为「没有数据」,不影响统计。
11
+ */
12
+ import { readFileSync, statSync } from 'node:fs';
13
+ /** 宿主会话 id 两种写法(`session-<uuid>` / 裸 `<uuid>`)统一成裸 uuid 便于比对。 */
14
+ export function normalizeSessionId(id) {
15
+ const lowered = id.toLowerCase();
16
+ return lowered.startsWith('session-') ? lowered.slice('session-'.length) : lowered;
17
+ }
18
+ function readJson(path, readText) {
19
+ const text = readText(path);
20
+ if (text === null)
21
+ return null;
22
+ try {
23
+ return JSON.parse(text);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /** 读取 `session_projcache.json` 里的会话标题表(按 mtime 缓存,避免每次请求都解析 2 MB JSON)。 */
30
+ export function createTitleSource(cachePath, readText = (path) => {
31
+ try {
32
+ return readFileSync(path, 'utf8');
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }) {
38
+ let cache = null;
39
+ return {
40
+ titles: () => {
41
+ let mtimeMs = 0;
42
+ try {
43
+ mtimeMs = statSync(cachePath).mtimeMs;
44
+ }
45
+ catch {
46
+ return new Map();
47
+ }
48
+ if (cache !== null && cache.mtimeMs === mtimeMs)
49
+ return cache.value;
50
+ const value = parseTitles(readJson(cachePath, readText));
51
+ cache = { mtimeMs, value };
52
+ return value;
53
+ },
54
+ };
55
+ }
56
+ /** 从投影缓存文档里取出 id → 标题(结构不符时返回空表)。 */
57
+ export function parseTitles(document) {
58
+ const titles = new Map();
59
+ const sessions = document?.tables?.sessions;
60
+ if (typeof sessions !== 'object' || sessions === null)
61
+ return titles;
62
+ for (const [id, entry] of Object.entries(sessions)) {
63
+ const value = entry?.rows?.title?.val;
64
+ if (typeof value !== 'string')
65
+ continue;
66
+ const trimmed = value.trim();
67
+ if (trimmed !== '')
68
+ titles.set(normalizeSessionId(id), trimmed);
69
+ }
70
+ return titles;
71
+ }
72
+ /** 归档会话 id 集合(归一后用于比对)。 */
73
+ export function createArchiveSource(host, workspacePath, readText = (path) => {
74
+ try {
75
+ return readFileSync(path, 'utf8');
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }) {
81
+ const fromFile = () => {
82
+ const document = readJson(workspacePath, readText);
83
+ const ids = document?.global?.archivedSessionIds;
84
+ return new Set(Array.isArray(ids) ? ids.filter((id) => typeof id === 'string').map(normalizeSessionId) : []);
85
+ };
86
+ return {
87
+ archived: () => {
88
+ // 先用宿主官方只读 getter;拿不到再读文件(两者都是权威数据,getter 优先)
89
+ try {
90
+ const registry = host.get('workspaceRegistry');
91
+ if (registry !== null && registry !== undefined && Array.isArray(registry.archivedSessionIds)) {
92
+ return new Set(registry.archivedSessionIds.filter((id) => typeof id === 'string').map(normalizeSessionId));
93
+ }
94
+ }
95
+ catch {
96
+ // 落到文件
97
+ }
98
+ return fromFile();
99
+ },
100
+ };
101
+ }
@@ -86,6 +86,8 @@ export function foldSessionEvents(events) {
86
86
  let currentModel = null;
87
87
  let firstTime = null;
88
88
  let lastTime = null;
89
+ // 会话标题在宿主侧只对有投影的会话可见,其余用「首条用户提问」兜底
90
+ let firstPrompt = null;
89
91
  for (const event of events) {
90
92
  const time = typeof event.time === 'number' ? event.time : null;
91
93
  if (time !== null) {
@@ -99,6 +101,9 @@ export function foldSessionEvents(events) {
99
101
  if (model !== null)
100
102
  currentModel = model;
101
103
  }
104
+ if (firstPrompt === null && event.type === 'user/message') {
105
+ firstPrompt = promptTextOf(event);
106
+ }
102
107
  const turn = event.data?.turn;
103
108
  const step = event.data?.step;
104
109
  const baseKey = Number.isInteger(turn) && Number.isInteger(step) ? `${turn}:${step}` : `seq:${event.seq}`;
@@ -119,7 +124,28 @@ export function foldSessionEvents(events) {
119
124
  buckets: bucketsOf(sample.usage),
120
125
  });
121
126
  }
122
- return { samples, firstTime, lastTime };
127
+ return { samples, firstTime, lastTime, firstPrompt };
128
+ }
129
+ /** 标题兜底:取 user/message 里第一个非空文本块,压平空白并截断。 */
130
+ function promptTextOf(event) {
131
+ const message = event.data?.message;
132
+ if (typeof message !== 'object' || message === null)
133
+ return null;
134
+ const content = message.content;
135
+ if (!Array.isArray(content))
136
+ return null;
137
+ for (const block of content) {
138
+ if (typeof block !== 'object' || block === null)
139
+ continue;
140
+ const text = block.text;
141
+ if (typeof text !== 'string')
142
+ continue;
143
+ const flat = text.replace(/\s+/g, ' ').trim();
144
+ if (flat === '')
145
+ continue;
146
+ return flat.length > 60 ? flat.slice(0, 60) + '…' : flat;
147
+ }
148
+ return null;
123
149
  }
124
150
  /**
125
151
  * 缓存命中率(百分数,一位小数):`cacheRead / (input + output + cacheRead)`。
@@ -187,6 +213,7 @@ export function summarizeSession(fold) {
187
213
  cacheHitRate: cacheHitRateOf(totals),
188
214
  firstTime: fold.firstTime,
189
215
  lastTime: fold.lastTime,
216
+ firstPrompt: fold.firstPrompt,
190
217
  primaryModelKey: models.length > 0 ? models[0].key : null,
191
218
  days,
192
219
  models,
package/lib/sessions.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * 服务缺失或读取失败时降级为空结果 + coverage 说明,绝不影响台账相关的其它端点。
7
7
  */
8
8
  import { attributeModelKey, foldSessionEvents, summarizeSession, } from './session-usage.js';
9
+ import { normalizeSessionId } from './session-meta.js';
9
10
  /**
10
11
  * 一次最多折叠的会话数:按更新时间倒序取,避免首次请求扫全量历史。
11
12
  * 本机会话约 120 个 / 143 MB,取 200 覆盖全量;折叠结果按 revision 缓存,
@@ -192,11 +193,15 @@ function attributedModels(models, knownProviders) {
192
193
  };
193
194
  });
194
195
  }
195
- export function sessionRowsOf(records, knownProviders) {
196
+ export function sessionRowsOf(records, knownProviders, meta = {}) {
197
+ const titles = meta.titles ?? new Map();
198
+ const archived = meta.archived ?? new Set();
196
199
  return records
197
200
  .map((record) => ({
198
201
  id: record.id,
199
- title: record.title,
202
+ // 标题优先级:宿主投影缓存的标题 → 会话头自带字段 → 首条用户提问 → 界面回落成短 id
203
+ title: titles.get(normalizeSessionId(record.id)) ?? record.title ?? record.summary.firstPrompt ?? null,
204
+ archived: archived.has(normalizeSessionId(record.id)),
200
205
  workspace: record.workspace,
201
206
  provider: record.summary.primaryModelKey === null ? null : attributeModelKey(record.summary.primaryModelKey, knownProviders).provider,
202
207
  firstTime: record.summary.firstTime,
@@ -209,7 +214,8 @@ export function sessionRowsOf(records, knownProviders) {
209
214
  }))
210
215
  .sort((first, second) => (second.lastTime ?? 0) - (first.lastTime ?? 0));
211
216
  }
212
- export function projectRowsOf(records, knownProviders) {
217
+ export function projectRowsOf(records, knownProviders, meta = {}) {
218
+ const archived = meta.archived ?? new Set();
213
219
  const byWorkspace = new Map();
214
220
  for (const record of records) {
215
221
  const workspace = record.workspace ?? '未归属工作区';
@@ -217,7 +223,7 @@ export function projectRowsOf(records, knownProviders) {
217
223
  if (accumulator === undefined) {
218
224
  accumulator = {
219
225
  workspace, calls: 0, tokens: 0, cacheReadTokens: 0, inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0,
220
- sessionCount: 0, lastTime: null, providers: new Map(), models: new Map(),
226
+ sessionCount: 0, archivedSessionCount: 0, lastTime: null, providers: new Map(), models: new Map(),
221
227
  };
222
228
  byWorkspace.set(workspace, accumulator);
223
229
  }
@@ -228,6 +234,8 @@ export function projectRowsOf(records, knownProviders) {
228
234
  accumulator.cacheReadTokens += record.summary.buckets.cacheReadTokens;
229
235
  accumulator.cacheWriteTokens += record.summary.buckets.cacheWriteTokens;
230
236
  accumulator.sessionCount += 1;
237
+ if (archived.has(normalizeSessionId(record.id)))
238
+ accumulator.archivedSessionCount += 1;
231
239
  const lastTime = record.summary.lastTime;
232
240
  if (lastTime !== null && (accumulator.lastTime === null || lastTime > accumulator.lastTime))
233
241
  accumulator.lastTime = lastTime;
@@ -259,6 +267,7 @@ export function projectRowsOf(records, knownProviders) {
259
267
  tokens: accumulator.tokens,
260
268
  cacheHitRate: displayTokens > 0 ? Math.round(accumulator.cacheReadTokens / displayTokens * 1000) / 10 : null,
261
269
  sessionCount: accumulator.sessionCount,
270
+ archivedSessionCount: accumulator.archivedSessionCount,
262
271
  lastTime: accumulator.lastTime,
263
272
  providers: Object.fromEntries(accumulator.providers),
264
273
  models: [...accumulator.models.values()].sort((first, second) => second.tokens - first.tokens),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-key-usage",
3
3
  "description": "DeepSeek Harness 侧边栏「使用情况」:按通道展示用量明细、活跃热力图、项目/会话用量与官方余额/配额(实时优先,失败回落快照)",
4
- "version": "0.1.1",
4
+ "version": "0.2.0",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "keywords": [
@@ -46,8 +46,8 @@
46
46
  },
47
47
  "scripts": {
48
48
  "build:client": "tsdown && node scripts/normalize-client-banner.mjs",
49
- "check": "node --check lib/index.js && node --check lib/ledger.js && node --check lib/collect.js && node --check lib/routes.js && node --check lib/sessions.js && node --check lib/session-usage.js && node --check lib/merge.js && node --check lib/live-quota.js",
50
- "test": "node test/ledger.test.mjs && node test/collect.test.mjs && node test/channels.test.mjs && node test/session-usage.test.mjs && node test/sessions.test.mjs && node test/merge.test.mjs && node test/live-quota.test.mjs && node test/routes.test.mjs"
49
+ "check": "node --check lib/index.js && node --check lib/ledger.js && node --check lib/collect.js && node --check lib/routes.js && node --check lib/sessions.js && node --check lib/session-usage.js && node --check lib/session-meta.js && node --check lib/merge.js && node --check lib/live-quota.js",
50
+ "test": "node test/ledger.test.mjs && node test/collect.test.mjs && node test/channels.test.mjs && node test/session-usage.test.mjs && node test/session-meta.test.mjs && node test/sessions.test.mjs && node test/merge.test.mjs && node test/live-quota.test.mjs && node test/routes.test.mjs"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "@deepseek-ai/cordis": "^4.0.1"