dsh-mobile-plugin 0.4.9 → 0.5.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/icon.png +0 -0
- package/lib/client.js +80 -1
- package/lib/index.js +9 -3
- package/lib/mobile/history-derive.js +86 -2
- package/lib/mobile/host-settings.js +103 -6
- package/lib/mobile/live-steps.js +356 -0
- package/lib/mobile/model-prices-table.cjs +23 -0
- package/lib/mobile/model-prices.cjs +88 -0
- package/lib/mobile/session-cache.js +17 -1
- package/lib/mobile/session-step.js +226 -0
- package/lib/mobile/stats-collect.cjs +40 -0
- package/lib/mobile/stats-ledger.cjs +45 -0
- package/lib/mobile/step-index.js +108 -0
- package/lib/mobile/stream.js +29 -0
- package/lib/panel/index.html +11 -11
- package/lib/relay/connection-check.js +115 -0
- package/lib/relay/firewall-probe.js +300 -0
- package/lib/relay/preview.js +147 -24
- package/lib/routes/control-plane.js +26 -0
- package/lib/routes/mobile-api.js +3 -0
- package/lib/shell/half-route.js +73 -0
- package/lib/shell/half-view.js +37 -0
- package/lib/shell/runtime.js +117 -21
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +5 -1
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 直播时那一轮的过程副本:**只在内存里**,收下一轮就换掉。
|
|
5
|
+
*
|
|
6
|
+
* 为什么需要它:录制里的推理正文与工具参数在落盘之前只存在于直播流里,宿主的
|
|
7
|
+
* `session.history` 取不到跑到一半的那一步 —— 用户点开「正在读取文件」这一行时,
|
|
8
|
+
* 详情接口只能从这份副本里答。落定以后按 `seq` 回拉历史,这份副本就不必再管。
|
|
9
|
+
*
|
|
10
|
+
* 它同时是**跑着那一轮的过程清单**:手机上那条「过程 · 已跑 3 步」要每一行一步的摘要,
|
|
11
|
+
* 而推理块整块不上线路、工具参数也不上(见 `wire-cut.js`),所以摘要与状态只能在这里
|
|
12
|
+
* 算好再按步发出去(`drainLiveSteps` → `session/live-steps` 帧)。**摘要由电脑算**
|
|
13
|
+
* 这条不变:手机上根本没有参数可拼。
|
|
14
|
+
*
|
|
15
|
+
* 容量口径:只留**当前这一轮**(`turn/start` 覆盖),整个进程里最多留几条会话。它复制
|
|
16
|
+
* 的本就是宿主此刻正在攒的东西,所以量级与「这一轮有多长」同一档,收口后不再增长。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 进程里同时留几条会话的直播副本(多开的会话不会一起跑,留几条够来回切)。 */
|
|
20
|
+
const MAX_SESSIONS = 4;
|
|
21
|
+
|
|
22
|
+
/** 单段文本的上限:详情是给人看的,超长输出照旧截断,别把一份 20MB 的结果塞回手机。 */
|
|
23
|
+
const TEXT_MAX = 200_000;
|
|
24
|
+
|
|
25
|
+
const { liveStepTurnOf } = require('./session-cache.js');
|
|
26
|
+
const { reasoningStepSummary, toolStepSummary } = require('./step-index.js');
|
|
27
|
+
|
|
28
|
+
function bucketOf(ctx) {
|
|
29
|
+
return liveStepTurnOf(ctx);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function turnOf(ctx, sessionId) {
|
|
33
|
+
const bucket = bucketOf(ctx);
|
|
34
|
+
let turn = bucket.get(sessionId);
|
|
35
|
+
if (!turn) {
|
|
36
|
+
turn = {
|
|
37
|
+
tools: new Map(),
|
|
38
|
+
/** 推理正文:键是**这一轮里的第几段思考**(不是内容块序号,块序号每步从 0 重数)。 */
|
|
39
|
+
thoughts: new Map(),
|
|
40
|
+
/** 内容块序号 → 那一段思考的编号(只在当前这一步内有效,落定即清)。 */
|
|
41
|
+
thoughtOfBlock: new Map(),
|
|
42
|
+
nextThought: 0,
|
|
43
|
+
callIdByIndex: new Map(),
|
|
44
|
+
activeCallId: '',
|
|
45
|
+
/** 这一轮跑过的步(按发生顺序),一步一条,`key` 是它在 App 侧的稳定身份。 */
|
|
46
|
+
steps: [],
|
|
47
|
+
stepByKey: new Map(),
|
|
48
|
+
/** 还没发出去的那几步(`drainLiveSteps` 取走)。 */
|
|
49
|
+
pending: [],
|
|
50
|
+
ended: false,
|
|
51
|
+
};
|
|
52
|
+
bucket.delete(sessionId);
|
|
53
|
+
bucket.set(sessionId, turn);
|
|
54
|
+
// 只留几条会话(Map 按插入顺序,最老的先丢)。
|
|
55
|
+
while (bucket.size > MAX_SESSIONS) bucket.delete(bucket.keys().next().value);
|
|
56
|
+
}
|
|
57
|
+
return turn;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 把一段文本接到已有的那段后面,超过上限就只留尾巴。 */
|
|
61
|
+
function appendText(prev, next) {
|
|
62
|
+
if (typeof next !== 'string' || next === '') return prev || '';
|
|
63
|
+
const joined = (prev || '') + next;
|
|
64
|
+
return joined.length > TEXT_MAX ? joined.slice(joined.length - TEXT_MAX) : joined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 记一步:没有就追加到清单末尾,有就改它(同一处只占一条)。
|
|
69
|
+
*
|
|
70
|
+
* 只有**真的变了**的那个字段才进待发清单 —— 工具身份帧会来好几发,每发都推送一遍等于
|
|
71
|
+
* 把同一行反复发给你。
|
|
72
|
+
*/
|
|
73
|
+
function putStep(turn, key, patch) {
|
|
74
|
+
let entry = turn.stepByKey.get(key);
|
|
75
|
+
if (!entry) {
|
|
76
|
+
entry = { key, step: {} };
|
|
77
|
+
turn.stepByKey.set(key, entry);
|
|
78
|
+
turn.steps.push(entry);
|
|
79
|
+
Object.assign(entry.step, patch);
|
|
80
|
+
turn.pending.push({ key, step: { ...entry.step } });
|
|
81
|
+
return entry;
|
|
82
|
+
}
|
|
83
|
+
const before = entry.step;
|
|
84
|
+
const changed = Object.keys(patch).some((field) => patch[field] !== before[field]);
|
|
85
|
+
if (changed) {
|
|
86
|
+
Object.assign(entry.step, patch);
|
|
87
|
+
turn.pending.push({ key, step: { ...entry.step } });
|
|
88
|
+
}
|
|
89
|
+
return entry;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toolKey(callId) {
|
|
93
|
+
return `tool:${callId}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function thinkKey(ordinal) {
|
|
97
|
+
return `think:${ordinal}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 工具步的摘要与状态:摘要参数齐了才拼(读到哪算哪的一行「读文件」等于没写)。
|
|
102
|
+
*
|
|
103
|
+
* `error` 是**三态**:没有这一格 = 结果还没来(还在跑);`null` = 结果到了、成功了;
|
|
104
|
+
* `'error'` / `'stopped'` = 结果到了、失败了。别把 `null` 当成「还在跑」。
|
|
105
|
+
*/
|
|
106
|
+
function refreshToolStep(turn, callId) {
|
|
107
|
+
const tool = turn.tools.get(callId);
|
|
108
|
+
if (!tool) return;
|
|
109
|
+
const summary = toolStepSummary(tool.name, tool.argsRaw);
|
|
110
|
+
const state = tool.error === undefined
|
|
111
|
+
? 'running'
|
|
112
|
+
: (tool.error === null ? 'ok' : (tool.error === 'stopped' ? 'stopped' : 'error'));
|
|
113
|
+
putStep(turn, toolKey(callId), {
|
|
114
|
+
kind: 'tool',
|
|
115
|
+
callId,
|
|
116
|
+
...(tool.name ? { name: tool.name } : {}),
|
|
117
|
+
summary,
|
|
118
|
+
state,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 一段思考落定:摘要补上,状态收成成功。 */
|
|
123
|
+
function settleThought(turn, ordinal) {
|
|
124
|
+
const text = turn.thoughts.get(ordinal);
|
|
125
|
+
if (typeof text !== 'string' || text === '') {
|
|
126
|
+
putStep(turn, thinkKey(ordinal), { state: 'ok' });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
putStep(turn, thinkKey(ordinal), { summary: reasoningStepSummary(text), state: 'ok' });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 这一步的所有思考都收尾(一步的块序号只在这一步内有效,落定即清)。 */
|
|
133
|
+
function closeThoughtsOfStep(turn) {
|
|
134
|
+
for (const ordinal of turn.thoughtOfBlock.values()) settleThought(turn, ordinal);
|
|
135
|
+
turn.thoughtOfBlock.clear();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 工具结果两种形状都认(与 history-derive 的 toolResultParts 同一口径)。 */
|
|
139
|
+
function resultParts(message) {
|
|
140
|
+
const direct = Array.isArray(message.content) ? message.content : [];
|
|
141
|
+
const envelope = direct.length === 1 && direct[0] && typeof direct[0] === 'object'
|
|
142
|
+
&& typeof direct[0].type !== 'string' ? direct[0] : null;
|
|
143
|
+
if (!envelope) return { parts: direct, isError: message.isError === true };
|
|
144
|
+
return {
|
|
145
|
+
parts: Array.isArray(envelope.content) ? envelope.content : [],
|
|
146
|
+
isError: envelope.isError === true || message.isError === true,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 收一帧直播事件(在 `wire-cut` 之前看原始内容,所以这里什么都拿得到)。
|
|
152
|
+
*
|
|
153
|
+
* 只有这几类事件有料:工具参数增量、推理增量、块边界、助手消息、工具结果、回合结束。
|
|
154
|
+
* 其余一概忽略。
|
|
155
|
+
*/
|
|
156
|
+
function noteLiveEvent(ctx, sessionId, event) {
|
|
157
|
+
if (!ctx || typeof sessionId !== 'string' || !sessionId) return;
|
|
158
|
+
if (!event || typeof event !== 'object') return;
|
|
159
|
+
const data = event.data && typeof event.data === 'object' ? event.data : null;
|
|
160
|
+
if (event.type === 'turn/start') {
|
|
161
|
+
// 新的一轮开始:上一轮的过程副本作废(换掉了,就别再让人点开旧内容)。
|
|
162
|
+
bucketOf(ctx).delete(sessionId);
|
|
163
|
+
turnOf(ctx, sessionId);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!data) return;
|
|
167
|
+
const turn = turnOf(ctx, sessionId);
|
|
168
|
+
if (event.type === 'assistant/message') {
|
|
169
|
+
// 一步落定:这一步的思考按块序号收尾(后面那一步的块序号从 0 重新数)。
|
|
170
|
+
closeThoughtsOfStep(turn);
|
|
171
|
+
// 块序号 → 调用 的对照也随之作废:下一步的 0 号块是另一个块,留着它会让一条正文
|
|
172
|
+
// 的块边界把上一步的工具重新标成「运行中」。
|
|
173
|
+
turn.callIdByIndex.clear();
|
|
174
|
+
turn.activeCallId = '';
|
|
175
|
+
const message = data.message;
|
|
176
|
+
const content = message && Array.isArray(message.content) ? message.content : [];
|
|
177
|
+
for (const block of content) {
|
|
178
|
+
if (!block || block.type !== 'tool-call' || block.id == null) continue;
|
|
179
|
+
const callId = String(block.id);
|
|
180
|
+
const current = turn.tools.get(callId) || { name: '', argsRaw: '' };
|
|
181
|
+
// 参数到这里是齐的 —— 摘要该按它拼出来了。身份帧可能在断线 / 合并里丢过,
|
|
182
|
+
// 所以这里也顺手把这一步补进清单(有就只更新)。
|
|
183
|
+
turn.tools.set(callId, {
|
|
184
|
+
name: typeof block.name === 'string' && block.name ? block.name : current.name,
|
|
185
|
+
argsRaw: typeof block.arguments === 'string'
|
|
186
|
+
? block.arguments
|
|
187
|
+
: (block.arguments != null ? JSON.stringify(block.arguments) : current.argsRaw),
|
|
188
|
+
});
|
|
189
|
+
refreshToolStep(turn, callId);
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (event.type === 'assistant/chunk') {
|
|
194
|
+
const chunk = data.chunk;
|
|
195
|
+
if (!chunk || typeof chunk !== 'object') return;
|
|
196
|
+
if (chunk.type === 'tool-call-delta') {
|
|
197
|
+
// 宿主只在**第一帧**带 `id` / `name`,后面那些帧只剩 `index` 与 `argumentsDelta`
|
|
198
|
+
// (见 wire-cut.js 的同一条注释)。所以按块序号记住「这个 index 是哪个调用」,
|
|
199
|
+
// 没有 id 的帧接到上一次那个调用上;一步里并行两个调用时按 index 各归各的。
|
|
200
|
+
const index = Number.isSafeInteger(chunk.index) ? chunk.index : null;
|
|
201
|
+
let callId = chunk.id != null ? String(chunk.id) : '';
|
|
202
|
+
if (callId) {
|
|
203
|
+
turn.activeCallId = callId;
|
|
204
|
+
if (index !== null) turn.callIdByIndex.set(index, callId);
|
|
205
|
+
} else if (index !== null && turn.callIdByIndex.has(index)) {
|
|
206
|
+
callId = turn.callIdByIndex.get(index);
|
|
207
|
+
} else if (turn.activeCallId) {
|
|
208
|
+
callId = turn.activeCallId;
|
|
209
|
+
}
|
|
210
|
+
if (!callId) return;
|
|
211
|
+
const current = turn.tools.get(callId) || { name: '', argsRaw: '' };
|
|
212
|
+
turn.tools.set(callId, {
|
|
213
|
+
name: typeof chunk.name === 'string' && chunk.name ? chunk.name : current.name,
|
|
214
|
+
argsRaw: appendText(current.argsRaw, chunk.argumentsDelta),
|
|
215
|
+
...(current.output !== undefined ? { output: current.output } : {}),
|
|
216
|
+
...(current.diffs !== undefined ? { diffs: current.diffs } : {}),
|
|
217
|
+
...(current.error !== undefined ? { error: current.error } : {}),
|
|
218
|
+
});
|
|
219
|
+
// 参数还在流:这一步照实说「运行中」,摘要等参数齐了再补。
|
|
220
|
+
const tool = turn.tools.get(callId);
|
|
221
|
+
putStep(turn, toolKey(callId), {
|
|
222
|
+
kind: 'tool',
|
|
223
|
+
callId,
|
|
224
|
+
...(tool.name ? { name: tool.name } : {}),
|
|
225
|
+
summary: '',
|
|
226
|
+
state: 'running',
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (chunk.type === 'reasoning-delta') {
|
|
231
|
+
if (!Number.isSafeInteger(chunk.index)) return;
|
|
232
|
+
let ordinal = turn.thoughtOfBlock.get(chunk.index);
|
|
233
|
+
if (ordinal === undefined) {
|
|
234
|
+
ordinal = turn.nextThought;
|
|
235
|
+
turn.nextThought += 1;
|
|
236
|
+
turn.thoughtOfBlock.set(chunk.index, ordinal);
|
|
237
|
+
turn.thoughts.set(ordinal, '');
|
|
238
|
+
// **思考也算一步**:从第一段推理就开始算,不必等到它调工具。
|
|
239
|
+
putStep(turn, thinkKey(ordinal), { kind: 'think', index: ordinal, summary: '', state: 'running' });
|
|
240
|
+
}
|
|
241
|
+
turn.thoughts.set(ordinal, appendText(turn.thoughts.get(ordinal), chunk.text));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (chunk.type === 'block-end') {
|
|
245
|
+
const index = Number.isSafeInteger(chunk.index) ? chunk.index : null;
|
|
246
|
+
if (index === null) return;
|
|
247
|
+
const callId = turn.callIdByIndex.get(index);
|
|
248
|
+
if (callId) {
|
|
249
|
+
// 参数流完了(接下来才是执行):这一刻的摘要就是用户要看的那个路径 / 命令。
|
|
250
|
+
refreshToolStep(turn, callId);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const ordinal = turn.thoughtOfBlock.get(index);
|
|
254
|
+
if (ordinal !== undefined) {
|
|
255
|
+
settleThought(turn, ordinal);
|
|
256
|
+
turn.thoughtOfBlock.delete(index);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (event.type === 'tool/result') {
|
|
262
|
+
const message = data.message;
|
|
263
|
+
const src = message && message.source;
|
|
264
|
+
if (!src || typeof src.callId !== 'string') return;
|
|
265
|
+
const current = turn.tools.get(src.callId) || { name: '', argsRaw: '' };
|
|
266
|
+
const { parts, isError } = resultParts(message);
|
|
267
|
+
const output = parts
|
|
268
|
+
.map((part) => (part && typeof part.text === 'string' ? part.text : ''))
|
|
269
|
+
.filter(Boolean)
|
|
270
|
+
.join('');
|
|
271
|
+
const diffs = data.meta && Array.isArray(data.meta.diffs) ? data.meta.diffs : undefined;
|
|
272
|
+
const code = data.error && typeof data.error.code === 'string' ? data.error.code : undefined;
|
|
273
|
+
// 失败有两种写法:`message.isError` 与 `data.error.code`(宿主两种都发过),任一都算失败。
|
|
274
|
+
const failed = isError || code !== undefined;
|
|
275
|
+
turn.tools.set(src.callId, {
|
|
276
|
+
...current,
|
|
277
|
+
output: output.length > TEXT_MAX ? output.slice(0, TEXT_MAX) : output,
|
|
278
|
+
...(failed ? { error: code === 'interrupted' ? 'stopped' : 'error' } : { error: null }),
|
|
279
|
+
...(diffs ? { diffs } : {}),
|
|
280
|
+
});
|
|
281
|
+
refreshToolStep(turn, src.callId);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (event.type === 'turn/end') {
|
|
285
|
+
turn.ended = true;
|
|
286
|
+
// 这一轮收口:还在跑的那几步按成功收尾(宿主不会再补一条结果给它们)。
|
|
287
|
+
closeThoughtsOfStep(turn);
|
|
288
|
+
for (const [callId, tool] of turn.tools) {
|
|
289
|
+
if (tool.error === undefined) putStep(turn, toolKey(callId), { state: 'ok' });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 这一轮到目前为止跑过的步(App 侧的过程清单)。
|
|
296
|
+
*
|
|
297
|
+
* 只给「一行摘要 + 状态 + 位置」:正文一个字节都不在这里,点开某一步才走 `/session/step`。
|
|
298
|
+
*/
|
|
299
|
+
function liveStepsOf(ctx, sessionId) {
|
|
300
|
+
if (!ctx || typeof sessionId !== 'string' || !sessionId) return [];
|
|
301
|
+
const turn = liveStepTurnOf(ctx).get(sessionId);
|
|
302
|
+
if (!turn) return [];
|
|
303
|
+
return turn.steps.map((entry) => ({ key: entry.key, step: { ...entry.step } }));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 取走「还没告诉过手机」的那几步。
|
|
308
|
+
*
|
|
309
|
+
* 每条都是 `{ key, step }`:新的一步就是追加,同一步的状态变化就是按 `key` 替换。
|
|
310
|
+
*/
|
|
311
|
+
function drainLiveSteps(ctx, sessionId) {
|
|
312
|
+
if (!ctx || typeof sessionId !== 'string' || !sessionId) return [];
|
|
313
|
+
const turn = liveStepTurnOf(ctx).get(sessionId);
|
|
314
|
+
if (!turn || turn.pending.length === 0) return [];
|
|
315
|
+
const out = turn.pending.slice();
|
|
316
|
+
turn.pending.length = 0;
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 取一步的直播详情;没有这一份(换了一轮、或这一步已经落盘)返回 null,由调用方转历史。
|
|
322
|
+
*/
|
|
323
|
+
function liveStepOf(ctx, sessionId, query) {
|
|
324
|
+
if (!ctx || typeof sessionId !== 'string' || !sessionId) return null;
|
|
325
|
+
const bucket = liveStepTurnOf(ctx);
|
|
326
|
+
const turn = bucket.get(sessionId);
|
|
327
|
+
if (!turn) return null;
|
|
328
|
+
if (query && query.kind === 'think') {
|
|
329
|
+
const index = Number.isSafeInteger(query.index) ? query.index : null;
|
|
330
|
+
if (index === null) return null;
|
|
331
|
+
const text = turn.thoughts.get(index);
|
|
332
|
+
if (typeof text !== 'string' || text === '') return null;
|
|
333
|
+
return { kind: 'think', text, live: true, state: turn.ended ? 'ok' : 'running' };
|
|
334
|
+
}
|
|
335
|
+
const callId = query && typeof query.callId === 'string' ? query.callId : '';
|
|
336
|
+
if (!callId) return null;
|
|
337
|
+
const tool = turn.tools.get(callId);
|
|
338
|
+
if (!tool) return null;
|
|
339
|
+
const state = tool.error === undefined ? (turn.ended ? 'ok' : 'running') : (tool.error || 'ok');
|
|
340
|
+
return {
|
|
341
|
+
kind: 'tool',
|
|
342
|
+
live: true,
|
|
343
|
+
...(tool.name ? { name: tool.name } : {}),
|
|
344
|
+
state,
|
|
345
|
+
...(tool.argsRaw ? { argsRaw: tool.argsRaw } : {}),
|
|
346
|
+
...(typeof tool.output === 'string' && tool.output ? { output: tool.output } : {}),
|
|
347
|
+
...(Array.isArray(tool.diffs) && tool.diffs.length > 0 ? { diffs: tool.diffs } : {}),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** 丢掉一条会话的直播副本(会话关掉、或者换设备时用)。 */
|
|
352
|
+
function clearLiveSteps(ctx, sessionId) {
|
|
353
|
+
liveStepTurnOf(ctx).delete(sessionId);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
module.exports = { noteLiveEvent, liveStepOf, liveStepsOf, drainLiveSteps, clearLiveSteps };
|