dsh-mobile-plugin 0.4.7 → 0.4.8

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.
@@ -21,6 +21,7 @@ const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
21
21
  const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal.js');
22
22
  const { dropSessionListCache } = require('../mobile/session-cache.js');
23
23
  const { observeCompletionEvent } = require('../mobile/completion.js');
24
+ const { observeAutoContinue } = require('../mobile/auto-continue.js');
24
25
  const { lastTurnOf } = require('../mobile/session-last-turn.js');
25
26
 
26
27
  /** 后台循环每个 tick 补推一个冷会话,保证没人动的会话镜像也不长期不更新。 */
@@ -771,6 +772,9 @@ function createMirrorPusher(deps) {
771
772
  // session/stream 的 SSE 负责。不跳的话每条增量都要重建五份全量快照并推一次
772
773
  // sidecar,形成推送风暴(配合 pushSessionImmediately 的单飞才有稳定上界)。
773
774
  observeCompletionEvent(ctx, sessionId, p?.event);
775
+ // 手机发起的轮被输出 token 上限截断时代发一句「继续」(见 mobile/auto-continue.js)。
776
+ // 只认 turn/end,其余事件进来一行就退;它自己吞掉所有异常,不拖这条流。
777
+ void observeAutoContinue(ctx, sessionId, p?.event);
774
778
  if (p?.event?.type === 'assistant/chunk') continue;
775
779
  // iOS 推送:turn/end 是「可能刚收尾」的信号,把事实(lastTurnOf 现读)交给
776
780
  // 通知器判断——只有 completed / failed 且这一轮之前没推过才会真发一帧。
@@ -0,0 +1,143 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 输出 token 用满之后替用户续一句「继续」。
5
+ *
6
+ * 宿主把 `finish=max-tokens` 折成 `turn/end(reason.kind='max-tokens')` 就收尾这一轮,
7
+ * 客户端只会画一行固定提示(桌面 `message.maxTokens` 那两句,App 侧是 `misc.structural.maxTokens`)
8
+ * ——要不要接着写是人的事。手机上这就等于每次长回答被截断,用户都得亲手打两个字;
9
+ * 而在手机上打字正是最费劲的那种操作。所以这里在宿主侧替他发这一句。
10
+ *
11
+ * 三条边界,缺一条就是替用户乱说话:
12
+ *
13
+ * - **只续手机发起的轮**:会话里最后一条 user 消息必须带移动端 rpcId 前缀
14
+ * (`mobile:`,与 `/session/send` 那条路同源,见 history-derive 的
15
+ * `clientMessageIdFromSource`)。电脑上开的轮一动不动 —— 桌面那边用户本来就在键盘前。
16
+ * - **一串最多续 {@link AUTO_CONTINUE_MAX_ROUNDS} 次**:真写满额度、或者思考死循环带出一段
17
+ * 可见正文时都会报 max-tokens(循环守卫只有在「思考是唯一产出」时才报 REASONING_LOOP),
18
+ * 无限续就是无限烧额度。续够次数就停手,把那一行提示留给用户。
19
+ * - **同一轮只续一次**:事件流重连会重放 `turn/end`,不认这一条就是连发好几条「继续」。
20
+ *
21
+ * 续写走的是 `sessions.prompt`(与 App 的 `/session/send` 同一条路),所以它会作为一条
22
+ * 普通的 user 消息落进会话:用户看得见、能接着聊,也能据此判断「刚才那是它替我发的」。
23
+ */
24
+
25
+ const { callSessions, mobilePromptRpcId } = require('./rpc.js');
26
+ const { unwrap } = require('./envelope.js');
27
+ const { clientMessageIdFromSource } = require('./history-derive.js');
28
+ const { autoContinueTurnsOf } = require('./session-cache.js');
29
+
30
+ /** 代发的那句话。短、明确、不夹带额外指令 —— 模型看见的就是用户会打的那两个字。 */
31
+ const AUTO_CONTINUE_TEXT = '继续';
32
+
33
+ /**
34
+ * 一串里最多代发几次。
35
+ *
36
+ * 三次是「够写完一篇长答复」与「别在病态输出上无限烧额度」之间的折中:按 8192 输出上限
37
+ * 算,四次加总三万多 token,正常长文到这已经写完了。
38
+ */
39
+ const AUTO_CONTINUE_MAX_ROUNDS = 3;
40
+
41
+ /**
42
+ * 代发消息的 clientMessageId 前缀。
43
+ *
44
+ * 它同时是**认回自己**的唯一凭据:这一串续了几次不另开计数器,而是回读会话里最后几条
45
+ * user 消息数连续的那几条 —— 宿主重启、事件重放、用户中途插话,三种情况都不会数错。
46
+ */
47
+ const AUTO_CONTINUE_CLIENT_PREFIX = 'auto-continue-';
48
+
49
+ /** 回读会话时取的消息条数:只要够看到「上一个人类发言」,不必读全量。 */
50
+ const HISTORY_MESSAGE_LIMIT = 40;
51
+
52
+ /** 这条 user 消息是不是我们自己代发的。 */
53
+ function isAutoContinueId(clientMessageId) {
54
+ return typeof clientMessageId === 'string' && clientMessageId.startsWith(AUTO_CONTINUE_CLIENT_PREFIX);
55
+ }
56
+
57
+ /**
58
+ * 数「这一轮是手机发起的吗」「这一串已经代发了几次」。
59
+ *
60
+ * 返回 `{ rounds }` 表示可以续(`rounds` 是已续次数);`null` 表示这一轮不该续
61
+ * —— 最后一条人类发言不是从手机来的,或者压根读不到会话。
62
+ *
63
+ * 只认**最后一条非代发的 user 消息**:它是这一轮的起点。中点插话(steer)也是 user 消息,
64
+ * 它同样带移动端 rpcId,所以「手机上补一句」不会把宿主判成「这是电脑开的轮」。
65
+ */
66
+ async function chainState(ctx, sessionId) {
67
+ const response = await callSessions(ctx, 'history', { sessionId, maxMessages: HISTORY_MESSAGE_LIMIT });
68
+ const result = unwrap(response);
69
+ if (result.error) return null;
70
+ const events = (result.value && Array.isArray(result.value.events)) ? result.value.events : [];
71
+
72
+ let rounds = 0;
73
+ for (let index = events.length - 1; index >= 0; index -= 1) {
74
+ const entry = events[index];
75
+ const event = entry && entry.event ? entry.event : entry;
76
+ if (!event || event.type !== 'user/message' || !event.data) continue;
77
+ const clientMessageId = clientMessageIdFromSource(event.data.source, event.data);
78
+ if (isAutoContinueId(clientMessageId)) {
79
+ rounds += 1;
80
+ continue;
81
+ }
82
+ // 收起循环:第一条不是我们代发的 user 消息就是这一轮的起点。
83
+ if (clientMessageId === undefined) return null;
84
+ return { rounds };
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * 发那一句「继续」。
91
+ *
92
+ * `requestId` 必须走 `mobilePromptRpcId`:宿主要把它写进 user 消息的 `source.rpcId`,
93
+ * 上面的 `chainState` 才能在下一轮认出「这条是我们发的」,App 也能把它当成本设备发的消息
94
+ * 认领(否则手机上会同时出现一条乐观行和一条来历不明的消息)。
95
+ */
96
+ function sendContinue(ctx, sessionId, turn, round) {
97
+ const clientMessageId = `${AUTO_CONTINUE_CLIENT_PREFIX}${turn}-${round}`;
98
+ const rpcId = mobilePromptRpcId(clientMessageId);
99
+ return callSessions(ctx, 'prompt', {
100
+ sessionId,
101
+ requestId: rpcId,
102
+ content: [{ type: 'text', text: AUTO_CONTINUE_TEXT }],
103
+ }, rpcId);
104
+ }
105
+
106
+ /**
107
+ * 吃一条宿主会话事件;是「手机发起的这一轮被输出上限截断了」就代发一句「继续」。
108
+ *
109
+ * 由镜像推送那条全局事件循环调用(`lib/mirror/pusher.cjs`,与 `observeCompletionEvent` 同一处)。
110
+ * **绝不抛**:它是事件循环里的一步,抛出去会把镜像推送整条流带走;代发失败没有代价
111
+ * ——那一行「已达到输出 token 上限」照旧在界面上,用户还能自己点。
112
+ *
113
+ * @returns {Promise<boolean>} 是否真的代发了一条(给守卫用)。
114
+ */
115
+ async function observeAutoContinue(ctx, sessionId, event) {
116
+ if (!sessionId || !event || event.type !== 'turn/end') return false;
117
+ const data = event.data;
118
+ if (!data || typeof data.turn !== 'number') return false;
119
+ if (!data.reason || data.reason.kind !== 'max-tokens') return false;
120
+
121
+ const turn = data.turn;
122
+ const map = autoContinueTurnsOf(ctx);
123
+ if (map.get(sessionId) === turn) return false; // 这一轮已经代发过(事件重放)
124
+ // 先占位再 await:同一轮的 turn/end 可能在历史读完之前又到一条。
125
+ map.set(sessionId, turn);
126
+ try {
127
+ const chain = await chainState(ctx, sessionId);
128
+ if (!chain || chain.rounds >= AUTO_CONTINUE_MAX_ROUNDS) return false;
129
+ await sendContinue(ctx, sessionId, turn, chain.rounds);
130
+ return true;
131
+ } catch {
132
+ // 占位撤掉:这一发是网络 / 网关的瞬时失败,下一次同类事件还该再试一次。
133
+ if (map.get(sessionId) === turn) map.delete(sessionId);
134
+ return false;
135
+ }
136
+ }
137
+
138
+ module.exports = {
139
+ AUTO_CONTINUE_TEXT,
140
+ AUTO_CONTINUE_MAX_ROUNDS,
141
+ AUTO_CONTINUE_CLIENT_PREFIX,
142
+ observeAutoContinue,
143
+ };
@@ -757,6 +757,13 @@ function deriveMessages(events) {
757
757
  if (bucket && bucket.size > 0) item.producedFiles = [...bucket];
758
758
  }
759
759
  }
760
+ // 压缩行带一个空正文:直播侧的同款行本来就带 `text: ''`(App 的 `structuralItems`
761
+ // 一条不落),历史这边少这一格时,没跟上判空修复的旧安装包会在这条行上直接读正文、
762
+ // 当场抛错、整页崩掉(2026-10-27 真机)。标题与摘要照旧按 kind 走,不读这个字段;
763
+ // 其它结构行刻意不带(与 `history-turn-end` 守卫钉的形状一致)。
764
+ for (const item of out) {
765
+ if (item.kind === 'compaction' && typeof item.text !== 'string') item.text = '';
766
+ }
760
767
  // 严格按事件 seq 升序(与消息混排;compaction 行升级/替换后位置以最终事件为准)。
761
768
  out.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
762
769
  return out;
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 会话侧的按 ctx 隔离状态:十三张表,一个入口。
4
+ * 会话侧的按 ctx 隔离状态:十四张表,一个入口。
5
5
  *
6
6
  * 这些表此前全是 sessions.js 的模块级 WeakMap。它们之所以必须按 ctx 隔离,是因为
7
7
  * 它们的值属于**宿主进程状态**(某个会话正在跑、某个列表刚构建完、某条发送已经
@@ -47,6 +47,10 @@ const writeIdempotency = new WeakMap();
47
47
  // 与 sessionListResponseCache 同一类东西(列表刚构建完的那份是谁),只是按工作区算。
48
48
  const workspaceShortcutStates = new WeakMap();
49
49
 
50
+ // 输出 token 用满之后「这一轮已经替用户代发过『继续』」的记账(见 mobile/auto-continue.js)。
51
+ // 只用来挡事件流重连的重放;续了几次由会话历史数出来,不放这里。
52
+ const autoContinueTurns = new WeakMap();
53
+
50
54
  /** 设备维度:同一个 ctx 下多台手机各自一份,缺省用 'legacy'(未带 x-dsh-device 的调用)。 */
51
55
  function canonicalDeviceKey(req, fallback = 'legacy') {
52
56
  const value = req?.headers?.['x-dsh-device'];
@@ -241,6 +245,21 @@ function workspaceShortcutStateOf(ctx) {
241
245
  return state;
242
246
  }
243
247
 
248
+ /**
249
+ * 「这一轮已经替用户代发过『继续』」的记账:sessionId → 轮次。
250
+ *
251
+ * 只挡事件流重连的重放(同一轮会到两条 `turn/end`),不做次数上限 —— 次数由会话历史里
252
+ * 连续的代发消息数出来,见 `mobile/auto-continue.js` 的文件头。
253
+ */
254
+ function autoContinueTurnsOf(ctx) {
255
+ let turns = autoContinueTurns.get(ctx);
256
+ if (!turns) {
257
+ turns = new Map();
258
+ autoContinueTurns.set(ctx, turns);
259
+ }
260
+ return turns;
261
+ }
262
+
244
263
  function completionGenerationOf(ctx, deviceKey) {
245
264
  let generations = completionGenerations.get(ctx);
246
265
  if (!generations) {
@@ -387,6 +406,7 @@ module.exports = {
387
406
  clearCompletionForAllDevices,
388
407
  completionGenerationOf,
389
408
  workspaceShortcutStateOf,
409
+ autoContinueTurnsOf,
390
410
  bumpCompletionGeneration,
391
411
  sendIdempotencyStateOf,
392
412
  promptOnceFor,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "engines": {