dsh-mobile-plugin 0.1.96 → 0.1.98
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 +73 -50
- package/config/agent-presets/standard/agent.cordis.yml +13 -0
- package/lib/git/exec.js +129 -0
- package/lib/git/package.json +3 -0
- package/lib/git/parse.js +247 -0
- package/lib/git/read.js +617 -0
- package/lib/index.js +23 -29
- package/lib/mobile/archive-clean.js +31 -1
- package/lib/mobile/channels.js +583 -0
- package/lib/mobile/events.js +10 -8
- package/lib/mobile/git-repos.js +210 -0
- package/lib/mobile/git.js +603 -0
- package/lib/mobile/history-derive.js +79 -1
- package/lib/mobile/host-bridge/endpoints.js +3 -0
- package/lib/mobile/host-bridge/event-synthesis.js +109 -1
- package/lib/mobile/host-bridge/shims.js +9 -2
- package/lib/mobile/host-settings.js +41 -0
- package/lib/mobile/inbox-clean.js +337 -0
- package/lib/mobile/pairing.js +18 -1
- package/lib/mobile/questions.js +31 -9
- package/lib/mobile/schedule.js +440 -0
- package/lib/mobile/session-cache.js +18 -0
- package/lib/mobile/session-file.js +192 -0
- package/lib/mobile/session-model-options.js +184 -0
- package/lib/mobile/session-models.js +24 -2
- package/lib/mobile/session-schedule-mark.js +70 -0
- package/lib/mobile/session-snapshot.js +12 -1
- package/lib/mobile/sessions.js +8 -0
- package/lib/mobile/stats-collect.cjs +257 -0
- package/lib/mobile/stats-ledger.cjs +407 -0
- package/lib/mobile/stats.js +38 -0
- package/lib/mobile/stream.js +18 -10
- package/lib/mobile/uploads.js +24 -6
- package/lib/mobile/workspaces.js +30 -6
- package/lib/panel/index.html +4 -4
- package/lib/relay/lan-proxy.js +10 -1
- package/lib/routes/control-plane.js +38 -0
- package/lib/routes/mobile-api.js +70 -1
- package/lib/session-cleanup.cjs +8 -0
- package/lib/shared/sse-write.js +17 -6
- package/lib/shell/device-ota.js +52 -2
- package/lib/shell/manager.js +15 -1
- package/lib/shell/updater.js +153 -6
- package/lib/update-install.cjs +11 -0
- package/package.json +3 -2
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 统计账本:按天累计的用量 + 每条会话的累计与身份(`$DSH_HOME/mobile-stats.json`)。
|
|
5
|
+
*
|
|
6
|
+
* 为什么要有这一份、以及它为什么不寄在会话日志上:用户会归档会话、也会清理已归档的
|
|
7
|
+
* 会话,清理删的是日志本身。统计如果每次现算,清理之后那部分用量就凭空消失(还常常
|
|
8
|
+
* 表现为「今天的使用量突然掉了一半」)。所以采集一旦读到增量就落进这个文件,之后会话
|
|
9
|
+
* 在不在都不影响历史数字 —— 那张会话卡片只会被标成「已清理」。
|
|
10
|
+
*
|
|
11
|
+
* 口径(与桌面插件 dsh-context 的每日账本一致,但只保留手机用得着的部分):
|
|
12
|
+
* - 一天的账落在那**一天完成观测**的日期上(宿主本地日期,`YYYY-MM-DD`);
|
|
13
|
+
* - 计费 token = 未命中输入 + 缓存读 + 缓存写 + 输出,四个桶分开存,合并多台设备时
|
|
14
|
+
* 才可能按同一口径重算计费量与命中率;
|
|
15
|
+
* - 会话卡片保留最近 `MAX_SESSIONS` 条(按最后活动时间),更老的会话从卡片里消失,
|
|
16
|
+
* 但它们的量**仍然留在按天的账上**(总量不会因为裁剪而缩水)。卡片退场只是不再展示
|
|
17
|
+
* (`retired`),水位留着 —— 水位一丢,这条会话下次再有动作就会被当成首采、把整段
|
|
18
|
+
* 历史重记一遍,按天的账凭空多出一截。
|
|
19
|
+
*
|
|
20
|
+
* 这个文件只做纯数据(读写 + 折算),不碰网络;采集在 stats-collect.cjs,路由在 stats.js。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const paths = require('../shared/paths');
|
|
27
|
+
|
|
28
|
+
/** 账本格式版本:将来改结构时据此决定是丢弃还是迁移。 */
|
|
29
|
+
const VERSION = 1;
|
|
30
|
+
|
|
31
|
+
/** 会话卡片的条数上限(按最后活动时间保留最近的)。 */
|
|
32
|
+
const MAX_SESSIONS = 200;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 退场卡片(已不在最近 200 条里、只留水位与身份)的条数上限。
|
|
36
|
+
*
|
|
37
|
+
* 退场记录不展示,存在只为「它再活过来时别重记一遍历史」;每条一百来字节,
|
|
38
|
+
* 留这么多足够覆盖一台机器几年的会话,再老的真删掉。
|
|
39
|
+
*/
|
|
40
|
+
const MAX_RETIRED = 2000;
|
|
41
|
+
|
|
42
|
+
/** 按天的账保留天数(约三年;一天一百多字节,超出的从最老的开始裁)。 */
|
|
43
|
+
const MAX_DAYS = 1100;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 热力图最多给多少天(52 周)。
|
|
47
|
+
*
|
|
48
|
+
* 页面按自己量到的宽度决定画几周(手机 8 周、iPad 横屏三十周上下),宿主这边一律给满 ——
|
|
49
|
+
* 只给 56 天的话宽屏想多画也没有数据;只给 26 周则 iPad 那张卡摊到 26 周之后右边还剩
|
|
50
|
+
* 一格多的空白,白改一场。
|
|
51
|
+
*/
|
|
52
|
+
const HEATMAP_DAYS = 52 * 7;
|
|
53
|
+
|
|
54
|
+
/** 每张会话卡片上留的最后一条消息摘要长度:卡片一行装得下的大致字数。 */
|
|
55
|
+
const LAST_TEXT_MAX = 120;
|
|
56
|
+
|
|
57
|
+
/** 用量字段:四个 token 桶 + 轮次/步数/耗时。顺序即写盘顺序,便于人工比对。 */
|
|
58
|
+
const USAGE_KEYS = [
|
|
59
|
+
'uncachedInputTokens',
|
|
60
|
+
'cacheReadTokens',
|
|
61
|
+
'cacheWriteTokens',
|
|
62
|
+
'outputTokens',
|
|
63
|
+
'turns',
|
|
64
|
+
'steps',
|
|
65
|
+
'llmMs',
|
|
66
|
+
'toolMs',
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function emptyUsage() {
|
|
70
|
+
const usage = {};
|
|
71
|
+
for (const key of USAGE_KEYS) usage[key] = 0;
|
|
72
|
+
return usage;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function emptyDay() {
|
|
76
|
+
return { ...emptyUsage(), requests: 0 };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function emptyLedger() {
|
|
80
|
+
return { version: VERSION, updatedAt: 0, days: {}, sessions: {}, composition: null };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 账本文件:`$DSH_HOME/mobile-stats.json`(DSH_HOME 在调用时解析,便于测试注入)。 */
|
|
84
|
+
function stateFile() {
|
|
85
|
+
return path.join(paths.dshHome(), 'mobile-stats.json');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 内存缓存;null = 还没读过盘。 */
|
|
89
|
+
let cached = null;
|
|
90
|
+
|
|
91
|
+
function num(value) {
|
|
92
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 宿主本地日期 `YYYY-MM-DD`。 */
|
|
96
|
+
function dayKeyOf(at) {
|
|
97
|
+
const date = new Date(at);
|
|
98
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
99
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
100
|
+
return `${date.getFullYear()}-${month}-${day}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 日期字符串按日历加减(用 UTC 解析/格式化,避开夏令时与本地时区偏移)。 */
|
|
104
|
+
function addDays(day, delta) {
|
|
105
|
+
const [year, month, date] = day.split('-').map((part) => Number(part));
|
|
106
|
+
const base = Date.UTC(year, month - 1, date);
|
|
107
|
+
const shifted = new Date(base + delta * 86400000);
|
|
108
|
+
const shiftedMonth = String(shifted.getUTCMonth() + 1).padStart(2, '0');
|
|
109
|
+
const shiftedDay = String(shifted.getUTCDate()).padStart(2, '0');
|
|
110
|
+
return `${shifted.getUTCFullYear()}-${shiftedMonth}-${shiftedDay}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 范围起点(含);`all` 返回 null,表示不设下限。 */
|
|
114
|
+
function startDayOf(range, now) {
|
|
115
|
+
const today = dayKeyOf(now);
|
|
116
|
+
if (range === '7d') return addDays(today, -6);
|
|
117
|
+
if (range === '30d') return addDays(today, -29);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function sanitizeUsage(source) {
|
|
122
|
+
const usage = emptyUsage();
|
|
123
|
+
if (!source || typeof source !== 'object') return usage;
|
|
124
|
+
for (const key of USAGE_KEYS) usage[key] = num(source[key]);
|
|
125
|
+
return usage;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sanitizeLedger(raw) {
|
|
129
|
+
if (!raw || typeof raw !== 'object') return emptyLedger();
|
|
130
|
+
if (raw.version !== VERSION) return emptyLedger();
|
|
131
|
+
const ledger = emptyLedger();
|
|
132
|
+
ledger.updatedAt = num(raw.updatedAt);
|
|
133
|
+
|
|
134
|
+
if (raw.days && typeof raw.days === 'object') {
|
|
135
|
+
for (const [day, value] of Object.entries(raw.days)) {
|
|
136
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !value || typeof value !== 'object') continue;
|
|
137
|
+
ledger.days[day] = { ...sanitizeUsage(value), requests: num(value.requests) };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (raw.sessions && typeof raw.sessions === 'object') {
|
|
142
|
+
for (const [sessionId, value] of Object.entries(raw.sessions)) {
|
|
143
|
+
if (!sessionId || !value || typeof value !== 'object') continue;
|
|
144
|
+
ledger.sessions[sessionId] = {
|
|
145
|
+
title: typeof value.title === 'string' ? value.title : '',
|
|
146
|
+
workspaceName: typeof value.workspaceName === 'string' ? value.workspaceName : '',
|
|
147
|
+
lastText: typeof value.lastText === 'string' ? value.lastText.slice(0, LAST_TEXT_MAX) : '',
|
|
148
|
+
cwd: typeof value.cwd === 'string' ? value.cwd : '',
|
|
149
|
+
firstAt: num(value.firstAt),
|
|
150
|
+
lastAt: num(value.lastAt),
|
|
151
|
+
collectedAt: num(value.collectedAt),
|
|
152
|
+
gone: value.gone === true,
|
|
153
|
+
// 退场 = 这条卡片已经不在最近 MAX_SESSIONS 里,只留水位与身份。
|
|
154
|
+
retired: value.retired === true,
|
|
155
|
+
billed: sanitizeUsage(value.billed),
|
|
156
|
+
// 水位是「宿主那边这条会话的累计值」,缺省为 null 表示还没采过(首采全量入账)。
|
|
157
|
+
watermark: value.watermark && typeof value.watermark === 'object' ? sanitizeUsage(value.watermark) : null,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (raw.composition && typeof raw.composition === 'object') {
|
|
163
|
+
ledger.composition = {
|
|
164
|
+
systemTokens: num(raw.composition.systemTokens),
|
|
165
|
+
toolsTokens: num(raw.composition.toolsTokens),
|
|
166
|
+
messageTokens: num(raw.composition.messageTokens),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return ledger;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 读账本(首次读盘,之后走内存;坏文件当空账本,不阻断统计页)。 */
|
|
173
|
+
function readLedger() {
|
|
174
|
+
if (cached !== null) return cached;
|
|
175
|
+
let raw = null;
|
|
176
|
+
try {
|
|
177
|
+
raw = JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
|
|
178
|
+
} catch {
|
|
179
|
+
raw = null;
|
|
180
|
+
}
|
|
181
|
+
cached = sanitizeLedger(raw);
|
|
182
|
+
return cached;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 原子写:先写临时文件再 rename,写一半断电也不会留下半份 JSON。 */
|
|
186
|
+
function saveLedger(ledger) {
|
|
187
|
+
cached = ledger;
|
|
188
|
+
ledger.updatedAt = Date.now();
|
|
189
|
+
const file = stateFile();
|
|
190
|
+
try {
|
|
191
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
192
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
193
|
+
fs.writeFileSync(tmp, JSON.stringify(ledger), { mode: 0o600 });
|
|
194
|
+
fs.renameSync(tmp, file);
|
|
195
|
+
} catch {
|
|
196
|
+
// 落盘失败只影响「重启后还在不在」:内存里这份仍然对当前进程有效,统计页照常能看。
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 测试与「换个 DSH_HOME 重读」用。 */
|
|
201
|
+
function forgetLedger() {
|
|
202
|
+
cached = null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 裁剪:会话卡片按最后活动时间保留最近的。
|
|
207
|
+
*
|
|
208
|
+
* 超出的卡片**不删记录、只标退场**(`retired`):水位与身份留着,那条会话以后再活跃
|
|
209
|
+
* 就不会被当成首采、把整段历史重记一遍。退场记录自己也封顶,最老的先删。
|
|
210
|
+
*/
|
|
211
|
+
function trim(ledger) {
|
|
212
|
+
const sessionIds = Object.keys(ledger.sessions);
|
|
213
|
+
if (sessionIds.length > MAX_SESSIONS) {
|
|
214
|
+
// 已经在退场里的不参与排位:它们本来就该往后排,重新算一遍只会来回抖动。
|
|
215
|
+
const live = sessionIds.filter((id) => ledger.sessions[id].retired !== true);
|
|
216
|
+
const retired = sessionIds.filter((id) => ledger.sessions[id].retired === true);
|
|
217
|
+
if (live.length > MAX_SESSIONS) {
|
|
218
|
+
live.sort((a, b) => num(ledger.sessions[b].lastAt) - num(ledger.sessions[a].lastAt));
|
|
219
|
+
for (const id of live.slice(MAX_SESSIONS)) ledger.sessions[id].retired = true;
|
|
220
|
+
}
|
|
221
|
+
// 退场池按最后活动时间留最新的那一批,其余真删。
|
|
222
|
+
const pool = [...retired, ...live.slice(MAX_SESSIONS)];
|
|
223
|
+
if (pool.length > MAX_RETIRED) {
|
|
224
|
+
pool.sort((a, b) => num(ledger.sessions[b].lastAt) - num(ledger.sessions[a].lastAt));
|
|
225
|
+
for (const id of pool.slice(MAX_RETIRED)) delete ledger.sessions[id];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const days = Object.keys(ledger.days).sort();
|
|
229
|
+
if (days.length > MAX_DAYS) {
|
|
230
|
+
for (const day of days.slice(0, days.length - MAX_DAYS)) delete ledger.days[day];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 把一次采集到的增量记进账本(纯函数,原地改并返回它)。
|
|
236
|
+
*
|
|
237
|
+
* `entry.date` 只在「首采回填」时传:一条会话第一次被采到时,它历史上有多少量我们都
|
|
238
|
+
* 不知道发生在哪天,只能记到它最后一次活动的那天 —— 记到今天会让今天凭空多出一大截,
|
|
239
|
+
* 记到创建那天又会让所有历史堆在最早一天。两者都是近似,取「最后活动日」更接近直觉。
|
|
240
|
+
*/
|
|
241
|
+
function applyDelta(ledger, entry) {
|
|
242
|
+
const sessionId = entry.sessionId;
|
|
243
|
+
const delta = sanitizeUsage(entry.delta);
|
|
244
|
+
const at = num(entry.at) || Date.now();
|
|
245
|
+
const date = entry.date || dayKeyOf(at);
|
|
246
|
+
|
|
247
|
+
const day = ledger.days[date] || (ledger.days[date] = emptyDay());
|
|
248
|
+
for (const key of USAGE_KEYS) day[key] += delta[key];
|
|
249
|
+
// 模型请求数按「步」的增量记:宿主没给独立的请求计数字段,而一步对应一次请求,
|
|
250
|
+
// 与 dsh-context 的 requests 口径差不到一条结算的距离。
|
|
251
|
+
day.requests += delta.steps;
|
|
252
|
+
|
|
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
|
+
});
|
|
266
|
+
for (const key of USAGE_KEYS) record.billed[key] += delta[key];
|
|
267
|
+
record.lastAt = Math.max(record.lastAt, at);
|
|
268
|
+
if (!record.firstAt) record.firstAt = at;
|
|
269
|
+
// 退场的卡片又有了新用量:回到列表里来(水位一直在,所以这次只记增量)。
|
|
270
|
+
record.retired = false;
|
|
271
|
+
if (entry.title) record.title = entry.title;
|
|
272
|
+
if (entry.cwd) record.cwd = entry.cwd;
|
|
273
|
+
if (entry.workspaceName) record.workspaceName = entry.workspaceName;
|
|
274
|
+
// 最后一句只留一行的长度:卡片底那行是给人认「这是哪条会话」的,不是正文存档。
|
|
275
|
+
if (typeof entry.lastText === 'string' && entry.lastText) {
|
|
276
|
+
record.lastText = entry.lastText.slice(0, LAST_TEXT_MAX);
|
|
277
|
+
}
|
|
278
|
+
return ledger;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** 会话卡片上的计费量:四个桶之和。 */
|
|
282
|
+
function billedOf(usage) {
|
|
283
|
+
return num(usage.uncachedInputTokens) + num(usage.cacheReadTokens) + num(usage.cacheWriteTokens) + num(usage.outputTokens);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 按范围折算成 App 要的总览(纯函数)。
|
|
288
|
+
*
|
|
289
|
+
* 逐日序列会把范围内**每一天都列出来**(没有活动的补 0):柱状图靠它保持等距,
|
|
290
|
+
* 由页面决定怎么省略日期标签。
|
|
291
|
+
*/
|
|
292
|
+
function summarize(ledger, range, now) {
|
|
293
|
+
const today = dayKeyOf(now);
|
|
294
|
+
const startDay = startDayOf(range, now);
|
|
295
|
+
const allDays = Object.keys(ledger.days).sort();
|
|
296
|
+
const firstDay = startDay || allDays[0] || today;
|
|
297
|
+
|
|
298
|
+
const days = [];
|
|
299
|
+
for (let cursor = firstDay; ; cursor = addDays(cursor, 1)) {
|
|
300
|
+
const source = ledger.days[cursor] || emptyDay();
|
|
301
|
+
days.push({
|
|
302
|
+
day: cursor,
|
|
303
|
+
uncachedInputTokens: num(source.uncachedInputTokens),
|
|
304
|
+
cacheReadTokens: num(source.cacheReadTokens),
|
|
305
|
+
cacheWriteTokens: num(source.cacheWriteTokens),
|
|
306
|
+
outputTokens: num(source.outputTokens),
|
|
307
|
+
requests: num(source.requests),
|
|
308
|
+
turns: num(source.turns),
|
|
309
|
+
});
|
|
310
|
+
if (cursor >= today) break;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const kpi = {
|
|
314
|
+
sessions: 0,
|
|
315
|
+
uncachedInputTokens: 0,
|
|
316
|
+
cacheReadTokens: 0,
|
|
317
|
+
cacheWriteTokens: 0,
|
|
318
|
+
outputTokens: 0,
|
|
319
|
+
turns: 0,
|
|
320
|
+
steps: 0,
|
|
321
|
+
llmMs: 0,
|
|
322
|
+
toolMs: 0,
|
|
323
|
+
activeMs: 0,
|
|
324
|
+
days: 0,
|
|
325
|
+
};
|
|
326
|
+
for (const point of days) {
|
|
327
|
+
const source = ledger.days[point.day] || emptyDay();
|
|
328
|
+
kpi.uncachedInputTokens += num(source.uncachedInputTokens);
|
|
329
|
+
kpi.cacheReadTokens += num(source.cacheReadTokens);
|
|
330
|
+
kpi.cacheWriteTokens += num(source.cacheWriteTokens);
|
|
331
|
+
kpi.outputTokens += num(source.outputTokens);
|
|
332
|
+
kpi.turns += num(source.turns);
|
|
333
|
+
kpi.steps += num(source.steps);
|
|
334
|
+
kpi.llmMs += num(source.llmMs);
|
|
335
|
+
kpi.toolMs += num(source.toolMs);
|
|
336
|
+
if (point.requests > 0 || point.turns > 0) kpi.days += 1;
|
|
337
|
+
}
|
|
338
|
+
kpi.activeMs = kpi.llmMs + kpi.toolMs;
|
|
339
|
+
|
|
340
|
+
// 热力图给足最近 52 周(364 天):手机上只画得下 8 周,iPad 横屏能摊到三十周上下 ——
|
|
341
|
+
// 窗口由页面按自己的宽度截最后几周,宿主这边一律给满,否则宽屏想多画也没有数据。
|
|
342
|
+
const heatmap = [];
|
|
343
|
+
for (let offset = HEATMAP_DAYS - 1; offset >= 0; offset -= 1) {
|
|
344
|
+
const day = addDays(today, -offset);
|
|
345
|
+
const source = ledger.days[day];
|
|
346
|
+
heatmap.push({ day, tokens: source ? billedOf(source) : 0 });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const rangeStartAt = startDay ? new Date(`${startDay}T00:00:00`).getTime() : 0;
|
|
350
|
+
const sessions = [];
|
|
351
|
+
for (const [sessionId, record] of Object.entries(ledger.sessions)) {
|
|
352
|
+
// 退场记录只留水位,不上卡片:它的量早就记在按天的账上了。
|
|
353
|
+
if (record.retired === true) continue;
|
|
354
|
+
if (rangeStartAt > 0 && num(record.lastAt) < rangeStartAt) continue;
|
|
355
|
+
sessions.push({
|
|
356
|
+
sessionId,
|
|
357
|
+
title: record.title || '',
|
|
358
|
+
...(record.workspaceName ? { workspaceName: record.workspaceName } : {}),
|
|
359
|
+
...(record.cwd ? { cwd: record.cwd } : {}),
|
|
360
|
+
...(record.lastText ? { lastText: record.lastText } : {}),
|
|
361
|
+
billedTokens: billedOf(record.billed),
|
|
362
|
+
turns: num(record.billed.turns),
|
|
363
|
+
lastAt: num(record.lastAt),
|
|
364
|
+
...(record.gone ? { gone: true } : {}),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
sessions.sort((a, b) => {
|
|
368
|
+
const goneA = a.gone ? 1 : 0;
|
|
369
|
+
const goneB = b.gone ? 1 : 0;
|
|
370
|
+
if (goneA !== goneB) return goneA - goneB;
|
|
371
|
+
if (b.billedTokens !== a.billedTokens) return b.billedTokens - a.billedTokens;
|
|
372
|
+
return b.lastAt - a.lastAt;
|
|
373
|
+
});
|
|
374
|
+
kpi.sessions = sessions.length;
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
generatedAt: now,
|
|
378
|
+
range,
|
|
379
|
+
kpi,
|
|
380
|
+
days,
|
|
381
|
+
heatmap,
|
|
382
|
+
...(ledger.composition ? { composition: ledger.composition } : {}),
|
|
383
|
+
sessions,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
module.exports = {
|
|
388
|
+
VERSION,
|
|
389
|
+
MAX_SESSIONS,
|
|
390
|
+
MAX_RETIRED,
|
|
391
|
+
MAX_DAYS,
|
|
392
|
+
HEATMAP_DAYS,
|
|
393
|
+
LAST_TEXT_MAX,
|
|
394
|
+
stateFile,
|
|
395
|
+
readLedger,
|
|
396
|
+
saveLedger,
|
|
397
|
+
forgetLedger,
|
|
398
|
+
applyDelta,
|
|
399
|
+
summarize,
|
|
400
|
+
trim,
|
|
401
|
+
dayKeyOf,
|
|
402
|
+
addDays,
|
|
403
|
+
startDayOf,
|
|
404
|
+
emptyUsage,
|
|
405
|
+
billedOf,
|
|
406
|
+
num,
|
|
407
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GET /api/mobile/v1/stats/overview?range=7d|30d|all[&fresh=1](token)
|
|
5
|
+
*
|
|
6
|
+
* 统计页的唯一接口:先采一轮(水位差记账),再把账本按范围折算成页面要的那份。
|
|
7
|
+
*
|
|
8
|
+
* 采集失败**不**让整页报错:账本里已有的数字照样是真的,宁可让用户看到一份偏旧的统计,
|
|
9
|
+
* 也不要为了「这次没采上」把整页变成错误页。要重新采就用 `fresh=1`(下拉刷新那条)。
|
|
10
|
+
*
|
|
11
|
+
* 范围词只有三档,别的值一律按 `7d`:多一档就要在页面和这里各维护一份日历口径。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { sendJson } = require('../http/routing.js');
|
|
15
|
+
const { collectStats, DEFAULT_BUDGET_MS } = require('./stats-collect.cjs');
|
|
16
|
+
const ledger = require('./stats-ledger.cjs');
|
|
17
|
+
|
|
18
|
+
const RANGES = new Set(['7d', '30d', 'all']);
|
|
19
|
+
|
|
20
|
+
function normalizeRange(value) {
|
|
21
|
+
return typeof value === 'string' && RANGES.has(value) ? value : '7d';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function handleStatsOverview(ctx, req, res) {
|
|
25
|
+
const url = new URL(req.url, 'http://x');
|
|
26
|
+
const range = normalizeRange(url.searchParams.get('range'));
|
|
27
|
+
const fresh = url.searchParams.get('fresh') === '1';
|
|
28
|
+
try {
|
|
29
|
+
// 带预算采:到点就用已经采到的那部分出数,别让手机等十几秒(见 stats-collect 的同名常量)。
|
|
30
|
+
await collectStats(ctx, { force: fresh, budgetMs: DEFAULT_BUDGET_MS });
|
|
31
|
+
} catch {
|
|
32
|
+
/* 采集是旁路:账本还在,统计页照常出数 */
|
|
33
|
+
}
|
|
34
|
+
const overview = ledger.summarize(ledger.readLedger(), range, Date.now());
|
|
35
|
+
sendJson(res, 200, { ok: true, overview });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { handleStatsOverview, normalizeRange };
|
package/lib/mobile/stream.js
CHANGED
|
@@ -263,7 +263,18 @@ async function handleStream(ctx, req, res) {
|
|
|
263
263
|
let loopEnded = false;
|
|
264
264
|
|
|
265
265
|
try {
|
|
266
|
-
//
|
|
266
|
+
// **先开实时流、再回放**:之前是先 replay 再 mux,两步之间的 follow 还没订阅,
|
|
267
|
+
// 这段时间发生的事件既不在 replay 快照里、也不在实时流里,只能等下一次重连补。
|
|
268
|
+
// 现在先让 mux 把 follow 订阅建立起来(第一帧在 `firstFrame` 里等着,尚未消费),
|
|
269
|
+
// 再回放 history;回放与 follow 的重叠由 `seq <= lastDeliveredSeq` 去重。
|
|
270
|
+
// 注意:payload 里带 sessionId(0.2.0 兼容桥据此收窄成单会话 follow + 投影 + 任务)。
|
|
271
|
+
const frames = ctx.apiProxy.events.mux({ rpcId: 'mobile-stream', payload: streamAddress }, ac.signal);
|
|
272
|
+
const frameIterator = frames[Symbol.asyncIterator]();
|
|
273
|
+
// 立刻踢一脚让上游订阅开始;回放期间不消费,事件由 follow 连续读入并在这里排队。
|
|
274
|
+
let firstFrame = frameIterator.next();
|
|
275
|
+
firstFrame.catch(() => { /* 错误在下面统一收口,这里只避免回放期间未处理的 rejection */ });
|
|
276
|
+
|
|
277
|
+
// 断线续传:回放 history 中 seq > resumeSeq 的事件(收敛状态)。
|
|
267
278
|
if (resumeSeq != null && resumeSeq > 0) {
|
|
268
279
|
const replay = await replaySince(ctx, sessionId, resumeSeq, res, ac.signal, (seq) => { lastDeliveredSeq = Math.max(lastDeliveredSeq, seq); }, streamAddress);
|
|
269
280
|
if (replay.gap && !ac.signal.aborted) {
|
|
@@ -274,13 +285,6 @@ async function handleStream(ctx, req, res) {
|
|
|
274
285
|
}
|
|
275
286
|
|
|
276
287
|
if (!clientGone) {
|
|
277
|
-
// payload 里带 sessionId:0.2.0 的宿主没有设备级事件流了,兼容桥据此收窄成
|
|
278
|
-
// 这一条会话的 follow + 投影 + 任务三条流(见 mobile/host-bridge.js 的 muxFrames)。
|
|
279
|
-
// 不带就是设备级视角,只会给工作区基线与全局投影 —— 这里反正只要单会话。
|
|
280
|
-
// 子会话必须带上父会话(`parentSessionId` + `mode`):宿主对 origin==='subagent'
|
|
281
|
-
// 的会话拿普通地址订阅一律回 agent-busy,表现是「页面打得开、内容不更新」。
|
|
282
|
-
// 地址的拼装与历史那条同源,见 host-bridge/event-synthesis.js 的 sessionAddress。
|
|
283
|
-
const frames = ctx.apiProxy.events.mux({ rpcId: 'mobile-stream', payload: streamAddress }, ac.signal);
|
|
284
288
|
// 写一帧并把三种结果统一收口:丢增量、需要等 drain、该收尾。
|
|
285
289
|
// 「需要等 drain」这一步是这次 OOM 的正解 —— 写不动就停手,等底层消化完再继续,
|
|
286
290
|
// 而不是把帧继续往内存里塞。
|
|
@@ -294,7 +298,11 @@ async function handleStream(ctx, req, res) {
|
|
|
294
298
|
}
|
|
295
299
|
return 'sent';
|
|
296
300
|
};
|
|
297
|
-
for
|
|
301
|
+
for (;;) {
|
|
302
|
+
const step = firstFrame ? await firstFrame : await frameIterator.next();
|
|
303
|
+
firstFrame = null;
|
|
304
|
+
if (step.done) break;
|
|
305
|
+
const frame = step.value;
|
|
298
306
|
const p = frame.payload;
|
|
299
307
|
if (!p || p.sessionId !== sessionId) continue;
|
|
300
308
|
// 增量事件保持原 wire:data 仍是原事件 JSON,SSE 元数据独立于 data。
|
|
@@ -347,7 +355,7 @@ async function handleStream(ctx, req, res) {
|
|
|
347
355
|
// 明确告诉客户端「流到此为止,请带 lastSeq 重连」:它会走已验证可用的 replay
|
|
348
356
|
// 路径补上缺口,而不是把静默断流当成正常状态。
|
|
349
357
|
if (!ac.signal.aborted && !clientGone) {
|
|
350
|
-
writeSse(res, { type: 'session/resync-required', sessionId, oldestSeq: lastDeliveredSeq + 1 });
|
|
358
|
+
writeSse(res, { type: 'session/resync-required', sessionId, oldestSeq: lastDeliveredSeq + 1, final: true });
|
|
351
359
|
}
|
|
352
360
|
res.end();
|
|
353
361
|
logStream(sessionId, `stream closed (loopEnded=${loopEnded}, aborted=${ac.signal.aborted}, clientGone=${clientGone}, deliveredSeq=${lastDeliveredSeq})`, null);
|
package/lib/mobile/uploads.js
CHANGED
|
@@ -132,23 +132,39 @@ async function uniquePath(dir, name) {
|
|
|
132
132
|
return path.join(dir, `${stem}-${Date.now()}${ext}`);
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* 清掉过期残片;尽力而为,失败不影响本次上传。
|
|
137
|
+
*
|
|
138
|
+
* 返回删掉了几个、腾了多少字节 —— `begin` 那条路不看它(`void` 掉),但收件目录的清理
|
|
139
|
+
* 与占用读数要拿它报数,所以别改成只在内部记账。
|
|
140
|
+
*
|
|
141
|
+
* **这条清扫只在 `begin` 时跑**:手机不再上传的那台机器上,残片就一直躺着(实测过:
|
|
142
|
+
* 一份 12MB 的 `.part` 从 10-20 睡到今天)。所以 `mobile/inbox-clean.js` 的清理与启动
|
|
143
|
+
* 收尾也会调它,TTL 判据只有这一份。
|
|
144
|
+
*/
|
|
136
145
|
async function sweepStaging(stagingDir) {
|
|
137
146
|
let entries;
|
|
138
147
|
try {
|
|
139
148
|
entries = await fsp.readdir(stagingDir, { withFileTypes: true });
|
|
140
149
|
} catch {
|
|
141
|
-
return;
|
|
150
|
+
return { deleted: 0, bytes: 0 };
|
|
142
151
|
}
|
|
143
152
|
const cutoff = Date.now() - STAGING_TTL_MS;
|
|
144
|
-
await Promise.all(entries.map(async (entry) => {
|
|
145
|
-
if (!entry.isFile() || !entry.name.endsWith('.part')) return;
|
|
153
|
+
const removed = await Promise.all(entries.map(async (entry) => {
|
|
154
|
+
if (!entry.isFile() || !entry.name.endsWith('.part')) return null;
|
|
146
155
|
const target = path.join(stagingDir, entry.name);
|
|
147
156
|
try {
|
|
148
157
|
const stat = await fsp.stat(target);
|
|
149
|
-
if (stat.mtimeMs
|
|
150
|
-
|
|
158
|
+
if (stat.mtimeMs >= cutoff) return null;
|
|
159
|
+
await fsp.unlink(target);
|
|
160
|
+
return stat.size;
|
|
161
|
+
} catch {
|
|
162
|
+
// 已被别人清掉,或者删不掉:都不算这次腾出来的量。
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
151
165
|
}));
|
|
166
|
+
const sizes = removed.filter((size) => typeof size === 'number');
|
|
167
|
+
return { deleted: sizes.length, bytes: sizes.reduce((sum, size) => sum + size, 0) };
|
|
152
168
|
}
|
|
153
169
|
|
|
154
170
|
function fail(res, status, code, message) {
|
|
@@ -263,7 +279,9 @@ module.exports = {
|
|
|
263
279
|
handleUploadAbort,
|
|
264
280
|
sanitizeName,
|
|
265
281
|
inboxRoot,
|
|
282
|
+
sweepStaging,
|
|
266
283
|
sweepExpiredUploads,
|
|
284
|
+
STAGING_TTL_MS,
|
|
267
285
|
CHUNK_BYTES,
|
|
268
286
|
CHUNK_MAX_BYTES,
|
|
269
287
|
};
|
package/lib/mobile/workspaces.js
CHANGED
|
@@ -23,6 +23,7 @@ const { invalidateSessionList } = require('./sessions.js');
|
|
|
23
23
|
const { withIdempotency, requestIdOf } = require('./idempotency.js');
|
|
24
24
|
const { canonicalDeviceKey } = require('./session-cache.js');
|
|
25
25
|
const { unwrap } = require('./envelope.js');
|
|
26
|
+
const { describeProjectGit } = require('./git-repos.js');
|
|
26
27
|
|
|
27
28
|
/** 调宿主 apiProxy.host.*(directory-picker 的 browse 能力)。 */
|
|
28
29
|
function callHost(ctx, method, payload) {
|
|
@@ -38,6 +39,22 @@ function callWorkspace(ctx, method, payload) {
|
|
|
38
39
|
* GET /api/mobile/v1/workspaces(token)
|
|
39
40
|
* 列出被控端当前所有工作区,供侧边栏「工作区」区块渲染与重命名/删除判定。
|
|
40
41
|
*/
|
|
42
|
+
/**
|
|
43
|
+
* 给每条工作区挂上 Git 仓库标记(`git`)。
|
|
44
|
+
*
|
|
45
|
+
* 只有带 `path` 的条目才查:没有路径的条目连目录都没有,给它一个「不是仓库」的结论
|
|
46
|
+
* 只是把「我不知道」说成「我查过了」。识别本身不抛错、按目录缓存,所以这里不再包
|
|
47
|
+
* try —— 包了只会把真 bug 藏起来。
|
|
48
|
+
*/
|
|
49
|
+
async function attachGitMarkers(items, options) {
|
|
50
|
+
return Promise.all(items.map(async (item) => {
|
|
51
|
+
if (!item || typeof item !== 'object') return item;
|
|
52
|
+
const path = typeof item.path === 'string' ? item.path.trim() : '';
|
|
53
|
+
if (!path) return item;
|
|
54
|
+
return { ...item, git: await describeProjectGit(path, options) };
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
41
58
|
/**
|
|
42
59
|
* 构建工作区快照 body(供移动端镜像同步推送复用)。
|
|
43
60
|
*
|
|
@@ -45,8 +62,10 @@ function callWorkspace(ctx, method, payload) {
|
|
|
45
62
|
* 但此前这里只保留 `items`,把它整个丢掉。于是 App 点完「归档会话」拿不到任何
|
|
46
63
|
* 归档状态,侧边栏那一行永远不消失——归档在移动端表现为一次没有反馈的空操作。
|
|
47
64
|
* 它同时也是将来做「取消归档」入口的唯一数据来源。
|
|
65
|
+
*
|
|
66
|
+
* `options.fresh` 透传给仓库识别,跳过它那 15 秒的目录缓存(写后刷新 / 下拉刷新)。
|
|
48
67
|
*/
|
|
49
|
-
async function buildWorkspacesBody(ctx) {
|
|
68
|
+
async function buildWorkspacesBody(ctx, options = {}) {
|
|
50
69
|
const { value, error } = unwrap(await callWorkspace(ctx, 'list', {}));
|
|
51
70
|
if (error) return { ok: false, error };
|
|
52
71
|
// 宿主报「成功」却没有给出清单数组,这是**没取到**,不是「你没有工作区」。
|
|
@@ -67,17 +86,19 @@ async function buildWorkspacesBody(ctx) {
|
|
|
67
86
|
const archivedSessionIds = value && Array.isArray(value.archivedSessionIds)
|
|
68
87
|
? value.archivedSessionIds.filter(id => typeof id === 'string')
|
|
69
88
|
: [];
|
|
89
|
+
const marked = await attachGitMarkers(items, { fresh: Boolean(options.fresh) });
|
|
70
90
|
return {
|
|
71
91
|
ok: true,
|
|
72
|
-
items,
|
|
92
|
+
items: marked,
|
|
73
93
|
archivedSessionIds,
|
|
74
94
|
// 与 sessions 快照同款的新鲜度/空态契约(见 lib/mobile/sessions.js 的说明):
|
|
75
95
|
// App 把 generatedAt + revision 存进快照用于展示「数据时间」,emptyConfirmed
|
|
76
96
|
// 让「用户真的删光了工作区」不必再靠连续两次空去猜。
|
|
77
97
|
generatedAt: Date.now(),
|
|
78
98
|
// 归档集合也是工作区快照语义的一部分;仅它变化时也必须让镜像推送判为新版本。
|
|
79
|
-
|
|
80
|
-
|
|
99
|
+
// 仓库标记也在里面:换分支是这份快照的一部分,变了就该推新版本。
|
|
100
|
+
revision: stableRevision({ items: marked, archivedSessionIds }),
|
|
101
|
+
emptyConfirmed: marked.length === 0,
|
|
81
102
|
};
|
|
82
103
|
}
|
|
83
104
|
|
|
@@ -93,8 +114,11 @@ async function buildWorkspacesBody(ctx) {
|
|
|
93
114
|
async function handleWorkspaces(ctx, req, res) {
|
|
94
115
|
const url = new URL(req.url, 'http://x');
|
|
95
116
|
const rev = (url.searchParams.get('rev') || '').trim();
|
|
96
|
-
const
|
|
97
|
-
|
|
117
|
+
const fresh = isFreshRequest(req.url);
|
|
118
|
+
// 仓库识别也认这一个 `fresh`:写后刷新 / 下拉刷新必须看见刚新建的仓库,
|
|
119
|
+
// 不能被识别那 15 秒的目录缓存挡回去。
|
|
120
|
+
const body = await buildWorkspacesBody(ctx, { fresh });
|
|
121
|
+
const unchanged = fresh ? null : unchangedListBody(body, rev);
|
|
98
122
|
sendJson(res, 200, unchanged || body);
|
|
99
123
|
}
|
|
100
124
|
|