dsh-mobile-plugin 0.5.17 → 0.5.19
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/config/agent-presets/standard/agent.cordis.yml +9 -0
- package/host.js +26 -4
- package/lib/host-compat.cjs +51 -4
- package/lib/impl.js +6 -0
- package/lib/mobile/cli/acp.js +216 -0
- package/lib/mobile/cli/agents.js +217 -0
- package/lib/mobile/cli/blocks.js +228 -0
- package/lib/mobile/cli/config-prefs.js +155 -0
- package/lib/mobile/cli/icons.js +138 -0
- package/lib/mobile/cli/images.js +132 -0
- package/lib/mobile/cli/mobile.js +1552 -0
- package/lib/mobile/cli/model-prefs.js +77 -0
- package/lib/mobile/cli/model-visibility.js +92 -0
- package/lib/mobile/cli/registry.js +207 -0
- package/lib/mobile/cli/service.js +901 -0
- package/lib/mobile/cli/usage.js +133 -0
- package/lib/mobile/monitor.js +17 -6
- package/lib/mobile/narration.js +205 -0
- package/lib/mobile/phone/binding.js +247 -0
- package/lib/mobile/phone/index.js +63 -0
- package/lib/mobile/phone/routes.js +168 -0
- package/lib/mobile/phone/service.js +327 -0
- package/lib/mobile/phone/tools.js +565 -0
- package/lib/mobile/questions.js +9 -3
- package/lib/mobile/services.js +16 -0
- package/lib/mobile/session-send.js +1 -0
- package/lib/mobile/stats-ledger.cjs +8 -23
- package/lib/mobile/terminal.js +574 -0
- package/lib/routes/mobile-api.js +139 -3
- package/package.json +2 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 一轮用掉多少 —— ACP 报上来的用量,翻成对话页底部那行指标认识的形状。
|
|
5
|
+
*
|
|
6
|
+
* 两个来源,都是协议里现成的,不去猜:
|
|
7
|
+
*
|
|
8
|
+
* - `usage_update` 通知:`used` / `size` 是**上下文窗口**占了多少(两家都给,opencode 实测
|
|
9
|
+
* 每轮末尾发一条,`size` 20 万);
|
|
10
|
+
* - `session/prompt` 的回包:`usage` 里是**整条会话累计**的输入 / 输出 / 缓存读写 token
|
|
11
|
+
* (`inputTokens` 明确写着「across all turns」)。
|
|
12
|
+
*
|
|
13
|
+
* 手机上那行指标要三项:token 速度、缓存命中率、上下文占用。对应关系——
|
|
14
|
+
*
|
|
15
|
+
* - 上下文占用 = `used` / `size`;
|
|
16
|
+
* - 缓存命中率 = cacheRead / (uncached + cacheRead + cacheWrite),与宿主那条 `cacheHitPercent`
|
|
17
|
+
* 同一个公式(那一份在 App 里,插件这边照抄口径,不另立一套);
|
|
18
|
+
* - token 速度 = 这一轮的输出 token / 这一轮花的时间。
|
|
19
|
+
*
|
|
20
|
+
* 报不上来的就是**没有**,不补零:`usage_update` 一次都没来过(有的适配器不发)时不给
|
|
21
|
+
* `contextPressure`,指标行自然少一段,而不是显示「上下文 0%」。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** 每个会话一份,挂在 session 对象上(会话收掉时一起没)。 */
|
|
25
|
+
function createUsage() {
|
|
26
|
+
return {
|
|
27
|
+
/** 上下文:最近一次 `usage_update`。 */
|
|
28
|
+
used: null,
|
|
29
|
+
size: null,
|
|
30
|
+
/** prompt 回包里的累计用量(最后一次见到的为准)。 */
|
|
31
|
+
prompt: null,
|
|
32
|
+
/** 上一轮 prompt 的累计快照:两个相减就是这一轮的量。 */
|
|
33
|
+
previous: null,
|
|
34
|
+
/** 累计轮数与耗时。 */
|
|
35
|
+
turns: 0,
|
|
36
|
+
llmMs: 0,
|
|
37
|
+
/** 上一轮的输出量与耗时(token 速度用它)。 */
|
|
38
|
+
decodeTokens: 0,
|
|
39
|
+
decodeMs: 0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function num(value) {
|
|
44
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `usage_update` 通知 → 上下文占用。 */
|
|
48
|
+
function applyUsageUpdate(usage, update) {
|
|
49
|
+
const used = num(update && update.used);
|
|
50
|
+
const size = num(update && update.size);
|
|
51
|
+
if (used !== null) usage.used = used;
|
|
52
|
+
if (size !== null && size > 0) usage.size = size;
|
|
53
|
+
return used !== null || size !== null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 一轮结束:记下这一轮的输出量与耗时,并把累计用量换成回包那一份。
|
|
58
|
+
*
|
|
59
|
+
* `elapsedMs` 是从发出 prompt 到它 resolve 的墙钟时间 —— ACP 不给逐轮的耗时,这是唯一
|
|
60
|
+
* 拿得到的口径,所以「token 速度」这一项是**整轮平均**,不是解码瞬时速度。够用:这一行
|
|
61
|
+
* 存在的意义是「快不快」,不是基准测试。
|
|
62
|
+
*/
|
|
63
|
+
function applyPromptUsage(usage, result, elapsedMs) {
|
|
64
|
+
const next = result && result.usage;
|
|
65
|
+
const elapsed = num(elapsedMs) || 0;
|
|
66
|
+
if (next && typeof next === 'object') {
|
|
67
|
+
const prev = usage.prompt;
|
|
68
|
+
const output = num(next.outputTokens);
|
|
69
|
+
const prevOutput = prev ? (num(prev.outputTokens) || 0) : 0;
|
|
70
|
+
usage.decodeTokens = output === null ? 0 : Math.max(0, output - prevOutput);
|
|
71
|
+
usage.prompt = {
|
|
72
|
+
inputTokens: num(next.inputTokens),
|
|
73
|
+
outputTokens: output,
|
|
74
|
+
totalTokens: num(next.totalTokens),
|
|
75
|
+
cachedReadTokens: num(next.cachedReadTokens),
|
|
76
|
+
cachedWriteTokens: num(next.cachedWriteTokens),
|
|
77
|
+
thoughtTokens: num(next.thoughtTokens),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (elapsed > 0) {
|
|
81
|
+
usage.llmMs += elapsed;
|
|
82
|
+
// 没有累计用量时(适配器不回 `usage`)至少还能给一个耗时:单看它也说明这一轮多久。
|
|
83
|
+
usage.decodeMs = usage.decodeTokens > 0 ? elapsed : 0;
|
|
84
|
+
}
|
|
85
|
+
usage.turns += 1;
|
|
86
|
+
usage.previous = usage.prompt;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 缓存命中率(整数百分比);没有输入计费量时 null(与 App 的 `cacheHitPercent` 同一个分支)。 */
|
|
90
|
+
function cacheHitPercent(cacheRead, billed) {
|
|
91
|
+
return billed === 0 ? null : Math.round((cacheRead / billed) * 100);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 会话 → `/session/stats` 的投影。
|
|
96
|
+
*
|
|
97
|
+
* 只给**真有数的**那几项,其余不出现(App 那边缺席就是「没有」,不会画成 0)。
|
|
98
|
+
*/
|
|
99
|
+
function statsOf(usage) {
|
|
100
|
+
const out = {};
|
|
101
|
+
const prompt = usage.prompt;
|
|
102
|
+
if (prompt && prompt.inputTokens !== null) {
|
|
103
|
+
const cachedRead = prompt.cachedReadTokens || 0;
|
|
104
|
+
const cachedWrite = prompt.cachedWriteTokens || 0;
|
|
105
|
+
out.tokenUsage = {
|
|
106
|
+
// ACP 的 `inputTokens` 是**含缓存**的总输入,App 那边三个桶是分开的(`uncached` 是
|
|
107
|
+
// 真正新读的那部分),所以这里减掉两个缓存桶,别把缓存算成新读。
|
|
108
|
+
uncachedInputTokens: Math.max(0, prompt.inputTokens - cachedRead - cachedWrite),
|
|
109
|
+
outputTokens: prompt.outputTokens || 0,
|
|
110
|
+
cacheReadTokens: cachedRead,
|
|
111
|
+
cacheWriteTokens: cachedWrite,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (usage.used !== null && usage.size !== null) {
|
|
115
|
+
out.contextPressure = { contextWindow: usage.size, pressureTokens: usage.used };
|
|
116
|
+
}
|
|
117
|
+
if (usage.turns > 0) {
|
|
118
|
+
out.sessionStats = {
|
|
119
|
+
turns: usage.turns,
|
|
120
|
+
// 这一版不认工具调用耗时与首字延迟:ACP 不给,编一个比空着更糟。
|
|
121
|
+
steps: 0,
|
|
122
|
+
llmMs: usage.llmMs,
|
|
123
|
+
toolMs: 0,
|
|
124
|
+
ttftMs: 0,
|
|
125
|
+
ttftSteps: 0,
|
|
126
|
+
decodeMs: usage.decodeMs,
|
|
127
|
+
decodeTokens: usage.decodeTokens,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { createUsage, applyUsageUpdate, applyPromptUsage, statsOf, cacheHitPercent };
|
package/lib/mobile/monitor.js
CHANGED
|
@@ -106,7 +106,7 @@ function monitorItem(summary, state, fact) {
|
|
|
106
106
|
* `{ error }` —— 两种都由调用方照 `/sessions` 的口径回,别在这里编一个空清单:空清单会被
|
|
107
107
|
* 原生当成「所有会话都没了」,基线一清,下一轮就为所有正在跑的会话重新报一次完成。
|
|
108
108
|
*/
|
|
109
|
-
async function buildMonitorState(ctx, deviceKey) {
|
|
109
|
+
async function buildMonitorState(ctx, deviceKey, extraItems = null) {
|
|
110
110
|
const { value, error } = unwrap(await callSessions(ctx, 'list', {}, 'mobile'));
|
|
111
111
|
if (error) return { error };
|
|
112
112
|
const summaries = Array.isArray(value) ? value : (value && Array.isArray(value.items) ? value.items : null);
|
|
@@ -174,6 +174,17 @@ async function buildMonitorState(ctx, deviceKey) {
|
|
|
174
174
|
// 拿不到预览就整个字段不出,老 App 会忽略它。
|
|
175
175
|
}
|
|
176
176
|
}));
|
|
177
|
+
// CLI 会话(`cli-*`)的监控条目由适配层按同一套字段给(见 mobile/cli/mobile.js 的
|
|
178
|
+
// monitorItems):它们是 ACP 子进程、不在宿主的会话清单里,但「在跑 / 刚完成 /
|
|
179
|
+
// 等你审批」这三件事对后台提醒是一回事。注入成一个取值器,这里不认识 CLI 的任何细节。
|
|
180
|
+
if (typeof extraItems === 'function') {
|
|
181
|
+
try {
|
|
182
|
+
const extra = extraItems();
|
|
183
|
+
if (Array.isArray(extra) && extra.length > 0) items.push(...extra);
|
|
184
|
+
} catch {
|
|
185
|
+
// 适配层取不出来的话就当没有 CLI 会话:后台提醒照旧为宿主会话工作。
|
|
186
|
+
}
|
|
187
|
+
}
|
|
177
188
|
// 按 id 排序后再算指纹:宿主是按 updatedAt 排的,聊一句话顺序就换,指纹会跟着每轮都变,
|
|
178
189
|
// 条件刷新等于白做。监控视图不关心顺序。
|
|
179
190
|
items.sort((a, b) => (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
|
|
@@ -215,10 +226,10 @@ function waitForChange(timeoutMs, res) {
|
|
|
215
226
|
}
|
|
216
227
|
|
|
217
228
|
/** 算一次监控视图,把「宿主出错 / 形状异常」也收成一个可下发的响应。 */
|
|
218
|
-
async function buildMonitorView(ctx, deviceKey) {
|
|
229
|
+
async function buildMonitorView(ctx, deviceKey, extraItems = null) {
|
|
219
230
|
let built;
|
|
220
231
|
try {
|
|
221
|
-
built = await buildMonitorState(ctx, deviceKey);
|
|
232
|
+
built = await buildMonitorState(ctx, deviceKey, extraItems);
|
|
222
233
|
} catch (error) {
|
|
223
234
|
return {
|
|
224
235
|
status: 500,
|
|
@@ -247,7 +258,7 @@ async function buildMonitorView(ctx, deviceKey) {
|
|
|
247
258
|
* 只挂一次:醒来之后不管变没变都如实回(变没变由 revision 说了算),由手机自己决定
|
|
248
259
|
* 要不要再挂。这样这里没有循环、没有重试,断线也不会在宿主侧留下一个转不停的请求。
|
|
249
260
|
*/
|
|
250
|
-
async function handleMonitor(ctx, req, res) {
|
|
261
|
+
async function handleMonitor(ctx, req, res, extraItems = null) {
|
|
251
262
|
const deviceKey = canonicalDeviceKey(req);
|
|
252
263
|
const url = new URL(req.url, 'http://x');
|
|
253
264
|
const rev = (url.searchParams.get('rev') || '').trim();
|
|
@@ -255,7 +266,7 @@ async function handleMonitor(ctx, req, res) {
|
|
|
255
266
|
// 没有 rev 就不挂:那不是「确认一下有没有变」,而是「给我一份完整状态」,立刻回。
|
|
256
267
|
const waitMs = rev ? Math.max(0, Math.min(WAIT_MAX_MS, Math.round(waitSeconds * 1000))) : 0;
|
|
257
268
|
|
|
258
|
-
const first = await buildMonitorView(ctx, deviceKey);
|
|
269
|
+
const first = await buildMonitorView(ctx, deviceKey, extraItems);
|
|
259
270
|
if (first.status !== 200) {
|
|
260
271
|
return sendJson(res, first.status, first.body);
|
|
261
272
|
}
|
|
@@ -272,7 +283,7 @@ async function handleMonitor(ctx, req, res) {
|
|
|
272
283
|
await waitForChange(waitMs, res);
|
|
273
284
|
// 手机在这段时间里断了(切网 / 进程被杀):别为一个没人收的响应再算一遍。
|
|
274
285
|
if (res.destroyed || res.writableEnded) return;
|
|
275
|
-
const again = await buildMonitorView(ctx, deviceKey);
|
|
286
|
+
const again = await buildMonitorView(ctx, deviceKey, extraItems);
|
|
276
287
|
const body = again.status === 200 ? (unchangedListBody(again.body, rev) || again.body) : again.body;
|
|
277
288
|
// 头已经出去了,不能再走 sendJson(它会再写一次头):这里只补正文。
|
|
278
289
|
try { res.end(JSON.stringify(body)); } catch { /* 连接已断开 */ }
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 过程播报:让模型在一轮跑久了之后,用一两句话说明它此刻在做什么、卡在哪里。
|
|
5
|
+
*
|
|
6
|
+
* 默认关。用户看到的现场是「问完之后 AI 闷头干到底,中途一句话都没有」——模型在工具循环里
|
|
7
|
+
* 本来没有任何开口的理由(中间没有新的用户消息),而进度条只是一行机器计数。开关打开后,
|
|
8
|
+
* 插件在模型动手之前往这一步的上下文里递一句提醒,它就会顺带讲一句。
|
|
9
|
+
*
|
|
10
|
+
* 递话走 `agent/pre-step` 的 waterfall(与开发地图那套「你卡住了」的补报同一条通道):
|
|
11
|
+
* 追加一条 **非 user 来源**的 user 消息,模型下一步就带着它思考。来源不是 `user` 这一条
|
|
12
|
+
* 不能省:App 直播与历史推导都把非 user 来源当上下文注入丢掉,所以这句话本身不会冒充成
|
|
13
|
+
* 用户自己发的气泡 —— 用户看到的只有模型因此说出的那几句正文。
|
|
14
|
+
*
|
|
15
|
+
* 代价照实说:这些话是真进对话正文的,会留在这一轮的历史里、也花 token。所以缺省关、
|
|
16
|
+
* 每台电脑各存一份(`<DSH_HOME>/storages/mobile-plugin/process-narration.json`)。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const { randomUUID } = require('node:crypto');
|
|
22
|
+
|
|
23
|
+
const { dshHome } = require('../shared/paths');
|
|
24
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
25
|
+
|
|
26
|
+
/** 第一句的门槛:一轮至少跑这么久才值得开口(短任务说一句话是纯噪声)。 */
|
|
27
|
+
const NARRATION_FIRST_MS = Number(process.env.DSH_MOBILE_NARRATION_FIRST_MS) || 90 * 1000;
|
|
28
|
+
/** 之后每隔这么久最多再说一句。 */
|
|
29
|
+
const NARRATION_INTERVAL_MS = Number(process.env.DSH_MOBILE_NARRATION_INTERVAL_MS) || 90 * 1000;
|
|
30
|
+
/** 一轮最多说几句:再多就是把工具循环变成播报台。 */
|
|
31
|
+
const NARRATION_MAX_PER_TURN = 3;
|
|
32
|
+
/**
|
|
33
|
+
* 来源标记。**必须不是 `user`**:App 的 `userMessageOrigin` 与 `history-derive` 都按
|
|
34
|
+
* `source.kind !== 'user'` 把注入丢掉,改这两处的口径就等于让提醒变成用户气泡。
|
|
35
|
+
*/
|
|
36
|
+
const NARRATION_SOURCE_KIND = 'mobile-narration';
|
|
37
|
+
/** 记「这一轮跑多久了」的表:会话数有限,封顶防慢泄漏。 */
|
|
38
|
+
const TRACKED_SESSIONS = 64;
|
|
39
|
+
|
|
40
|
+
function settingsFile() {
|
|
41
|
+
return path.join(dshHome(), 'storages', 'mobile-plugin', 'process-narration.json');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 读开关。读不到 / 读坏都当「关」—— 会往用户对话里塞消息、花 token 的事,缺省必须在关的
|
|
46
|
+
* 那一侧(与收件目录自动清理同一个口径,与自动更新的缺省相反)。
|
|
47
|
+
*/
|
|
48
|
+
function narrationEnabled() {
|
|
49
|
+
try {
|
|
50
|
+
const raw = JSON.parse(fs.readFileSync(settingsFile(), 'utf8'));
|
|
51
|
+
return raw?.enabled === true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 写开关。写不进去就抛,让调用方如实回失败(回「开好了」却没落盘是最坏的一种)。 */
|
|
58
|
+
function setNarration(enabled) {
|
|
59
|
+
const file = settingsFile();
|
|
60
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
61
|
+
fs.writeFileSync(file, `${JSON.stringify({ enabled: enabled === true }, null, 2)}\n`);
|
|
62
|
+
return narrationEnabled();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 造消息的工厂:宿主给的 `createUserMessage` 会品牌化 id 并 deepFreeze,取不到时退回等价对象。 */
|
|
66
|
+
let createUserMessagePromise = null;
|
|
67
|
+
function resolveCreateUserMessage() {
|
|
68
|
+
if (!createUserMessagePromise) {
|
|
69
|
+
createUserMessagePromise = import('@deepseek-ai/dsh-llm')
|
|
70
|
+
.then((llm) => llm?.createUserMessage ?? null)
|
|
71
|
+
.catch(() => null);
|
|
72
|
+
}
|
|
73
|
+
return createUserMessagePromise;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 那一句提醒的正文。
|
|
78
|
+
*
|
|
79
|
+
* 写法的三条规矩(与开发地图那套补报同一口径):
|
|
80
|
+
* 1. **说清这不是用户说的话**:它长得像系统提示,不说明白模型会当成用户在催它;
|
|
81
|
+
* 2. **给动作,不给问题**:模型没有「我该不该汇报」这个自我认知,直接说「用一两句话说」;
|
|
82
|
+
* 3. **允许它说「没什么可说的」**:没有卡点还要硬编一句,读起来比不说更糟。
|
|
83
|
+
*/
|
|
84
|
+
function narrationText() {
|
|
85
|
+
return [
|
|
86
|
+
'<system-reminder>',
|
|
87
|
+
'这是一条进度提醒,**不是用户说的话**。',
|
|
88
|
+
'请在这一轮继续之前,先用一两句自然的话(就是正常对用户说话的口气,不要写成报告或条目)说清:现在做到哪一步、有没有卡住或绕路。',
|
|
89
|
+
'没有什么值得说的就继续干活,不用勉强编一句。',
|
|
90
|
+
'说的时候不要提这条提醒本身。',
|
|
91
|
+
'</system-reminder>',
|
|
92
|
+
].join('\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function buildHint() {
|
|
96
|
+
const input = {
|
|
97
|
+
content: [{ type: 'text', text: narrationText() }],
|
|
98
|
+
source: { kind: NARRATION_SOURCE_KIND },
|
|
99
|
+
};
|
|
100
|
+
const create = await resolveCreateUserMessage();
|
|
101
|
+
if (create) return create(input);
|
|
102
|
+
return Object.freeze({ ...input, role: 'user', id: randomUUID() });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** sessionId → { turn, startedAt, lastAt, count }。 */
|
|
106
|
+
const sessions = new Map();
|
|
107
|
+
|
|
108
|
+
function trackOf(sessionId, turn, now) {
|
|
109
|
+
const current = sessions.get(sessionId);
|
|
110
|
+
if (current && current.turn === turn) return current;
|
|
111
|
+
// 换轮就重开一份:门槛按轮算,不跟上一条消息的水位混在一起。
|
|
112
|
+
const fresh = { turn, startedAt: now, lastAt: 0, count: 0 };
|
|
113
|
+
sessions.delete(sessionId);
|
|
114
|
+
sessions.set(sessionId, fresh);
|
|
115
|
+
while (sessions.size > TRACKED_SESSIONS) sessions.delete(sessions.keys().next().value);
|
|
116
|
+
return fresh;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 这一步该不该递话。
|
|
121
|
+
*
|
|
122
|
+
* 纯判断,输入是「这一轮跑了多久、已经说过几句、上次说是什么时候」,方便守卫直接真跑。
|
|
123
|
+
*/
|
|
124
|
+
function narrationDecision({ elapsedMs, said, sinceLastMs }) {
|
|
125
|
+
if (elapsedMs < NARRATION_FIRST_MS) return false;
|
|
126
|
+
if (said >= NARRATION_MAX_PER_TURN) return false;
|
|
127
|
+
if (said > 0 && sinceLastMs < NARRATION_INTERVAL_MS) return false;
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `agent/pre-step` 的监听:跑得够久就追加一句提醒。
|
|
133
|
+
*
|
|
134
|
+
* 任何失败都吞掉 —— 播报是锦上添花,读盘失败或消息造不出来不该让用户这一轮发不出去。
|
|
135
|
+
* 子代理会话直接跳过:它的上下文更紧,而且那不是用户在等的那个对话。
|
|
136
|
+
*/
|
|
137
|
+
async function onPreStep(payload, next) {
|
|
138
|
+
const decision = await next();
|
|
139
|
+
try {
|
|
140
|
+
// 拒收的那一档没有 messages 可追加(`decision.kind === 'reject'`),原样放行。
|
|
141
|
+
if (!decision || !Array.isArray(decision.messages)) return decision;
|
|
142
|
+
if (!narrationEnabled()) return decision;
|
|
143
|
+
const header = payload?.agent?.session?.header;
|
|
144
|
+
if (!header?.id || header.origin === 'subagent') return decision;
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
const track = trackOf(header.id, payload.turn, now);
|
|
147
|
+
const wanted = narrationDecision({
|
|
148
|
+
elapsedMs: now - track.startedAt,
|
|
149
|
+
said: track.count,
|
|
150
|
+
sinceLastMs: track.lastAt ? now - track.lastAt : Number.POSITIVE_INFINITY,
|
|
151
|
+
});
|
|
152
|
+
if (!wanted) return decision;
|
|
153
|
+
const hint = await buildHint();
|
|
154
|
+
track.count += 1;
|
|
155
|
+
track.lastAt = now;
|
|
156
|
+
return { ...decision, messages: [...decision.messages, hint] };
|
|
157
|
+
} catch {
|
|
158
|
+
return decision;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 挂上钩子。返回清理函数(`ctx.effect` 要的那个)。 */
|
|
163
|
+
function installNarration(ctx) {
|
|
164
|
+
return ctx.on('agent/pre-step', onPreStep);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* GET /api/mobile/v1/narration —— 当前开关(手机打开菜单时读一次)。
|
|
169
|
+
* POST /api/mobile/v1/narration —— `{ enabled: boolean }` 拨开关。
|
|
170
|
+
*
|
|
171
|
+
* 两条收在同一路径上由 `req.method` 分派(宿主的注册器不按方法分派,与 `/relay/config` 同一条路)。
|
|
172
|
+
*/
|
|
173
|
+
async function handleNarration(_ctx, req, res) {
|
|
174
|
+
if (req && req.method === 'POST') {
|
|
175
|
+
const body = await readJsonBody(req);
|
|
176
|
+
if (!body || typeof body.enabled !== 'boolean') {
|
|
177
|
+
sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'enabled 必须是布尔值' } });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
sendJson(res, 200, { ok: true, enabled: setNarration(body.enabled) });
|
|
182
|
+
} catch (error) {
|
|
183
|
+
sendJson(res, 500, {
|
|
184
|
+
ok: false,
|
|
185
|
+
error: { code: 'write-failed', message: `开关没写进去:${error && error.message ? error.message : error}` },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
sendJson(res, 200, { ok: true, enabled: narrationEnabled() });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
installNarration,
|
|
195
|
+
narrationEnabled,
|
|
196
|
+
setNarration,
|
|
197
|
+
narrationDecision,
|
|
198
|
+
narrationText,
|
|
199
|
+
handleNarration,
|
|
200
|
+
settingsFile,
|
|
201
|
+
NARRATION_FIRST_MS,
|
|
202
|
+
NARRATION_INTERVAL_MS,
|
|
203
|
+
NARRATION_MAX_PER_TURN,
|
|
204
|
+
NARRATION_SOURCE_KIND,
|
|
205
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 「这条会话操作哪台手机」的绑定账。
|
|
5
|
+
*
|
|
6
|
+
* 规则只有一条:**会话第一次收到某台手机发来的消息时,把会话绑到那台手机上**,之后不管
|
|
7
|
+
* 用户在 B 手机还是平板上继续聊,都还是操作 A。绑定要落盘 —— 宿主重启、插件热替换之后
|
|
8
|
+
* 它还在,否则用户第二天回来会发现任务跑到另一台手机上去了。
|
|
9
|
+
*
|
|
10
|
+
* 另记一本「最近一次发消息的是谁」。它不进绑定,只用来在两件事上有话可说:
|
|
11
|
+
*
|
|
12
|
+
* 1. 工具执行时发现「这一轮来自 B、绑着 A」,就把这件事交给模型,让它问一句要不要改绑,
|
|
13
|
+
* 而不是闷头操纵 A;
|
|
14
|
+
* 2. 没有任何绑定的老会话(这个功能上线之前就存在的对话)第一次要操作手机时,按最近一次
|
|
15
|
+
* 发消息的那台算,而不是干等。
|
|
16
|
+
*
|
|
17
|
+
* 显式改绑走 `setBinding`(模型调 `phone_target`),它只改绑定,不改最近发消息的人 ——
|
|
18
|
+
* 后者是事实,改了就成了编。
|
|
19
|
+
*
|
|
20
|
+
* 两本账都按 sessionId 存,条目上限与已读回执同一量级:会话被删掉时这里不会跟着删
|
|
21
|
+
* (宿主不知道),只按最近活动淘汰,留着一条指向已删会话的记录只是几百字节。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const paths = require('../../shared/paths');
|
|
27
|
+
|
|
28
|
+
/** sessionId 与 deviceId 的长度上限(真实值都是 UUID 量级)。 */
|
|
29
|
+
const MAX_ID = 200;
|
|
30
|
+
/** 条目上限:按 lastAt 保留最新的。 */
|
|
31
|
+
const MAX_ENTRIES = 2000;
|
|
32
|
+
|
|
33
|
+
/** 文件:`$DSH_HOME/phone-bindings.json`。DSH_HOME 在调用时解析,测试可注入。 */
|
|
34
|
+
function stateFile() {
|
|
35
|
+
return path.join(paths.dshHome(), 'phone-bindings.json');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** null = 还没写过;否则 `{ [sessionId]: { bound, boundAt, last, lastAt } }`。 */
|
|
39
|
+
let table = null;
|
|
40
|
+
|
|
41
|
+
function load() {
|
|
42
|
+
if (table) return table;
|
|
43
|
+
try {
|
|
44
|
+
const raw = JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
|
|
45
|
+
table = raw && typeof raw === 'object' && raw.sessions && typeof raw.sessions === 'object'
|
|
46
|
+
? raw.sessions
|
|
47
|
+
: {};
|
|
48
|
+
} catch {
|
|
49
|
+
table = {};
|
|
50
|
+
}
|
|
51
|
+
return table;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function text(value) {
|
|
55
|
+
return typeof value === 'string' ? value.trim().slice(0, MAX_ID) : '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 只留合法形状,并按 lastAt 截断 —— 盘上的文件可能是旧版本或被人手改过。 */
|
|
59
|
+
function normalize(entry) {
|
|
60
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
61
|
+
const bound = text(entry.bound);
|
|
62
|
+
const last = text(entry.last);
|
|
63
|
+
if (!bound && !last) return null;
|
|
64
|
+
const boundAt = Number.isFinite(entry.boundAt) ? entry.boundAt : 0;
|
|
65
|
+
const lastAt = Number.isFinite(entry.lastAt) ? entry.lastAt : 0;
|
|
66
|
+
return { bound, boundAt, last, lastAt };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function persist() {
|
|
70
|
+
const entries = Object.entries(load())
|
|
71
|
+
.map(([id, entry]) => [id, normalize(entry)])
|
|
72
|
+
.filter(([, entry]) => entry);
|
|
73
|
+
if (entries.length > MAX_ENTRIES) {
|
|
74
|
+
entries.sort((a, b) => (b[1].lastAt || b[1].boundAt) - (a[1].lastAt || a[1].boundAt));
|
|
75
|
+
entries.length = MAX_ENTRIES;
|
|
76
|
+
}
|
|
77
|
+
const sessions = Object.fromEntries(entries);
|
|
78
|
+
try {
|
|
79
|
+
fs.mkdirSync(path.dirname(stateFile()), { recursive: true });
|
|
80
|
+
const tmp = `${stateFile()}.tmp`;
|
|
81
|
+
fs.writeFileSync(tmp, JSON.stringify({ version: 1, sessions }), 'utf8');
|
|
82
|
+
fs.renameSync(tmp, stateFile());
|
|
83
|
+
} catch {
|
|
84
|
+
// 落盘失败不该让一次动作中断:内存里那份仍然是对的,下一次写再试。
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function entryOf(sessionId) {
|
|
89
|
+
const id = text(sessionId);
|
|
90
|
+
if (!id) return null;
|
|
91
|
+
return normalize(load()[id]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 这条会话绑在哪台手机上;没有绑定时为 null。 */
|
|
95
|
+
function boundDeviceOf(sessionId) {
|
|
96
|
+
const entry = entryOf(sessionId);
|
|
97
|
+
return entry && entry.bound ? entry.bound : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 最近一次给这条会话发消息的是哪台手机;没有记录时为 null。 */
|
|
101
|
+
function lastSenderOf(sessionId) {
|
|
102
|
+
const entry = entryOf(sessionId);
|
|
103
|
+
return entry && entry.last ? entry.last : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 记一次「这台手机发来了消息」。**同时**在会话还没有绑定时把它绑上去。
|
|
108
|
+
*
|
|
109
|
+
* 只有 deviceId 非空才动账:没带设备头的调用(桌面端、测试、老客户端)不该把一条已经绑好
|
|
110
|
+
* 的会话改成「未绑定」,也不该把新会话绑到一台认不出的设备上。
|
|
111
|
+
*
|
|
112
|
+
* @returns {{bound: string, changed: boolean}} changed 表示这一次真的建立了绑定
|
|
113
|
+
*/
|
|
114
|
+
function noteSender(sessionId, deviceId, now = Date.now()) {
|
|
115
|
+
const id = text(sessionId);
|
|
116
|
+
const device = text(deviceId);
|
|
117
|
+
const current = entryOf(id);
|
|
118
|
+
if (!id || !device) return { bound: current ? current.bound : '', changed: false };
|
|
119
|
+
const entry = current || { bound: '', boundAt: 0, last: '', lastAt: 0 };
|
|
120
|
+
const changed = !entry.bound;
|
|
121
|
+
if (changed) {
|
|
122
|
+
entry.bound = device;
|
|
123
|
+
entry.boundAt = now;
|
|
124
|
+
}
|
|
125
|
+
entry.last = device;
|
|
126
|
+
entry.lastAt = now;
|
|
127
|
+
load()[id] = entry;
|
|
128
|
+
persist();
|
|
129
|
+
return { bound: entry.bound, changed };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 显式改绑(模型调 `phone_target`)。只改 bound,`last` 原样留着。
|
|
134
|
+
*
|
|
135
|
+
* @returns {boolean} 有没有真的写下去(空 deviceId 一律拒)
|
|
136
|
+
*/
|
|
137
|
+
function setBinding(sessionId, deviceId, now = Date.now()) {
|
|
138
|
+
const id = text(sessionId);
|
|
139
|
+
const device = text(deviceId);
|
|
140
|
+
if (!id || !device) return false;
|
|
141
|
+
const entry = entryOf(id) || { bound: '', boundAt: 0, last: '', lastAt: 0 };
|
|
142
|
+
entry.bound = device;
|
|
143
|
+
entry.boundAt = now;
|
|
144
|
+
if (!entry.lastAt) entry.lastAt = now;
|
|
145
|
+
load()[id] = entry;
|
|
146
|
+
persist();
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 从一次请求里认出「发消息的是哪台手机」,记进账。
|
|
152
|
+
*
|
|
153
|
+
* 挂在**发送成功之后**:发失败的那一发什么都没发生,拿它当「用户在这台手机上说话」会让一条
|
|
154
|
+
* 会话被一条没送达的消息绑到某台机器上。没带设备头的调用(桌面端、老客户端、测试)落在
|
|
155
|
+
* `legacy`,那不是任何一台手机,一律不记 —— 记了就会把已经绑好的账改成别的。
|
|
156
|
+
*
|
|
157
|
+
* 失败绝不影响调用方:绑定只是「下次操作哪台手机」的默认值,记账出错不该让一条已经交出去的
|
|
158
|
+
* 消息报失败。
|
|
159
|
+
*/
|
|
160
|
+
function noteRequestSender(req, sessionId) {
|
|
161
|
+
try {
|
|
162
|
+
const raw = req && req.headers ? req.headers['x-dsh-device'] : undefined;
|
|
163
|
+
const deviceId = text(Array.isArray(raw) ? raw[0] : raw);
|
|
164
|
+
if (!deviceId) return;
|
|
165
|
+
noteSender(sessionId, deviceId);
|
|
166
|
+
} catch { /* 绑定账写不进去不影响这一发 */ }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 某台手机不再被操作时(用户解除配对),把**指向它**的绑定一条条清掉。
|
|
171
|
+
*
|
|
172
|
+
* 只清 `bound` 指向它的那些;`last` 也指向它的不动 —— 那是「谁发过消息」的事实,不该因为
|
|
173
|
+
* 解绑就变成另一台。清掉之后这条会话回到「没有绑定」,下一次发消息的设备会重新绑上。
|
|
174
|
+
*
|
|
175
|
+
* @returns {number} 清了几条
|
|
176
|
+
*/
|
|
177
|
+
function clearBindingForDevice(deviceId, now = Date.now()) {
|
|
178
|
+
const device = text(deviceId);
|
|
179
|
+
if (!device) return 0;
|
|
180
|
+
const state = load();
|
|
181
|
+
let removed = 0;
|
|
182
|
+
for (const [id, entry] of Object.entries(state)) {
|
|
183
|
+
const normalized = normalize(entry);
|
|
184
|
+
if (!normalized || normalized.bound !== device) continue;
|
|
185
|
+
normalized.bound = '';
|
|
186
|
+
normalized.boundAt = 0;
|
|
187
|
+
if (!normalized.last) { delete state[id]; continue; }
|
|
188
|
+
normalized.lastAt = normalized.lastAt || now;
|
|
189
|
+
state[id] = normalized;
|
|
190
|
+
removed += 1;
|
|
191
|
+
}
|
|
192
|
+
if (removed) persist();
|
|
193
|
+
return removed;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 会话被删 / 用户主动解绑时清掉。 */
|
|
197
|
+
function clearBinding(sessionId) {
|
|
198
|
+
const id = text(sessionId);
|
|
199
|
+
if (!id) return false;
|
|
200
|
+
const state = load();
|
|
201
|
+
if (!(id in state)) return false;
|
|
202
|
+
delete state[id];
|
|
203
|
+
persist();
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 这一次要操作哪台手机。
|
|
209
|
+
*
|
|
210
|
+
* 顺序:显式 target(调用方本次传入)→ 绑定 → 最近一次发消息的那台。三者都没有就是
|
|
211
|
+
* `null` —— 调用方必须把这当成「没有目标」报出来,**不许**拿「随便挑一台在线的」顶上:
|
|
212
|
+
* 那会让一条对话在用户不知情时去操作另一台手机。
|
|
213
|
+
*
|
|
214
|
+
* @returns {{deviceId: string, from: 'explicit'|'bound'|'sender'|'', changed: boolean}}
|
|
215
|
+
*/
|
|
216
|
+
function resolveTarget(sessionId, explicit) {
|
|
217
|
+
const wanted = text(explicit);
|
|
218
|
+
if (wanted) {
|
|
219
|
+
const current = boundDeviceOf(sessionId);
|
|
220
|
+
const changed = current !== wanted;
|
|
221
|
+
if (changed) setBinding(sessionId, wanted);
|
|
222
|
+
return { deviceId: wanted, from: 'explicit', changed };
|
|
223
|
+
}
|
|
224
|
+
const bound = boundDeviceOf(sessionId);
|
|
225
|
+
if (bound) return { deviceId: bound, from: 'bound', changed: false };
|
|
226
|
+
const sender = lastSenderOf(sessionId);
|
|
227
|
+
if (sender) return { deviceId: sender, from: 'sender', changed: true };
|
|
228
|
+
return { deviceId: '', from: '', changed: false };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 测试用:丢掉内存里那份(下一个用例重新从盘上读)。 */
|
|
232
|
+
function resetForTest() {
|
|
233
|
+
table = null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = {
|
|
237
|
+
stateFile,
|
|
238
|
+
boundDeviceOf,
|
|
239
|
+
lastSenderOf,
|
|
240
|
+
noteSender,
|
|
241
|
+
noteRequestSender,
|
|
242
|
+
setBinding,
|
|
243
|
+
clearBinding,
|
|
244
|
+
clearBindingForDevice,
|
|
245
|
+
resolveTarget,
|
|
246
|
+
resetForTest,
|
|
247
|
+
};
|