dsh-mobile-plugin 0.4.0 → 0.4.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/README.md +71 -74
- package/dsh-mobile-credentials/lib/credentials.js +47 -24
- package/host.js +22 -36
- package/lib/client.js +29 -82
- package/lib/desktop-view.cjs +5 -10
- package/lib/git/read.js +6 -8
- package/lib/host-compat.cjs +13 -25
- package/lib/host-guard.cjs +9 -39
- package/lib/host-patches.cjs +5 -16
- package/lib/http/routing.js +7 -27
- package/lib/impl.js +6 -12
- package/lib/index.js +1 -1
- package/lib/mirror/pusher.cjs +33 -85
- package/lib/mirror-snapshot.cjs +6 -24
- package/lib/mobile/agent-presets.js +6 -26
- package/lib/mobile/allowed-zone.js +100 -0
- package/lib/mobile/approvals.js +18 -44
- package/lib/mobile/archive-clean.js +13 -21
- package/lib/mobile/attachments.js +16 -37
- package/lib/mobile/channels.js +15 -41
- package/lib/mobile/credential-upgrade.js +7 -16
- package/lib/mobile/credentials.js +5 -21
- package/lib/mobile/directory-browse.js +8 -27
- package/lib/mobile/directory-listing.js +10 -23
- package/lib/mobile/drive-roots.js +59 -0
- package/lib/mobile/envelope.js +4 -15
- package/lib/mobile/events.js +35 -65
- package/lib/mobile/extensions.js +5 -8
- package/lib/mobile/file-read.js +168 -0
- package/lib/mobile/git-repos.js +6 -15
- package/lib/mobile/git.js +11 -19
- package/lib/mobile/history-derive.js +85 -134
- package/lib/mobile/host-bridge/endpoints.js +4 -6
- package/lib/mobile/host-bridge/event-synthesis.js +21 -35
- package/lib/mobile/host-bridge/shims.js +12 -22
- package/lib/mobile/host-bridge.js +8 -20
- package/lib/mobile/host-settings.js +9 -19
- package/lib/mobile/inbox-clean.js +7 -24
- package/lib/mobile/local-images.js +48 -23
- package/lib/mobile/model-vendors.js +105 -0
- package/lib/mobile/monitor.js +8 -17
- package/lib/mobile/pairing.js +7 -19
- package/lib/mobile/plugin-market.js +475 -0
- package/lib/mobile/plugin-self.js +6 -23
- package/lib/mobile/plugin-update.js +8 -27
- package/lib/mobile/plugins.js +6 -24
- package/lib/mobile/questions.js +15 -40
- package/lib/mobile/references.js +11 -31
- package/lib/mobile/relay-control.js +136 -33
- package/lib/mobile/rpc.js +5 -13
- package/lib/mobile/schedule.js +8 -33
- package/lib/mobile/self-check.cjs +9 -36
- package/lib/mobile/session-cache.js +13 -19
- package/lib/mobile/session-cwd.js +28 -0
- package/lib/mobile/session-file.js +9 -33
- package/lib/mobile/session-flags.js +7 -13
- package/lib/mobile/session-mirror.js +8 -15
- package/lib/mobile/session-models.js +3 -3
- package/lib/mobile/session-schedule-mark.js +8 -14
- package/lib/mobile/session-send.js +13 -21
- package/lib/mobile/session-snapshot.js +4 -6
- package/lib/mobile/sessions.js +7 -23
- package/lib/mobile/stats-collect.cjs +154 -37
- package/lib/mobile/stats-ledger.cjs +258 -75
- package/lib/mobile/stats-scheduler.cjs +41 -0
- package/lib/mobile/stream.js +39 -130
- package/lib/mobile/subagents.js +5 -18
- package/lib/mobile/uploads.js +75 -21
- package/lib/mobile/vendor-logos.cjs +30 -0
- package/lib/mobile/wire-cut.js +212 -0
- package/lib/mobile/workspaces.js +20 -8
- package/lib/panel/index.html +7 -7
- package/lib/read-receipt.cjs +10 -21
- package/lib/relay/apply-ledger.cjs +52 -0
- package/lib/relay/core.js +14 -3
- package/lib/relay/lan-network.cjs +14 -28
- package/lib/relay/lan-proxy.js +14 -21
- package/lib/relay/migrate.js +90 -0
- package/lib/relay/pairing-policy.js +6 -9
- package/lib/relay/preview.js +8 -25
- package/lib/relay/protocol.js +9 -12
- package/lib/relay/status.js +5 -8
- package/lib/relay/tunnel.js +9 -14
- package/lib/relay/verify.js +52 -0
- package/lib/routes/control-plane.js +39 -47
- package/lib/routes/mobile-api.js +47 -34
- package/lib/session-cleanup.cjs +26 -27
- package/lib/shared/credentials-entry.js +4 -11
- package/lib/shared/device-headers.js +12 -4
- package/lib/shared/dshtoken.js +4 -4
- package/lib/shared/e2ee-request.js +1 -1
- package/lib/shared/e2ee.js +8 -14
- package/lib/shared/fresh-request.js +6 -22
- package/lib/shared/http-util.js +3 -4
- package/lib/shared/list-revision.js +5 -14
- package/lib/shared/session-signal.js +7 -16
- package/lib/shared/sse-write.js +14 -20
- package/lib/shared/workspace-guard.js +7 -15
- package/lib/shell/device-ota.js +4 -6
- package/lib/shell/manager.js +12 -27
- package/lib/shell/ota-package.js +7 -12
- package/lib/shell/runtime.js +8 -16
- package/lib/shell/self-check-after-swap.js +9 -23
- package/lib/shell/updater.js +15 -28
- package/lib/sidecar-facade-worker.cjs +75 -41
- package/lib/sidecar-facade.cjs +18 -8
- package/lib/sidecar-lifecycle.cjs +21 -10
- package/lib/update-install.cjs +24 -62
- package/lib/update-registry.cjs +6 -18
- package/package.json +1 -1
- package/lib/auto-archive.cjs +0 -98
|
@@ -3,29 +3,16 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 交付文件的字节(POST /api/mobile/v1/session/file)。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* 文件在电脑上,宿主只给了路径,App 必须先取回字节。这条路由就是那条通道。
|
|
6
|
+
* 轮尾那张「交付文件」卡在桌面上点一下就打开;手机上没有那条路,App 必须先取回字节。
|
|
8
7
|
*
|
|
9
|
-
*
|
|
8
|
+
* 三条边界:
|
|
9
|
+
* 1. 放行面 = 本会话工作区里的文件 ∪ 本会话 present 声明过的路径。前者判「解析后的落点是否
|
|
10
|
+
* 在 cwd 之下」,`../x` 落在外面就退回白名单,不在声明里就 403;后者是字符串全等的显式
|
|
11
|
+
* 声明,用于工作区之外的文件。放开工作区不等于能读这台电脑上的任意文件。
|
|
12
|
+
* 2. 相对路径按会话工作区解析:cwd 取自会话列表摘要;没有 cwd 的会话只能走 present 白名单。
|
|
13
|
+
* 3. 流式回写,不在插件进程里缓冲:几十 MB 的录屏不该整包躺在内存里。
|
|
10
14
|
*
|
|
11
|
-
*
|
|
12
|
-
* 的第二条,见 docs/decisions.md)。前者的判法是「解析后的落点在 cwd 之下」:相对路径
|
|
13
|
-
* 按 cwd 拼、绝对路径原样,然后整条路径必须在 cwd 里 —— 所以 `../x` 这种穿越落在
|
|
14
|
-
* cwd 之外,退回旧的那条白名单,不在声明里就 403。后者保持原样(`deliverables/presented`
|
|
15
|
-
* 里显式声明过的、**字符串全等**),它的用处是工作区之外的文件也能交付(绝对路径、
|
|
16
|
-
* cwd 变了的老会话)。
|
|
17
|
-
*
|
|
18
|
-
* **为什么敢放开工作区**:正文里模型写的 `[报告](docs/报告.md)` 就是这条路上的用户预期
|
|
19
|
-
* —— 手机上点一下能看,比「点了没反应」有意义得多;而这条会话的模型本来就能读同一个
|
|
20
|
-
* 工作区,手机与电脑是同一个使用者,放宽的是「同一份工作区能不能在手机上看」,不是
|
|
21
|
-
* 「能不能读这台电脑上的任意文件」(那仍然是 403,与不暴露 shell / 任意写入同一条边界)。
|
|
22
|
-
* 2. **相对路径按会话工作区解析**:cwd 取自会话列表摘要(与 /session 同一个来源)。没有
|
|
23
|
-
* cwd 的会话只能走 present 那条白名单,相对路径解析不出来就回 409。
|
|
24
|
-
* 3. **流式回写、不在插件进程里缓冲**:几十 MB 的录屏不该整包躺在内存里,与会话日志
|
|
25
|
-
* 导出同一个理由。
|
|
26
|
-
*
|
|
27
|
-
* 取不到时两种失败要分得开:两条口子都不认这个路径就回 403(`outside-workspace`),
|
|
28
|
-
* 认了、盘上没了回 404(`missing`)—— 前者是「这个路径不该被读」,后者是「文件被挪走了」。
|
|
15
|
+
* 两种失败分开:两条口子都不认就 403(`outside-workspace`),认了但盘上没了回 404(`missing`)。
|
|
29
16
|
*/
|
|
30
17
|
|
|
31
18
|
const fs = require('node:fs');
|
|
@@ -36,6 +23,7 @@ const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
|
36
23
|
const { signedPath } = require('../shared/e2ee-request');
|
|
37
24
|
const { unwrap } = require('./envelope.js');
|
|
38
25
|
const { callSessions } = require('./rpc.js');
|
|
26
|
+
const { cwdOf } = require('./session-cwd.js');
|
|
39
27
|
const { presentedPathsOf } = require('./history-derive.js');
|
|
40
28
|
|
|
41
29
|
/** 单文件上限:手机内存与中转链路都不适合再大的东西(超出如实报 413)。 */
|
|
@@ -118,18 +106,6 @@ function isInsideWorkspace(cwd, target) {
|
|
|
118
106
|
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
119
107
|
}
|
|
120
108
|
|
|
121
|
-
/** 会话的工作区目录(相对路径的解析基准)。取不到就回空串,由调用方决定怎么办。 */
|
|
122
|
-
async function cwdOf(ctx, sessionId) {
|
|
123
|
-
try {
|
|
124
|
-
const { value } = unwrap(await callSessions(ctx, 'list', {}));
|
|
125
|
-
const items = (value && value.items) || [];
|
|
126
|
-
const row = items.find((item) => item && item.sessionId === sessionId);
|
|
127
|
-
return row && typeof row.cwd === 'string' ? row.cwd : '';
|
|
128
|
-
} catch {
|
|
129
|
-
return '';
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
109
|
/**
|
|
134
110
|
* POST { sessionId, path } → 该文件的原始字节。
|
|
135
111
|
*
|
|
@@ -17,21 +17,15 @@ const approvals = require('./approvals.js');
|
|
|
17
17
|
const questions = require('./questions.js');
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
20
|
+
* 宿主的两个会话集合(同一次 `workspace.list` 快照里拿到):
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* - `pinnedSessionIds`:已置顶的会话(宿主侧「最近置顶的在前」)。桌面端也只从这份
|
|
27
|
-
* 快照读置顶,`sessions.list` 的摘要里没有置顶字段,所以移动端同样要自己读,
|
|
28
|
-
* 否则 App 既画不出置顶标记、也排不到工作区最前。
|
|
22
|
+
* - `archivedSessionIds`:已归档的会话。`sessions.list` 自身不过滤归档,移动端必须自己读,
|
|
23
|
+
* 否则「归档」写成功了、列表却照旧把那一行发回手机。
|
|
24
|
+
* - `pinnedSessionIds`:已置顶的会话。摘要里没有置顶字段,不读这份快照就画不出置顶标记、
|
|
25
|
+
* 也排不到工作区最前。
|
|
29
26
|
*
|
|
30
|
-
* 两个集合合并成一次 RPC
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* 取不到时两个集合都回空(失败放开):宁可多显示几行、少几个置顶,也不能因为一次
|
|
34
|
-
* RPC 抖动让整个会话列表凭空变空。
|
|
27
|
+
* 两个集合合并成一次 RPC:同源同一次快照,而会话列表每 10 秒重建一次。取不到时都回空 ——
|
|
28
|
+
* 宁可多显示几行,也不能因为一次 RPC 抖动让整个列表变空。
|
|
35
29
|
*/
|
|
36
30
|
async function workspaceSessionFlags(ctx) {
|
|
37
31
|
try {
|
|
@@ -63,12 +63,9 @@ async function buildSingleSessionMirror(ctx, sessionId, options = { limit: 200 }
|
|
|
63
63
|
/**
|
|
64
64
|
* Build mirror warm snapshots for the host mirror push.
|
|
65
65
|
*
|
|
66
|
-
* Two modes:
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* 2. Background (no sessionIds): build from summaries, one session at a time,
|
|
70
|
-
* returning after each session so the caller can push incrementally.
|
|
71
|
-
* Processes at most `maxSessions` sessions per call, using cache when fresh.
|
|
66
|
+
* Two modes: high-priority (sessionIds given) builds all specified sessions at once; background
|
|
67
|
+
* (no sessionIds) builds from summaries one at a time so the caller can push incrementally,
|
|
68
|
+
* at most `maxSessions` per call, using the cache when fresh.
|
|
72
69
|
*
|
|
73
70
|
* @param {object} ctx - harness context
|
|
74
71
|
* @param {Array} summaries - session list summaries from host
|
|
@@ -77,15 +74,11 @@ async function buildSingleSessionMirror(ctx, sessionId, options = { limit: 200 }
|
|
|
77
74
|
* @param {string[]} [options.sessionIds] - specific sessions to build (high-priority)
|
|
78
75
|
* @param {number} [options.limit=200] - max messages per session (capped at 200)
|
|
79
76
|
* @param {number} [options.maxSessions=32] - max sessions to build in one call
|
|
80
|
-
* @param {boolean} [options.noCache=false] -
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* @returns {Promise<object>} messages map { [sessionId]: { ok, items, lastSeq, hasMore } }
|
|
86
|
-
* In background mode (no sessionIds), also sets _moreSessions on the result when
|
|
87
|
-
* there are remaining sessions beyond maxSessions, and _builtCount for how many
|
|
88
|
-
* were actually built this call.
|
|
77
|
+
* @param {boolean} [options.noCache=false] - 完全绕开时间型缓存(既不读也不写)。事件驱动的
|
|
78
|
+
* 推送必须传它:缓存只认「宿主抬了 updatedAt」这一种变化,新消息落盘而摘要未变时会把上一份
|
|
79
|
+
* 正文当成这一份发出去。也不能靠传一个必然不同的 updatedAt 来绕:同一毫秒的两次推送指纹相同。
|
|
80
|
+
* @returns {Promise<object>} messages map { [sessionId]: { ok, items, lastSeq, hasMore } };
|
|
81
|
+
* 后台模式还会带 _moreSessions(是否还有剩余会话)与 _builtCount(本次真正构建了几个)。
|
|
89
82
|
*/
|
|
90
83
|
async function buildMirrorWarmSnapshots(ctx, summaries, activeSessionId, options = {}) {
|
|
91
84
|
const maxSessions = Math.max(1, Math.min(Number(options.maxSessions) || 32, 256));
|
|
@@ -165,9 +165,9 @@ async function buildModelsSnapshot(ctx, sessionId) {
|
|
|
165
165
|
|
|
166
166
|
/** GET /api/mobile/v1/session/models?sessionId=…(token)
|
|
167
167
|
* 返回会话当前模型选择与可用模型目录(透传宿主 session.models)。
|
|
168
|
-
* 响应:{ ok, current
|
|
169
|
-
* `current`
|
|
170
|
-
* `options`
|
|
168
|
+
* 响应:{ ok, current, defaultModel, routable, groups, options?, failures }。
|
|
169
|
+
* `current` 是这个会话实际会用的,`defaultModel` 是全局默认(新建会话的落点),
|
|
170
|
+
* `options` 是当前渠道的附加选项行,没有就没有这个字段。
|
|
171
171
|
*/
|
|
172
172
|
async function handleModels(ctx, req, res) {
|
|
173
173
|
const url = new URL(req.url, 'http://x');
|
|
@@ -1,23 +1,17 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* 会话行左边那只时钟:这一条会话有没有还在生效的自动化任务。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 主状态是空闲」时才挂得上(有审批 / 有新消息 / 正在跑时,那一格留给状态点),归档行那格
|
|
9
|
-
* 留空。手机端此前没有这个标记,用户在桌面上排了定时任务,手机上那条会话看不出任何区别。
|
|
6
|
+
* 桌面上那个标记只在会话行的主状态空闲时挂得上(有审批 / 新消息 / 正在跑时那格留给状态点),
|
|
7
|
+
* 归档行留空;手机端此前没有这个标记。
|
|
10
8
|
*
|
|
11
|
-
* 数据从宿主的任务目录来:`schedule.catalog()` 一次给出全部任务的 sessionId + status
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* 数据从宿主的任务目录来:`schedule.catalog()` 一次给出全部任务的 sessionId + status,按
|
|
10
|
+
* `status === 'active'` 收成集合,列表每行查表。不逐行发 `schedule/list` —— 会话列表每 10 秒
|
|
11
|
+
* 重建一次,逐行请求等于每轮几十次往返。
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* 会话列表失败是拿主要功能换装饰;
|
|
18
|
-
* - **读失败也当没有**,但只吞这一条,不缓存:下一个 10 秒周期照常重问,宿主的
|
|
19
|
-
* `schedule/changed` 与目录本身都是活的;
|
|
20
|
-
* - 只看 `active`:一次性任务投完就自动结束(`status` 转 inactive),桌面上那颗也跟着消失。
|
|
13
|
+
* 三条取舍:服务不在(老宿主 / 极简 profile)就当没有任务;读失败也只吞这一条、不缓存,
|
|
14
|
+
* 下个周期照常重问;只看 active,一次性任务投完转 inactive 后标记自然消失。
|
|
21
15
|
*/
|
|
22
16
|
|
|
23
17
|
/** 目录一次最多收多少条(与 schedule.js 的 SCHEDULE_MAX_ENTRIES 同量级,这里只用来防异常宿主)。 */
|
|
@@ -43,21 +43,15 @@ function singleTextOf(content) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* 经网关执行 slash 命令(commands.execute
|
|
47
|
-
*
|
|
48
|
-
* agent 查找器解析,与 session.prompt 的 turnAgentFor 同源,支持冷会话自动 resume)。
|
|
46
|
+
* 经网关执行 slash 命令(commands.execute,桌面 Web 端同一条派发路径:命令注册表命中才执行,
|
|
47
|
+
* 绝不经 session.prompt 发给模型;agent 由 agentId 经同一查找器解析,支持冷会话自动 resume)。
|
|
49
48
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* 参数 = line + submittedAttachments(encoded images 与 staged file receipts,
|
|
54
|
-
* 按提交顺序排列,纯命令调用传空数组即可)。
|
|
55
|
-
* 这里曾经写成 `images: []`——少了 submittedAttachments 又多了 images,导致
|
|
56
|
-
* /permission、/plan 等**所有** slash 命令 400;形状守卫见
|
|
57
|
-
* test/commands-execute-shape.test.cjs(改这里必须同步那张表)。
|
|
49
|
+
* args 的键名必须恰好是宿主 descriptor 的 wire 名:scope = agentId,参数 = line +
|
|
50
|
+
* submittedAttachments(encoded images 与 staged file receipts,按提交顺序,纯命令传空数组)。
|
|
51
|
+
* 多一个少一个都是 gateway/arguments-invalid,形状守卫见 test/commands-execute-shape.test.cjs。
|
|
58
52
|
*
|
|
59
|
-
* @returns {execution} 派发成功(
|
|
60
|
-
* | {
|
|
53
|
+
* @returns {execution} 派发成功(undefined=未知命令/语法不符)| {error} 查找或派发失败
|
|
54
|
+
* | {missing} 网关服务未组合(调用方回退普通消息)
|
|
61
55
|
*/
|
|
62
56
|
async function executeCommand(ctx, sessionId, line) {
|
|
63
57
|
// 走 host-bridge/transport.js 的 invoke:Peer 身份与「服务/方法不存在」的收敛都在
|
|
@@ -82,8 +76,7 @@ function promptOnce(ctx, clientMessageId, payload) {
|
|
|
82
76
|
// 把这一发认回去的身份一起送进宿主的 prompt 请求(`request.requestId`,见
|
|
83
77
|
// host-bridge/endpoints.js)。宿主要是不认识它,user 消息的 `source.rpcId` 就是那个
|
|
84
78
|
// 端点自己生成的 `mobile-prompt-<uuid>` —— App 那边 `clientMessageId` 永远对不上,
|
|
85
|
-
// 只能靠「同文本 +
|
|
86
|
-
// 因为实时帧里的图还被漏读,张数一对不上就彻底认领不了)。
|
|
79
|
+
// 只能靠「同文本 + 同图数」猜着认领乐观行(实时帧里的图一旦漏读,张数对不上就彻底认领不了)。
|
|
87
80
|
const request = clientMessageId ? { ...payload, requestId: rpcId } : payload;
|
|
88
81
|
if (!clientMessageId) return callSessions(ctx, 'prompt', request, rpcId);
|
|
89
82
|
// 同一个 clientMessageId 在本会话里只承诺一次:内核重试、以及隔了几分钟用户手点的
|
|
@@ -176,14 +169,13 @@ async function handleSend(ctx, req, res) {
|
|
|
176
169
|
sendJson(res, 200, { ok: true, accepted: true });
|
|
177
170
|
}
|
|
178
171
|
|
|
179
|
-
/** GET /api/mobile/v1/commands?sessionId=…(token
|
|
180
|
-
*
|
|
172
|
+
/** GET /api/mobile/v1/commands?sessionId=…(token):会话有效命令目录(commands.list,与桌面
|
|
173
|
+
* Web 端命令面板同源)。
|
|
181
174
|
* 响应:{ ok, items:[{name, description, input?:{hint}}] };网关未组合或失败时降级为空清单,
|
|
182
|
-
*
|
|
175
|
+
* 客户端菜单据此只展示真实可执行的命令。
|
|
183
176
|
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
* 0.2.1 要求每个请求自带 Peer,这条路径没跟上,用户看到的就是「目标、计划、指令全没了」。 */
|
|
177
|
+
* 失败一律降级成空清单是刻意设计:输入区不能因为目录拉不到就报错。代价是这个端点出问题时
|
|
178
|
+
* 手机上看不出提示,「+」菜单静默只剩媒体与文件两行。 */
|
|
187
179
|
async function handleCommands(ctx, req, res) {
|
|
188
180
|
const sessionId = new URL(req.url, 'http://x').searchParams.get('sessionId');
|
|
189
181
|
if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
|
|
@@ -77,12 +77,10 @@ async function buildStatsSnapshot(ctx, sessionId) {
|
|
|
77
77
|
/**
|
|
78
78
|
* 统计投影快照(handleStats 与 buildStatsSnapshot 共用)。
|
|
79
79
|
*
|
|
80
|
-
* 0.2.0 把整帧投影从
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* pending),排队条与计划条读的是 planMode。历史轮询是每 5 秒一次的热路径,多背一次
|
|
85
|
-
* 投影 RPC 划不来,所以宁可让统计单独走投影端点。
|
|
80
|
+
* 0.2.0 把整帧投影从 history/page 的返回值里拆走了,「拉一条历史顺带取投影」这条路断了;
|
|
81
|
+
* 改走 `apiProxy.sessions.projections({ request: { sessionId } })`,返回值是
|
|
82
|
+
* `{ asOfSeq, values }`。顺带把 plan 归一化成 planMode(排队条与计划条读它)。
|
|
83
|
+
* 历史轮询是每 5 秒一次的热路径,多背一次投影 RPC 划不来,所以统计单独走投影端点。
|
|
86
84
|
*/
|
|
87
85
|
async function statsProjections(ctx, sessionId) {
|
|
88
86
|
// 显式给 signal:宿主部分方法内部直接读 signal.aborted,不做 undefined 兜底。
|
package/lib/mobile/sessions.js
CHANGED
|
@@ -1,32 +1,16 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* 移动端会话 API
|
|
4
|
+
* 移动端会话 API 的会话列表与新建会话。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* envelope.js 信封拆解(unwrap / envelopeValue),唯一的实现
|
|
12
|
-
* rpc.js 对宿主 apiProxy 的窄接口封装(callSessions / callAgentPresets)
|
|
13
|
-
* session-cache.js 按 ctx 隔离的状态表,全部 WeakMap 集中在这里
|
|
14
|
-
* attachments.js 图片附件还原
|
|
15
|
-
* completion.js 完成提醒状态机
|
|
16
|
-
* session-models.js 模型目录与切模型
|
|
17
|
-
* session-search.js 正文检索
|
|
18
|
-
* session-send.js 发消息 / slash 命令 / 读 / 附件重取 / 停 / 改名 / 分叉
|
|
19
|
-
* session-snapshot.js 消息与统计快照(镜像与分页共用)
|
|
20
|
-
* session-mirror.js 镜像整帧构建
|
|
21
|
-
* session-goal.js 目标条动作
|
|
22
|
-
* session-presets.js Agent 预设目录
|
|
23
|
-
* session-preview.js 列表行的 lastMessage 预览(按会话指纹复用 + 运行中会话的节流)
|
|
24
|
-
* session-list-shortcut.js `?rev=` 条件刷新记住的那份清单是谁
|
|
6
|
+
* 这个文件一度是一千九百多行的单体,HTTP 路由、宿主 RPC、缓存、事件派生全混在一起;现在
|
|
7
|
+
* 按域拆开:envelope(信封拆解)、rpc(窄接口封装)、session-cache(按 ctx 隔离的状态表)、
|
|
8
|
+
* attachments、completion、session-models、session-search、session-send、session-snapshot、
|
|
9
|
+
* session-mirror、session-goal、session-presets、session-preview、session-list-shortcut。
|
|
25
10
|
*
|
|
26
11
|
* 剩下的就是列表:GET /sessions(10 秒缓存 + fresh 绕过 + 完成态确认 + `?rev=` 条件刷新)、
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* 所以 lib/mobile 下所有 import 路径一行都不用改。
|
|
12
|
+
* 单会话摘要、正文水位探针、统计、新建会话、排队消息动作。本文件继续转出各子模块的导出,
|
|
13
|
+
* 所以 lib/mobile 下的 import 路径一行都不用改。
|
|
30
14
|
*/
|
|
31
15
|
|
|
32
16
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
@@ -3,31 +3,27 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 统计采集:把宿主每条会话的累计用量读出来,和水位一比,增量记进账本。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* 不重放会话事件:手机要的是「按天看个大概」,重放整条日志是每秒都在烧的开销。所以走水位差
|
|
7
|
+
* (宿主累计值 − 账本水位 = 这次入账的量),零依赖,代价是入账日期取「插件看到增量的那一天」,
|
|
8
|
+
* 跨零点的一轮会落在后一天。首采会把历史全量入账,日期取它最后一次活动的日期,算一次性回填。
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* 好处是零依赖(只用现有的会话投影接口,不需要宿主新能力,也不用装别家插件),
|
|
13
|
-
* 代价如实说:入账日期取「插件看到增量的那一天」,跨零点的一轮会落在后一天。
|
|
14
|
-
* 这是刻意的近似 —— 为一个几十分钟的边界去重放事件流不值得。
|
|
10
|
+
* 采集分三条路进来:打开统计页 / 下拉刷新(`DEFAULT_BUDGET_MS`)、宿主侧每几分钟自己跑一轮
|
|
11
|
+
* (`PERIODIC_INTERVAL_MS`,只会读真有变动的会话)、以及删除会话之前的那一刀补采。
|
|
15
12
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* 而不是从今天零点重新计数。
|
|
13
|
+
* 清理前那次补采(`skipPreview`)不读「最后一句」:那一步要读会话日志,是整轮最重的一趟,
|
|
14
|
+
* 而删除不可恢复、时间又卡在请求预算里。宁可让首次见到的会话卡片少一行摘要。
|
|
19
15
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* 数字记进账本,宁可让首次见到的会话卡片少一行摘要 —— 标题与项目名还认得出来。
|
|
16
|
+
* 模型维度(`book.models`)是**近似**,写清楚省得以后有人当账本该有的精度去用:增量记在
|
|
17
|
+
* 「采集到它的这一刻这条会话正在用的模型」名下(`readSessionModel`)。水位法只认观测点,
|
|
18
|
+
* 会话中途换模型、而换模型到下一次采集之间的量分不开了,整段归到换后的那个 —— 这与首采
|
|
19
|
+
* 回填记在「最后活动那天」是同一种近似。想要真按模型拆,得让宿主按模型累计用量,那是另一
|
|
20
|
+
* 件事(现在的投影只有会话级累计)。
|
|
26
21
|
*/
|
|
27
22
|
|
|
28
23
|
const { unwrap } = require('./envelope.js');
|
|
29
24
|
const { callSessions } = require('./rpc.js');
|
|
30
25
|
const { sessionPreviewFor } = require('./session-preview.js');
|
|
26
|
+
const { listTitle } = require('./history-derive.js');
|
|
31
27
|
const ledger = require('./stats-ledger.cjs');
|
|
32
28
|
|
|
33
29
|
/** 同一条会话两次采集之间的最小间隔(打开页面时的重复请求直接复用上一轮结果)。 */
|
|
@@ -39,11 +35,9 @@ const CONCURRENCY = 4;
|
|
|
39
35
|
/**
|
|
40
36
|
* 这一轮先扫「最近还在动」的会话,再按「最久没采过」补老账。
|
|
41
37
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* 采过的会顺手把时间推到当下、自动排到队尾,于是每一轮的预算都接着上一轮的地方往下走,
|
|
46
|
-
* 不需要额外持久化一个游标。
|
|
38
|
+
* 只按最后活动时间倒序扫会让最老的那批永远排在队尾、永远采不到。所以队列分两段:近 7 天有
|
|
39
|
+
* 动作的优先(它们才是数字在变的那批),其余按 `collectedAt` 升序 —— 采过的会把时间推到当下、
|
|
40
|
+
* 自动排到队尾,每一轮的预算接着上一轮往下走,不需要额外持久化游标。
|
|
47
41
|
*/
|
|
48
42
|
const RECENT_WINDOW_MS = 7 * 86400_000;
|
|
49
43
|
|
|
@@ -51,13 +45,32 @@ const RECENT_WINDOW_MS = 7 * 86400_000;
|
|
|
51
45
|
* 一次采集的时间预算。
|
|
52
46
|
*
|
|
53
47
|
* 手机上点开统计页时,插件会当场把每条会话的累计用量读一遍;一百多条会话串起来能拖到
|
|
54
|
-
*
|
|
48
|
+
* 十几秒(每条两趟宿主调用),用户看到的就是「点统计,转好久」。
|
|
55
49
|
* 所以给这一趟设上限:到点就停,把**已经采到的**那部分记进账本先回给手机,剩下的等
|
|
56
50
|
* 下一次打开 / 下拉刷新接着采 —— 水位差是累积的,采一半不会算错,只是数字暂时偏小。
|
|
57
51
|
*/
|
|
58
52
|
const DEFAULT_BUDGET_MS = 1500;
|
|
59
53
|
|
|
54
|
+
/**
|
|
55
|
+
* 删除会话之前那次补采的预算。
|
|
56
|
+
*
|
|
57
|
+
* 统计页那 1.5 秒是为了别让用户转好久;删除是不可恢复的,值得多等几秒把账补全,但不能
|
|
58
|
+
* 超过 App 单发请求的 8 秒总预算。两条删除路径(手机那个「清理已归档」按钮、agent 的
|
|
59
|
+
* 删除工具)共用这一个数,改预算别只改一处。
|
|
60
|
+
*/
|
|
61
|
+
const DELETING_COLLECT_BUDGET_MS = 6_000;
|
|
62
|
+
|
|
63
|
+
/** 宿主侧定期采集的间隔。
|
|
64
|
+
*
|
|
65
|
+
* 以前只在「有人打开统计页 / 下拉刷新 / 点清理」时采,水位差就把「上次观测到现在」的整段
|
|
66
|
+
* 用量记在**观测到它的那一天**:连着几天没打开页面,那几天的量全压到最后一天,按天图上是
|
|
67
|
+
* 一片空白加一根尖柱。定期跑一轮之后,归属误差从「几天」收到「几分钟」,顺带把「清理前
|
|
68
|
+
* 没采到的那一截」一起收掉。一轮的开销靠 `seenUpdatedAt` 兜住:一点没动的会话连投影都不读。
|
|
69
|
+
*/
|
|
70
|
+
const PERIODIC_INTERVAL_MS = 5 * 60_000;
|
|
71
|
+
|
|
60
72
|
const num = ledger.num;
|
|
73
|
+
const billedOf = ledger.billedOf;
|
|
61
74
|
|
|
62
75
|
function normalizePath(value) {
|
|
63
76
|
if (typeof value !== 'string') return '';
|
|
@@ -76,22 +89,28 @@ async function listSessions(ctx) {
|
|
|
76
89
|
}
|
|
77
90
|
}
|
|
78
91
|
|
|
79
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* cwd → 项目展示名。
|
|
94
|
+
*
|
|
95
|
+
* 宿主 `workspace.list` 的形状是「数组或 `{ items }`」,条目上目录叫 `path`、展示名是
|
|
96
|
+
* `title`(与 App 侧那份 `Workspace` 同一套)。以前按 `{ workspaces }` + `cwd` / `name`
|
|
97
|
+
* 读,三个字段一个都对不上,真机上卡片从来就没有过项目名——而这条取不到的错是静默的。
|
|
98
|
+
*/
|
|
80
99
|
async function workspaceNames(ctx) {
|
|
81
|
-
const
|
|
100
|
+
const byPath = new Map();
|
|
82
101
|
try {
|
|
83
102
|
const { value } = unwrap(await ctx.apiProxy.workspace.list({ rpcId: 'mobile', method: 'workspace.list', payload: {} }));
|
|
84
|
-
const
|
|
85
|
-
for (const workspace of
|
|
103
|
+
const items = Array.isArray(value) ? value : (value && Array.isArray(value.items) ? value.items : []);
|
|
104
|
+
for (const workspace of items) {
|
|
86
105
|
if (!workspace || typeof workspace !== 'object') continue;
|
|
87
|
-
const
|
|
88
|
-
const name = typeof workspace.
|
|
89
|
-
if (
|
|
106
|
+
const dir = normalizePath(workspace.path);
|
|
107
|
+
const name = typeof workspace.title === 'string' ? workspace.title : '';
|
|
108
|
+
if (dir && name) byPath.set(dir, name);
|
|
90
109
|
}
|
|
91
110
|
} catch {
|
|
92
111
|
/* 拿不到项目名不影响数字 */
|
|
93
112
|
}
|
|
94
|
-
return
|
|
113
|
+
return byPath;
|
|
95
114
|
}
|
|
96
115
|
|
|
97
116
|
/** 一条会话的投影(tokenUsage / sessionStats / contextBreakdown);读不到返回 null。 */
|
|
@@ -145,6 +164,50 @@ function hasDelta(usage) {
|
|
|
145
164
|
return Object.values(usage).some((value) => value > 0);
|
|
146
165
|
}
|
|
147
166
|
|
|
167
|
+
/**
|
|
168
|
+
* 会话标题:宿主列表摘要里的 title 投影(与 `/sessions` 那条列表同一份来源,见 `listTitle`)。
|
|
169
|
+
*
|
|
170
|
+
* 摘要顶层的 `title` 是不存在的字段(以前照着它读,于是账本里几百条记录的标题全是空串,
|
|
171
|
+
* 卡片只能退到目录名兜底)。这里只取投影里那个真标题:取不到就留空,由 App 用目录末段兜底。
|
|
172
|
+
*/
|
|
173
|
+
function titleOf(summary) {
|
|
174
|
+
const projected = summary && summary.projections && summary.projections.values
|
|
175
|
+
? summary.projections.values.title
|
|
176
|
+
: '';
|
|
177
|
+
return typeof projected === 'string' ? projected : '';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 这条会话此刻的渠道 + 模型(`session.models` 的 `current`),读不到返回 null。
|
|
182
|
+
*
|
|
183
|
+
* 展示名要另外去 `groups` 里对:宿主 `current` 只给 id,页面那块模型卡得画「渠道名 / 模型名」。
|
|
184
|
+
* 对不上(模型已从目录里消失、渠道被卸载)就留空串,归属仍按 id 记 —— 名字对不上不等于这条
|
|
185
|
+
* 用量没有主,而且账本读盘也不拿展示名当有效性判据(见 `sanitizeModelSelection`)。
|
|
186
|
+
*
|
|
187
|
+
* 任何异常都返回 null:统计是旁路,不能因为这条读失败就把整轮记账丢掉。
|
|
188
|
+
*/
|
|
189
|
+
async function readSessionModel(ctx, sessionId) {
|
|
190
|
+
try {
|
|
191
|
+
const { value, error } = unwrap(await callSessions(ctx, 'models', { sessionId }));
|
|
192
|
+
if (error || !value || typeof value !== 'object') return null;
|
|
193
|
+
const current = value.current;
|
|
194
|
+
if (!current || typeof current !== 'object') return null;
|
|
195
|
+
const selection = ledger.sanitizeModelSelection({ provider: current.provider, model: current.model });
|
|
196
|
+
if (!selection) return null;
|
|
197
|
+
const groups = Array.isArray(value.groups) ? value.groups : [];
|
|
198
|
+
const group = groups.find((item) => item && item.id === selection.provider);
|
|
199
|
+
const models = group && Array.isArray(group.models) ? group.models : [];
|
|
200
|
+
const entry = models.find((item) => item && item.id === selection.model);
|
|
201
|
+
return ledger.sanitizeModelSelection({
|
|
202
|
+
...selection,
|
|
203
|
+
providerName: group ? group.name : '',
|
|
204
|
+
modelName: entry ? entry.name : '',
|
|
205
|
+
});
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
148
211
|
/**
|
|
149
212
|
* 采集串行化:同一时刻只跑一轮。
|
|
150
213
|
*
|
|
@@ -182,12 +245,17 @@ async function collectOnce(ctx, options = {}) {
|
|
|
182
245
|
|
|
183
246
|
const wanted = Array.isArray(options.sessionIds) ? new Set(options.sessionIds) : null;
|
|
184
247
|
const names = await workspaceNames(ctx);
|
|
248
|
+
// 这一轮有没有真改过账本:定期采集在空闲时应当一个字节都不写(见 PERIODIC_INTERVAL_MS)。
|
|
249
|
+
let touched = false;
|
|
185
250
|
|
|
186
251
|
// 消失检测只在「采整份列表」时做:定向补采拿到的是子集,凭它判「会话没了」会误标。
|
|
187
252
|
if (!wanted) {
|
|
188
253
|
const liveIds = new Set(items.map((item) => item.sessionId));
|
|
189
254
|
for (const [sessionId, record] of Object.entries(book.sessions)) {
|
|
190
|
-
if (!liveIds.has(sessionId)
|
|
255
|
+
if (!liveIds.has(sessionId) && !record.gone) {
|
|
256
|
+
record.gone = true;
|
|
257
|
+
touched = true;
|
|
258
|
+
}
|
|
191
259
|
}
|
|
192
260
|
}
|
|
193
261
|
|
|
@@ -217,10 +285,27 @@ async function collectOnce(ctx, options = {}) {
|
|
|
217
285
|
const summary = targets[index];
|
|
218
286
|
const sessionId = summary.sessionId;
|
|
219
287
|
const record = book.sessions[sessionId];
|
|
288
|
+
const title = titleOf(summary);
|
|
289
|
+
/** 名字补上 / 重新出现在列表里:这两件都要落盘,数字一个不动。 */
|
|
290
|
+
const renameIfNeeded = () => {
|
|
291
|
+
if (title && title !== record.title) {
|
|
292
|
+
record.title = title;
|
|
293
|
+
touched = true;
|
|
294
|
+
}
|
|
295
|
+
if (record.gone) {
|
|
296
|
+
record.gone = false;
|
|
297
|
+
touched = true;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
220
300
|
if (!options.force && record && record.collectedAt && now - record.collectedAt < COLLECT_INTERVAL_MS) {
|
|
221
301
|
// 还没到下一次采集的点:只把可能变了的名字补上,数字一个字不动。
|
|
222
|
-
|
|
223
|
-
|
|
302
|
+
renameIfNeeded();
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
// 宿主摘要的 `updatedAt` 与上次读到的一模一样 = 这条一点没动过,水位差必然是 0。
|
|
306
|
+
// 定期采集每几分钟一轮,靠这一条把空闲那几轮压成「一次列表调用」。
|
|
307
|
+
if (!options.force && record && record.seenUpdatedAt && num(summary.updatedAt) <= num(record.seenUpdatedAt)) {
|
|
308
|
+
renameIfNeeded();
|
|
224
309
|
continue;
|
|
225
310
|
}
|
|
226
311
|
|
|
@@ -238,6 +323,23 @@ async function collectOnce(ctx, options = {}) {
|
|
|
238
323
|
const cwd = normalizePath(summary.cwd);
|
|
239
324
|
const workspaceName = cwd ? names.get(cwd) : undefined;
|
|
240
325
|
|
|
326
|
+
// 模型只在「这一轮真会往模型表里记东西」时才去读:三种情形 —— 首采(全量要挂到模型
|
|
327
|
+
// 名下)、有增量、以及老账本里那条会话已经有量却还没有模型(升级当天的回填)。
|
|
328
|
+
// 其余轮次一条都不读,否则定期采集每 5 分钟就给每条会话多一趟宿主调用。
|
|
329
|
+
const needsModel = firstSight || hasDelta(delta) || Boolean(record && !record.model && billedOf(record.billed) > 0);
|
|
330
|
+
const model = needsModel ? await readSessionModel(ctx, sessionId) : null;
|
|
331
|
+
// 老账本回填:这条会话在升级前记过累计量,但它没有模型维度,模型卡会是 0 —— 把它那条
|
|
332
|
+
// 会话的累计量一次性复制进模型桶。日期与会话最后一次活动那天一致(同一条近似)。
|
|
333
|
+
// 首采不在这里做:首采的 delta 就是全量,已经跟着 applyDelta 记进模型桶了,再补一次会翻倍。
|
|
334
|
+
if (model && record && !record.model && billedOf(record.billed) > 0 && !firstSight) {
|
|
335
|
+
ledger.applyModelUsage(book, {
|
|
336
|
+
model,
|
|
337
|
+
day: ledger.dayKeyOf(num(record.lastAt) || lastActiveAt),
|
|
338
|
+
usage: record.billed,
|
|
339
|
+
});
|
|
340
|
+
touched = true;
|
|
341
|
+
}
|
|
342
|
+
|
|
241
343
|
if (hasDelta(delta) || firstSight) {
|
|
242
344
|
// 最后一条消息的摘要只在真有增量时去读(它是日志,属于重活):
|
|
243
345
|
// 卡片上那一行「最后一句说了什么」比什么都更能让人认出这是哪条会话。
|
|
@@ -256,18 +358,26 @@ async function collectOnce(ctx, options = {}) {
|
|
|
256
358
|
sessionId,
|
|
257
359
|
at: firstSight ? lastActiveAt : now,
|
|
258
360
|
delta: firstSight ? host : delta,
|
|
259
|
-
title
|
|
361
|
+
title,
|
|
260
362
|
cwd,
|
|
261
363
|
workspaceName,
|
|
262
364
|
lastText,
|
|
365
|
+
model,
|
|
263
366
|
});
|
|
367
|
+
touched = true;
|
|
264
368
|
}
|
|
265
369
|
|
|
266
370
|
const saved = book.sessions[sessionId];
|
|
267
371
|
if (saved) {
|
|
372
|
+
// 这一刻的模型落在这条会话的记录上:它换过模型的话,下一次增量就记到新模型名下
|
|
373
|
+
// (这一轮的量仍按「采集这一刻」归,见文件头)。
|
|
374
|
+
if (model) saved.model = model;
|
|
268
375
|
saved.watermark = host;
|
|
269
376
|
saved.collectedAt = now;
|
|
270
377
|
saved.gone = false;
|
|
378
|
+
// 记下这一次读到的那条会话 `updatedAt`:下一轮它没变就不用再读投影(见文件头的间隔说明)。
|
|
379
|
+
saved.seenUpdatedAt = num(summary.updatedAt) || now;
|
|
380
|
+
touched = true;
|
|
271
381
|
// 上下文构成是**这条会话**的快照:跟着记录走,页面才能说清「这是哪条会话的构成」。
|
|
272
382
|
// 以前全局只留一份、每轮被最后读完的那条覆盖(四条并发,谁后回是谁),
|
|
273
383
|
// 于是卡片上的构成跟用户此刻在用的那条毫无关系。
|
|
@@ -286,15 +396,22 @@ async function collectOnce(ctx, options = {}) {
|
|
|
286
396
|
|
|
287
397
|
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
|
|
288
398
|
|
|
289
|
-
|
|
290
|
-
|
|
399
|
+
// 一点没改就别写盘:定期采集空闲时要真的不花钱,而整份 JSON 每次重写一遍不是免费的。
|
|
400
|
+
if (touched) {
|
|
401
|
+
ledger.trim(book);
|
|
402
|
+
ledger.saveLedger(book);
|
|
403
|
+
}
|
|
291
404
|
return book;
|
|
292
405
|
}
|
|
293
406
|
|
|
294
407
|
module.exports = {
|
|
295
408
|
COLLECT_INTERVAL_MS,
|
|
296
409
|
DEFAULT_BUDGET_MS,
|
|
410
|
+
DELETING_COLLECT_BUDGET_MS,
|
|
411
|
+
PERIODIC_INTERVAL_MS,
|
|
297
412
|
collectStats,
|
|
298
413
|
hostUsageOf,
|
|
299
414
|
diffUsage,
|
|
415
|
+
titleOf,
|
|
416
|
+
readSessionModel,
|
|
300
417
|
};
|