dsh-mobile-plugin 0.1.100 → 0.2.1
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 +14 -5
- package/dsh-mobile-credentials/lib/credentials.js +405 -84
- package/lib/client.js +31 -8
- package/lib/http/routing.js +165 -9
- package/lib/impl.js +88 -9
- package/lib/index.js +3 -2
- package/lib/mirror/pusher.cjs +35 -4
- package/lib/mobile/approvals.js +62 -15
- package/lib/mobile/auth.js +46 -26
- package/lib/mobile/credential-upgrade.js +137 -0
- package/lib/mobile/entry-window.js +32 -0
- package/lib/mobile/events.js +14 -4
- package/lib/mobile/extensions.js +279 -0
- package/lib/mobile/history-derive.js +209 -11
- package/lib/mobile/host-bridge/event-synthesis.js +61 -10
- package/lib/mobile/pairing.js +150 -64
- package/lib/mobile/plugin-self.js +187 -0
- package/lib/mobile/push-notify-route.js +37 -0
- package/lib/mobile/push-notify.js +84 -0
- package/lib/mobile/questions.js +75 -10
- package/lib/mobile/relay-control.js +547 -0
- package/lib/mobile/session-cache.js +60 -2
- package/lib/mobile/session-file.js +133 -34
- package/lib/mobile/session-list-shortcut.js +38 -0
- package/lib/mobile/session-model-options.js +3 -1
- package/lib/mobile/session-models.js +15 -3
- package/lib/mobile/session-send.js +30 -27
- package/lib/mobile/session-snapshot.js +12 -12
- package/lib/mobile/sessions.js +20 -0
- package/lib/mobile/stats-collect.cjs +55 -12
- package/lib/mobile/stats-ledger.cjs +140 -21
- package/lib/mobile/stream-registry.js +73 -0
- package/lib/mobile/stream.js +382 -31
- package/lib/mobile/subagents.js +11 -2
- package/lib/mobile/uploads.js +91 -5
- package/lib/mobile/workspaces.js +39 -8
- package/lib/panel/index.html +11 -11
- package/lib/relay/core.js +28 -2
- package/lib/relay/lan-network.cjs +47 -0
- package/lib/relay/lan-proxy.js +283 -122
- package/lib/relay/protocol.js +4 -2
- package/lib/relay/tunnel.js +20 -14
- package/lib/routes/control-plane.js +139 -9
- package/lib/routes/mobile-api.js +92 -9
- package/lib/shared/e2ee-pair.js +91 -0
- package/lib/shared/e2ee-plain.js +79 -0
- package/lib/shared/e2ee-request.js +331 -0
- package/lib/shared/e2ee.js +173 -0
- package/lib/shared/http-util.js +27 -4
- package/lib/shared/list-signal.js +16 -0
- package/lib/shared/pair-throttle.js +150 -0
- package/lib/shared/sse-write.js +16 -5
- package/lib/shared/via-header.js +65 -0
- package/lib/shell/runtime.js +147 -42
- package/lib/shell/self-check-after-swap.js +93 -0
- package/lib/shell/updater.js +44 -3
- package/lib/sidecar-facade-worker.cjs +70 -6
- package/lib/update-install.cjs +13 -5
- package/package.json +3 -2
|
@@ -36,6 +36,17 @@ const COLLECT_INTERVAL_MS = 60_000;
|
|
|
36
36
|
/** 并发读投影的条数:宿主那侧是内存里读缓存,四条并行不会把它压住。 */
|
|
37
37
|
const CONCURRENCY = 4;
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* 这一轮先扫「最近还在动」的会话,再按「最久没采过」补老账。
|
|
41
|
+
*
|
|
42
|
+
* 之前每一轮都只按最后活动时间倒序扫、1.5 秒到点就停,于是最老的那批永远排在队尾、
|
|
43
|
+
* 永远采不到(实测七十多条会话从没进过账本,其中大多是子代理)。现在把队列分成两段:
|
|
44
|
+
* 近 7 天有动作的照旧优先(它们才是数字在变的那批),其余的按 `collectedAt` 升序 ——
|
|
45
|
+
* 采过的会顺手把时间推到当下、自动排到队尾,于是每一轮的预算都接着上一轮的地方往下走,
|
|
46
|
+
* 不需要额外持久化一个游标。
|
|
47
|
+
*/
|
|
48
|
+
const RECENT_WINDOW_MS = 7 * 86400_000;
|
|
49
|
+
|
|
39
50
|
/**
|
|
40
51
|
* 一次采集的时间预算。
|
|
41
52
|
*
|
|
@@ -134,6 +145,25 @@ function hasDelta(usage) {
|
|
|
134
145
|
return Object.values(usage).some((value) => value > 0);
|
|
135
146
|
}
|
|
136
147
|
|
|
148
|
+
/**
|
|
149
|
+
* 采集串行化:同一时刻只跑一轮。
|
|
150
|
+
*
|
|
151
|
+
* 水位法在**两个请求同时进来**时会重复记账(先把同一份增量各记一遍,之后水位才对上,
|
|
152
|
+
* 多出来的量就永久留在按天账上)—— 打开统计页又立刻下拉刷新、或者清理已归档和打开页面
|
|
153
|
+
* 撞在一起,都能走到这条路上。串行而不是合并:清理前那次定向补采要的是它自己那几条,
|
|
154
|
+
* 直接复用统计页那一轮等于那几条根本没采。
|
|
155
|
+
*/
|
|
156
|
+
let tail = Promise.resolve();
|
|
157
|
+
|
|
158
|
+
function collectStats(ctx, options = {}) {
|
|
159
|
+
const run = tail.then(
|
|
160
|
+
() => collectOnce(ctx, options),
|
|
161
|
+
() => collectOnce(ctx, options),
|
|
162
|
+
);
|
|
163
|
+
tail = run.then(() => undefined, () => undefined);
|
|
164
|
+
return run;
|
|
165
|
+
}
|
|
166
|
+
|
|
137
167
|
/**
|
|
138
168
|
* 采一轮。
|
|
139
169
|
*
|
|
@@ -141,7 +171,7 @@ function hasDelta(usage) {
|
|
|
141
171
|
* `force` 跳过 60 秒的最小间隔(用户手动刷新时要真读一次);
|
|
142
172
|
* `skipPreview` 不读「最后一句」摘要(清理前补采用,见文件头)。
|
|
143
173
|
*/
|
|
144
|
-
async function
|
|
174
|
+
async function collectOnce(ctx, options = {}) {
|
|
145
175
|
const now = Date.now();
|
|
146
176
|
const budgetMs = Number.isFinite(options.budgetMs) && options.budgetMs > 0 ? options.budgetMs : DEFAULT_BUDGET_MS;
|
|
147
177
|
const deadline = now + budgetMs;
|
|
@@ -161,10 +191,20 @@ async function collectStats(ctx, options = {}) {
|
|
|
161
191
|
}
|
|
162
192
|
}
|
|
163
193
|
|
|
164
|
-
//
|
|
194
|
+
// 队列分两段:近 7 天有动作的优先(数字在变的就是它们),其余的按「最久没采过」排 ——
|
|
195
|
+
// 采完一轮会把 `collectedAt` 推到当下,自动沉到队尾,于是预算每次都接着上一轮往下走。
|
|
196
|
+
const recentSince = now - RECENT_WINDOW_MS;
|
|
165
197
|
const targets = items
|
|
166
198
|
.filter((item) => !wanted || wanted.has(item.sessionId))
|
|
167
|
-
.sort((a, b) =>
|
|
199
|
+
.sort((a, b) => {
|
|
200
|
+
const recentA = num(a.updatedAt) >= recentSince;
|
|
201
|
+
const recentB = num(b.updatedAt) >= recentSince;
|
|
202
|
+
if (recentA !== recentB) return recentA ? -1 : 1;
|
|
203
|
+
if (recentA) return num(b.updatedAt) - num(a.updatedAt);
|
|
204
|
+
const collectedA = book.sessions[a.sessionId] ? num(book.sessions[a.sessionId].collectedAt) : 0;
|
|
205
|
+
const collectedB = book.sessions[b.sessionId] ? num(book.sessions[b.sessionId].collectedAt) : 0;
|
|
206
|
+
return collectedA - collectedB;
|
|
207
|
+
});
|
|
168
208
|
let cursor = 0;
|
|
169
209
|
|
|
170
210
|
const worker = async () => {
|
|
@@ -228,15 +268,18 @@ async function collectStats(ctx, options = {}) {
|
|
|
228
268
|
saved.watermark = host;
|
|
229
269
|
saved.collectedAt = now;
|
|
230
270
|
saved.gone = false;
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
271
|
+
// 上下文构成是**这条会话**的快照:跟着记录走,页面才能说清「这是哪条会话的构成」。
|
|
272
|
+
// 以前全局只留一份、每轮被最后读完的那条覆盖(四条并发,谁后回是谁),
|
|
273
|
+
// 于是卡片上的构成跟用户此刻在用的那条毫无关系。
|
|
274
|
+
const breakdown = projections.contextBreakdown;
|
|
275
|
+
if (breakdown && typeof breakdown === 'object') {
|
|
276
|
+
saved.composition = {
|
|
277
|
+
systemTokens: num(breakdown.systemTokens),
|
|
278
|
+
toolsTokens: num(breakdown.toolsTokens),
|
|
279
|
+
messageTokens: num(breakdown.messageTokens),
|
|
280
|
+
at: now,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
240
283
|
}
|
|
241
284
|
}
|
|
242
285
|
};
|
|
@@ -42,6 +42,15 @@ const MAX_RETIRED = 2000;
|
|
|
42
42
|
/** 按天的账保留天数(约三年;一天一百多字节,超出的从最老的开始裁)。 */
|
|
43
43
|
const MAX_DAYS = 1100;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* 每条会话自己那份「按天用量」保留多少天。
|
|
47
|
+
*
|
|
48
|
+
* 它存在只为回答「这张卡片在这个范围里用了多少」:页面的三档范围最大 30 天,
|
|
49
|
+
* 再留一倍余量足够;更老的日期按天的总账仍然还在,只是卡片不再按那条会话拆得开。
|
|
50
|
+
* 不封顶的话,两百条会话 × 三年就是一份几十 MB 的 JSON,每次采集都要整份读写。
|
|
51
|
+
*/
|
|
52
|
+
const MAX_DAILY_DAYS = 62;
|
|
53
|
+
|
|
45
54
|
/**
|
|
46
55
|
* 热力图最多给多少天(52 周)。
|
|
47
56
|
*
|
|
@@ -125,6 +134,18 @@ function sanitizeUsage(source) {
|
|
|
125
134
|
return usage;
|
|
126
135
|
}
|
|
127
136
|
|
|
137
|
+
/** 上下文构成快照:三条都缺或全 0 就当没有(别在卡片上画一个 0 的环)。 */
|
|
138
|
+
function readComposition(source) {
|
|
139
|
+
if (!source || typeof source !== 'object') return null;
|
|
140
|
+
const composition = {
|
|
141
|
+
systemTokens: num(source.systemTokens),
|
|
142
|
+
toolsTokens: num(source.toolsTokens),
|
|
143
|
+
messageTokens: num(source.messageTokens),
|
|
144
|
+
};
|
|
145
|
+
if (composition.systemTokens + composition.toolsTokens + composition.messageTokens <= 0) return null;
|
|
146
|
+
return { ...composition, at: num(source.at) };
|
|
147
|
+
}
|
|
148
|
+
|
|
128
149
|
function sanitizeLedger(raw) {
|
|
129
150
|
if (!raw || typeof raw !== 'object') return emptyLedger();
|
|
130
151
|
if (raw.version !== VERSION) return emptyLedger();
|
|
@@ -141,6 +162,19 @@ function sanitizeLedger(raw) {
|
|
|
141
162
|
if (raw.sessions && typeof raw.sessions === 'object') {
|
|
142
163
|
for (const [sessionId, value] of Object.entries(raw.sessions)) {
|
|
143
164
|
if (!sessionId || !value || typeof value !== 'object') continue;
|
|
165
|
+
const daily = {};
|
|
166
|
+
if (value.daily && typeof value.daily === 'object') {
|
|
167
|
+
for (const [day, usageOfDay] of Object.entries(value.daily)) {
|
|
168
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !usageOfDay || typeof usageOfDay !== 'object') continue;
|
|
169
|
+
daily[day] = sanitizeUsage(usageOfDay);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const billed = sanitizeUsage(value.billed);
|
|
173
|
+
// 老账本(2026-10-24 之前)没有每条会话的按天用量:把它的历史量记在它最后一次
|
|
174
|
+
// 活动那天。这跟「首采回填」是同一条近似,也是一次性回填 —— 之后就只记增量。
|
|
175
|
+
if (Object.keys(daily).length === 0 && billedOf(billed) > 0) {
|
|
176
|
+
daily[dayKeyOf(num(value.lastAt) || Date.now())] = { ...billed };
|
|
177
|
+
}
|
|
144
178
|
ledger.sessions[sessionId] = {
|
|
145
179
|
title: typeof value.title === 'string' ? value.title : '',
|
|
146
180
|
workspaceName: typeof value.workspaceName === 'string' ? value.workspaceName : '',
|
|
@@ -152,7 +186,11 @@ function sanitizeLedger(raw) {
|
|
|
152
186
|
gone: value.gone === true,
|
|
153
187
|
// 退场 = 这条卡片已经不在最近 MAX_SESSIONS 里,只留水位与身份。
|
|
154
188
|
retired: value.retired === true,
|
|
155
|
-
billed
|
|
189
|
+
billed,
|
|
190
|
+
// 这条会话自己那份按天用量:卡片上的数字要跟着页面范围走(见 `rangeUsageOf`)。
|
|
191
|
+
daily,
|
|
192
|
+
// 最后一次观测到的上下文构成:跟着会话走,不再全局只留一份。
|
|
193
|
+
composition: readComposition(value.composition),
|
|
156
194
|
// 水位是「宿主那边这条会话的累计值」,缺省为 null 表示还没采过(首采全量入账)。
|
|
157
195
|
watermark: value.watermark && typeof value.watermark === 'object' ? sanitizeUsage(value.watermark) : null,
|
|
158
196
|
};
|
|
@@ -160,6 +198,8 @@ function sanitizeLedger(raw) {
|
|
|
160
198
|
}
|
|
161
199
|
|
|
162
200
|
if (raw.composition && typeof raw.composition === 'object') {
|
|
201
|
+
// 老账本只有一份全局构成(每轮被最后读完的那条覆盖)。留着当兜底:
|
|
202
|
+
// 没有任何一条会话带自己的构成时才用它。
|
|
163
203
|
ledger.composition = {
|
|
164
204
|
systemTokens: num(raw.composition.systemTokens),
|
|
165
205
|
toolsTokens: num(raw.composition.toolsTokens),
|
|
@@ -225,12 +265,40 @@ function trim(ledger) {
|
|
|
225
265
|
for (const id of pool.slice(MAX_RETIRED)) delete ledger.sessions[id];
|
|
226
266
|
}
|
|
227
267
|
}
|
|
268
|
+
// 每条会话自己那份按天用量也有上限:它只服务「这张卡片在这个范围里用了多少」,
|
|
269
|
+
// 最老的那些日子从它自己的账上裁掉,按天的总账不动。
|
|
270
|
+
for (const record of Object.values(ledger.sessions)) {
|
|
271
|
+
if (!record.daily) continue;
|
|
272
|
+
const own = Object.keys(record.daily).sort();
|
|
273
|
+
if (own.length > MAX_DAILY_DAYS) {
|
|
274
|
+
for (const day of own.slice(0, own.length - MAX_DAILY_DAYS)) delete record.daily[day];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
228
277
|
const days = Object.keys(ledger.days).sort();
|
|
229
278
|
if (days.length > MAX_DAYS) {
|
|
230
279
|
for (const day of days.slice(0, days.length - MAX_DAYS)) delete ledger.days[day];
|
|
231
280
|
}
|
|
232
281
|
}
|
|
233
282
|
|
|
283
|
+
/** 账本里一条会话记录的初始形状(`applyDelta` 与读盘兜底共用一份)。 */
|
|
284
|
+
function emptySession(at) {
|
|
285
|
+
return {
|
|
286
|
+
title: '',
|
|
287
|
+
workspaceName: '',
|
|
288
|
+
lastText: '',
|
|
289
|
+
cwd: '',
|
|
290
|
+
firstAt: at,
|
|
291
|
+
lastAt: 0,
|
|
292
|
+
collectedAt: 0,
|
|
293
|
+
gone: false,
|
|
294
|
+
retired: false,
|
|
295
|
+
billed: emptyUsage(),
|
|
296
|
+
daily: {},
|
|
297
|
+
composition: null,
|
|
298
|
+
watermark: null,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
234
302
|
/**
|
|
235
303
|
* 把一次采集到的增量记进账本(纯函数,原地改并返回它)。
|
|
236
304
|
*
|
|
@@ -250,20 +318,12 @@ function applyDelta(ledger, entry) {
|
|
|
250
318
|
// 与 dsh-context 的 requests 口径差不到一条结算的距离。
|
|
251
319
|
day.requests += delta.steps;
|
|
252
320
|
|
|
253
|
-
const record = ledger.sessions[sessionId] || (ledger.sessions[sessionId] =
|
|
254
|
-
title: '',
|
|
255
|
-
workspaceName: '',
|
|
256
|
-
lastText: '',
|
|
257
|
-
cwd: '',
|
|
258
|
-
firstAt: at,
|
|
259
|
-
lastAt: 0,
|
|
260
|
-
collectedAt: 0,
|
|
261
|
-
gone: false,
|
|
262
|
-
retired: false,
|
|
263
|
-
billed: emptyUsage(),
|
|
264
|
-
watermark: null,
|
|
265
|
-
});
|
|
321
|
+
const record = ledger.sessions[sessionId] || (ledger.sessions[sessionId] = emptySession(at));
|
|
266
322
|
for (const key of USAGE_KEYS) record.billed[key] += delta[key];
|
|
323
|
+
// 这条会话自己那份按天用量:卡片的数字要跟页面范围走,所以增量在「记哪一天」这件事上
|
|
324
|
+
// 必须和按天的总账记在同一个日期上(首采回填也是同一天)。
|
|
325
|
+
const ownDay = record.daily[date] || (record.daily[date] = emptyUsage());
|
|
326
|
+
for (const key of USAGE_KEYS) ownDay[key] += delta[key];
|
|
267
327
|
record.lastAt = Math.max(record.lastAt, at);
|
|
268
328
|
if (!record.firstAt) record.firstAt = at;
|
|
269
329
|
// 退场的卡片又有了新用量:回到列表里来(水位一直在,所以这次只记增量)。
|
|
@@ -283,11 +343,33 @@ function billedOf(usage) {
|
|
|
283
343
|
return num(usage.uncachedInputTokens) + num(usage.cacheReadTokens) + num(usage.cacheWriteTokens) + num(usage.outputTokens);
|
|
284
344
|
}
|
|
285
345
|
|
|
346
|
+
/**
|
|
347
|
+
* 一条会话在 [startDay, today] 里的用量。
|
|
348
|
+
*
|
|
349
|
+
* `startDay` 为 null(`all`)时直接用记录里的累计值 —— 累计值是权威,而它那份按天用量
|
|
350
|
+
* 只保留最近 `MAX_DAILY_DAYS` 天。
|
|
351
|
+
*/
|
|
352
|
+
function usageInRange(record, startDay, today) {
|
|
353
|
+
const billed = record.billed || emptyUsage();
|
|
354
|
+
if (!startDay) return sanitizeUsage(billed);
|
|
355
|
+
const usage = emptyUsage();
|
|
356
|
+
const daily = record.daily || {};
|
|
357
|
+
for (const [day, value] of Object.entries(daily)) {
|
|
358
|
+
if (day < startDay || day > today) continue;
|
|
359
|
+
for (const key of USAGE_KEYS) usage[key] += num(value[key]);
|
|
360
|
+
}
|
|
361
|
+
return usage;
|
|
362
|
+
}
|
|
363
|
+
|
|
286
364
|
/**
|
|
287
365
|
* 按范围折算成 App 要的总览(纯函数)。
|
|
288
366
|
*
|
|
289
367
|
* 逐日序列会把范围内**每一天都列出来**(没有活动的补 0):柱状图靠它保持等距,
|
|
290
368
|
* 由页面决定怎么省略日期标签。
|
|
369
|
+
*
|
|
370
|
+
* 三块口径必须对得上(2026-10-24 改):`kpi` 是范围内总量,`sessions` 里的每张卡片是
|
|
371
|
+
* **这条会话在同一个范围里**的用量,`retired` / `untracked` 把不在卡片上的那部分明说出来 ——
|
|
372
|
+
* 卡片加起来永远解释不了总量,是这个页面被读成「数据对不上」的根源。
|
|
291
373
|
*/
|
|
292
374
|
function summarize(ledger, range, now) {
|
|
293
375
|
const today = dayKeyOf(now);
|
|
@@ -336,6 +418,7 @@ function summarize(ledger, range, now) {
|
|
|
336
418
|
if (point.requests > 0 || point.turns > 0) kpi.days += 1;
|
|
337
419
|
}
|
|
338
420
|
kpi.activeMs = kpi.llmMs + kpi.toolMs;
|
|
421
|
+
const rangeBilled = billedOf(kpi);
|
|
339
422
|
|
|
340
423
|
// 热力图给足最近 52 周(364 天):手机上只画得下 8 周,iPad 横屏能摊到三十周上下 ——
|
|
341
424
|
// 窗口由页面按自己的宽度截最后几周,宿主这边一律给满,否则宽屏想多画也没有数据。
|
|
@@ -346,20 +429,42 @@ function summarize(ledger, range, now) {
|
|
|
346
429
|
heatmap.push({ day, tokens: source ? billedOf(source) : 0 });
|
|
347
430
|
}
|
|
348
431
|
|
|
349
|
-
const rangeStartAt = startDay ? new Date(`${startDay}T00:00:00`).getTime() : 0;
|
|
350
432
|
const sessions = [];
|
|
433
|
+
let retiredSessions = 0;
|
|
434
|
+
let retiredBilled = 0;
|
|
435
|
+
// 构成快照取「最近有动作的那条会话」:每轮只在不是同一条时换,不再被并发读完的最后一条覆盖。
|
|
436
|
+
let composition = null;
|
|
437
|
+
let compositionSession = null;
|
|
351
438
|
for (const [sessionId, record] of Object.entries(ledger.sessions)) {
|
|
352
|
-
|
|
353
|
-
if (record.retired === true)
|
|
354
|
-
|
|
439
|
+
const usage = usageInRange(record, startDay, today);
|
|
440
|
+
if (record.retired === true) {
|
|
441
|
+
// 退场记录只留水位,不上卡片:它的量早就记在按天的账上了。范围里有量时在汇总行报个数。
|
|
442
|
+
const billed = billedOf(usage);
|
|
443
|
+
if (billed > 0) {
|
|
444
|
+
retiredSessions += 1;
|
|
445
|
+
retiredBilled += billed;
|
|
446
|
+
}
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (record.composition && num(record.lastAt) > num(compositionSession && compositionSession.lastAt)) {
|
|
450
|
+
composition = {
|
|
451
|
+
systemTokens: num(record.composition.systemTokens),
|
|
452
|
+
toolsTokens: num(record.composition.toolsTokens),
|
|
453
|
+
messageTokens: num(record.composition.messageTokens),
|
|
454
|
+
};
|
|
455
|
+
compositionSession = { lastAt: num(record.lastAt), sessionId, title: record.title || '' };
|
|
456
|
+
}
|
|
457
|
+
const billedTokens = billedOf(usage);
|
|
458
|
+
// 范围内一个 token、一轮都没有的会话不上卡片:切到「近 7 天」时老会话不该占着位置。
|
|
459
|
+
if (billedTokens === 0 && num(usage.turns) === 0) continue;
|
|
355
460
|
sessions.push({
|
|
356
461
|
sessionId,
|
|
357
462
|
title: record.title || '',
|
|
358
463
|
...(record.workspaceName ? { workspaceName: record.workspaceName } : {}),
|
|
359
464
|
...(record.cwd ? { cwd: record.cwd } : {}),
|
|
360
465
|
...(record.lastText ? { lastText: record.lastText } : {}),
|
|
361
|
-
billedTokens
|
|
362
|
-
turns: num(
|
|
466
|
+
billedTokens,
|
|
467
|
+
turns: num(usage.turns),
|
|
363
468
|
lastAt: num(record.lastAt),
|
|
364
469
|
...(record.gone ? { gone: true } : {}),
|
|
365
470
|
});
|
|
@@ -373,13 +478,25 @@ function summarize(ledger, range, now) {
|
|
|
373
478
|
});
|
|
374
479
|
kpi.sessions = sessions.length;
|
|
375
480
|
|
|
481
|
+
// 卡片 + 退场合计之后仍然少的那一截:老版本把整条记录删掉过(水位与明细都没了),
|
|
482
|
+
// 按天的账上却还留着那些天。明说出来,别让用户自己拿计算器发现差额。
|
|
483
|
+
// 只报量、不报条数 —— 那些会话连记录都没了,条数是编不出来的。
|
|
484
|
+
const tracked = sessions.reduce((sum, row) => sum + row.billedTokens, 0) + retiredBilled;
|
|
485
|
+
const untrackedBilled = Math.max(0, rangeBilled - tracked);
|
|
486
|
+
|
|
487
|
+
// 没有任何一条会话带自己的构成时(升级前的老账本),退回那份全局快照。
|
|
488
|
+
if (!composition && ledger.composition) composition = { ...ledger.composition };
|
|
489
|
+
|
|
376
490
|
return {
|
|
377
491
|
generatedAt: now,
|
|
378
492
|
range,
|
|
379
493
|
kpi,
|
|
380
494
|
days,
|
|
381
495
|
heatmap,
|
|
382
|
-
...(
|
|
496
|
+
...(composition ? { composition } : {}),
|
|
497
|
+
...(compositionSession ? { compositionSessionId: compositionSession.sessionId } : {}),
|
|
498
|
+
retired: { sessions: retiredSessions, tokens: retiredBilled },
|
|
499
|
+
untrackedBilled,
|
|
383
500
|
sessions,
|
|
384
501
|
};
|
|
385
502
|
}
|
|
@@ -389,6 +506,7 @@ module.exports = {
|
|
|
389
506
|
MAX_SESSIONS,
|
|
390
507
|
MAX_RETIRED,
|
|
391
508
|
MAX_DAYS,
|
|
509
|
+
MAX_DAILY_DAYS,
|
|
392
510
|
HEATMAP_DAYS,
|
|
393
511
|
LAST_TEXT_MAX,
|
|
394
512
|
stateFile,
|
|
@@ -403,5 +521,6 @@ module.exports = {
|
|
|
403
521
|
startDayOf,
|
|
404
522
|
emptyUsage,
|
|
405
523
|
billedOf,
|
|
524
|
+
usageInRange,
|
|
406
525
|
num,
|
|
407
526
|
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 按设备登记的 SSE 流。
|
|
5
|
+
*
|
|
6
|
+
* token 换了只保证**下一次请求**被拒;一条已经开着的流不会自己断,被踢的手机还能继续收
|
|
7
|
+
* 消息,直到它自己重连。不做这一步,「单台吊销」就只完成了一半。
|
|
8
|
+
*
|
|
9
|
+
* 登记表按 deviceId 分组:同一台手机可能同时开着两条流(会话流 + 设备级事件流),
|
|
10
|
+
* 两台手机之间互不影响。收流由吊销那条路径调 `drop`,逐条 `end()` 掉。
|
|
11
|
+
*
|
|
12
|
+
* 这里**不做超时与配额**:宿主本来就是给自己那几台设备推东西,流的上限由它们自己的
|
|
13
|
+
* 打开次数决定,加一层闸只会把「手机连着却收不到」变成一种新的失败形态。
|
|
14
|
+
*/
|
|
15
|
+
const streams = new Map();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 登记一条流。
|
|
19
|
+
*
|
|
20
|
+
* @param {string} deviceId 设备 id(凭据记录上的 `deviceId`)
|
|
21
|
+
* @param {object} res 这条流的响应对象(只需要 `end` 与 `destroy`)
|
|
22
|
+
* @returns {() => void} 注销函数;流自己关掉时调用,避免表越攒越大
|
|
23
|
+
*/
|
|
24
|
+
function track(deviceId, res) {
|
|
25
|
+
const key = typeof deviceId === 'string' ? deviceId : '';
|
|
26
|
+
if (!key || !res) return () => {};
|
|
27
|
+
let set = streams.get(key);
|
|
28
|
+
if (!set) {
|
|
29
|
+
set = new Set();
|
|
30
|
+
streams.set(key, set);
|
|
31
|
+
}
|
|
32
|
+
set.add(res);
|
|
33
|
+
return () => {
|
|
34
|
+
const current = streams.get(key);
|
|
35
|
+
if (!current) return;
|
|
36
|
+
current.delete(res);
|
|
37
|
+
if (current.size === 0) streams.delete(key);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 收掉这台设备上所有已建立的流。
|
|
43
|
+
*
|
|
44
|
+
* 先 `end()` 再 `destroy()`:`end` 让对端看到正常的流结束(App 会在下一次请求上收到
|
|
45
|
+
* `device-revoked`,而不是一个「网络出错」);对已经卡住的连接 `destroy` 是兜底。
|
|
46
|
+
*
|
|
47
|
+
* @returns {number} 实际收掉了多少条
|
|
48
|
+
*/
|
|
49
|
+
function drop(deviceId) {
|
|
50
|
+
const key = typeof deviceId === 'string' ? deviceId : '';
|
|
51
|
+
const set = streams.get(key);
|
|
52
|
+
if (!set) return 0;
|
|
53
|
+
streams.delete(key);
|
|
54
|
+
let closed = 0;
|
|
55
|
+
for (const res of set) {
|
|
56
|
+
closed += 1;
|
|
57
|
+
try { res.end?.(); } catch { /* 对端已经走了 */ }
|
|
58
|
+
try { res.destroy?.(); } catch { /* 同上 */ }
|
|
59
|
+
}
|
|
60
|
+
return closed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 测试与自检用:当前登记了多少条流。 */
|
|
64
|
+
function size() {
|
|
65
|
+
let total = 0;
|
|
66
|
+
for (const set of streams.values()) total += set.size;
|
|
67
|
+
return total;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 测试用:清空登记表。 */
|
|
71
|
+
function reset() { streams.clear(); }
|
|
72
|
+
|
|
73
|
+
module.exports = { track, drop, size, reset };
|