dsh-mobile-plugin 0.4.6 → 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,
@@ -87,6 +87,10 @@ class RelayTunnel {
87
87
  this.httpPumpCursor = 0;
88
88
  this.pendingHttpResponseBytes = 0;
89
89
  this.lastPong = 0;
90
+ // 对端存不存镜像:注册回执里明确说了才改,没说(老中转)按「存」处理,行为与以前一字
91
+ // 不差。纯转发节点(`MIRROR_CACHE=0`)收下整份镜像也只解析完丢掉,worker 据此只把
92
+ // 摘要推上去(lib/sidecar-facade-worker.cjs 的 digestFrame)。
93
+ this.relayMirror = true;
90
94
  // 清除所有授权的等待回调(一次一个);由 requestRevokeAll 注入,revokeAllAck 触发。
91
95
  this._revokeAllAck = null;
92
96
  }
@@ -462,6 +466,9 @@ class RelayTunnel {
462
466
 
463
467
  /** 建立连接并注册;resolve 于收到 registered,reject 于连接/注册失败 */
464
468
  start() {
469
+ // 每条新连接都从「对端存镜像」重新判定:能走到推送的那一步一定已经收到过回执,
470
+ // 而回执缺这个字段的老中转本来就该按「存」处理。
471
+ this.relayMirror = true;
465
472
  return new Promise((resolve, reject) => {
466
473
  let settled = false;
467
474
  const done = (fn, arg) => {
@@ -482,6 +489,9 @@ class RelayTunnel {
482
489
 
483
490
  const push = createFrameDecoder((frame) => {
484
491
  if (frame.type === T.registered) {
492
+ // 回执里带 `mirror`:对端说不存镜像,就只推摘要(凭据 + 两个 revision),正文不推。
493
+ // 老中转回的是空载荷,`parseJson` 给空对象,于是判定为「存」——与以前完全一样。
494
+ this.relayMirror = parseJson(frame.payload).mirror !== false;
485
495
  clearTimeout(timer);
486
496
  done(resolve);
487
497
  this.onRegistered();
@@ -41,6 +41,9 @@ let tunnel = null
41
41
  let lan = null
42
42
  let tunnelState = 'off'
43
43
  let tunnelError = null
44
+ // 当前这条隧道的对端存不存镜像(注册回执里报的)。false ⇒ 宿主只推摘要帧,正文不推。
45
+ // 未知与老中转一律按 true:那是原来的行为,别让一次漏字段把镜像功能悄悄降级。
46
+ let relayMirror = true
44
47
  let lanState = 'off'
45
48
  let lanError = null
46
49
  // 局域网代理**实际**在监听的端口。0 = 还没起来。首选端口被占时会退到内核分配的
@@ -62,7 +65,7 @@ function lanUrlPort() { return lanBoundPort || config.lanPort }
62
65
  function publishState() {
63
66
  process.send?.({
64
67
  type: 'state',
65
- tunnel: { state: tunnelState, error: tunnelError },
68
+ tunnel: { state: tunnelState, error: tunnelError, mirror: relayMirror },
66
69
  lan: { enabled: Boolean(config.lanEnabled), port: lanUrlPort(), requestedPort: config.lanPort, state: lanState, error: lanError },
67
70
  })
68
71
  }
@@ -122,11 +125,40 @@ const TUNNEL_SEND_MAX_BODY = 8 * 1024 * 1024 - 64 * 1024
122
125
  * 裁字段而不是改推送策略:宿主那侧「推什么、什么时候推」一个字都不用改,两侧版本
123
126
  * 不必同时上线(老 worker + 新宿主也照旧工作,只是白推;新 worker + 老宿主也照旧)。
124
127
  */
125
- function frameForTunnel(body) {
128
+ /**
129
+ * 对端不存镜像时上隧道的那一帧:只留中转还会真的读的东西。
130
+ *
131
+ * 纯转发节点(`MIRROR_CACHE=0`)的 `mirror::remember` 只留凭据摘要与时间戳,两份清单正文
132
+ * 解析完就丢——那是每台宿主每 5 秒几十 KB、一天上百 GB 的白推。所以对端在注册回执里说
133
+ * `mirror:false` 之后,清单与工作区各只留 `ok` 和 `revision`:
134
+ *
135
+ * - `ok` 必须留着,中转用它判「这份镜像是不是畸形」;
136
+ * - `revision` 必须留着,它是中转镜像账本里那一版的身份;
137
+ * - `creds` 必须留着,`/push-token` 的鉴权真源就是这本账里的摘要,丢了它上报全 403。
138
+ *
139
+ * 本地快照不受影响:`localSnapshot` 在这一帧被裁之前就已经赋值,局域网直连的读取隔离
140
+ * 读的是它(见 `/tunnel/send` 那一段)。
141
+ */
142
+ function digestFrame(body) {
143
+ const pick = (value) => {
144
+ const out = { ok: true }
145
+ if (value && typeof value === 'object' && typeof value.revision === 'string') out.revision = value.revision
146
+ return out
147
+ }
148
+ return {
149
+ kind: 'sync',
150
+ at: body.at,
151
+ sessions: pick(body.sessions),
152
+ workspaces: pick(body.workspaces),
153
+ creds: Array.isArray(body.creds) ? body.creds : [],
154
+ }
155
+ }
156
+
157
+ function frameForTunnel(body, mirrorOff = false) {
126
158
  if (!body || typeof body !== 'object') return { kind: 'raw', data: body }
127
159
  if (body.kind !== 'sync') return body
128
160
  const { messages, stats, models, ...rest } = body
129
- return rest
161
+ return mirrorOff ? digestFrame(rest) : rest
130
162
  }
131
163
  // /configure 只送 relay.json 那几十个字段,给 64KB 绰绰有余;放宽只会让误用更难发现。
132
164
  const CONFIGURE_MAX_BODY = 64 * 1024
@@ -255,6 +287,8 @@ function stopTunnel() {
255
287
  reconnect.cancel()
256
288
  if (tunnel) tunnel.stop()
257
289
  tunnel = null; tunnelState = 'off'; tunnelError = null
290
+ // 断开即忘掉上一台中转的口径:下次连的可能是另一台,别拿旧的判断去决定推不推正文。
291
+ relayMirror = true
258
292
  publishState()
259
293
  }
260
294
  function startNetworkMonitor() {
@@ -297,8 +331,10 @@ function openTunnel() {
297
331
  // 「bg queue: sidecar facade request failed: 503」刷屏,而隧道其实在正常转发会话)。
298
332
  onRegistered: () => {
299
333
  // 注册成功 = 链路真的通了:连续失败账清零,下次掉线重新从 5s 起算。
334
+ // 回执里的镜像口径(`relayMirror`)跟着状态一起改、一起播,同样只在这道身份闸之内:
335
+ // 旧实例迟到的回执不许改新连接的口径。
300
336
  if (tunnel !== current) return; reconnect.reset()
301
- tunnelState = 'connected'; tunnelError = null; publishState()
337
+ tunnelState = 'connected'; tunnelError = null; relayMirror = current.relayMirror !== false; publishState()
302
338
  },
303
339
  onClose: () => { if (tunnel === current && config.enabled) { tunnelState = 'error'; tunnelError = '连接断开'; publishState(); scheduleReconnect() } },
304
340
  onPairRequest: async (message) => {
@@ -444,7 +480,7 @@ function status() {
444
480
  // 量不到的一律报 null、不拿 0 顶替:0 是「一条都没有」,与「不知道」是两件事。
445
481
  const lanStreams = lan && typeof lan.stats === 'function' ? lan.stats() : null
446
482
  const relaySseOpen = tunnel ? (typeof tunnel.upstreamSseCount === 'number' ? tunnel.upstreamSseCount : null) : 0
447
- return { ok: true, sidecar: true, enabled: Boolean(config.enabled), relayUrl: config.relayUrl || '', instanceId: config.instanceId, name: config.name, localPort: config.upstreamPort, tunnel: { state: tunnelState, error: tunnelError }, lan: { enabled: Boolean(config.lanEnabled), port: lanUrlPort(), requestedPort: config.lanPort, state: lanState, error: lanError }, process: { memoryUsage: process.memoryUsage() }, streams: { lan: lanStreams, relaySseOpen } }
483
+ return { ok: true, sidecar: true, enabled: Boolean(config.enabled), relayUrl: config.relayUrl || '', instanceId: config.instanceId, name: config.name, localPort: config.upstreamPort, tunnel: { state: tunnelState, error: tunnelError, mirror: relayMirror }, lan: { enabled: Boolean(config.lanEnabled), port: lanUrlPort(), requestedPort: config.lanPort, state: lanState, error: lanError }, process: { memoryUsage: process.memoryUsage() }, streams: { lan: lanStreams, relaySseOpen } }
448
484
  }
449
485
  const server = http.createServer(async (req, res) => {
450
486
  try {
@@ -470,7 +506,7 @@ const server = http.createServer(async (req, res) => {
470
506
  const frameType = body && typeof body === 'object' && body.kind === 'push'
471
507
  ? (body.pushFrame === 'register' ? T.pushRegister : T.pushSend)
472
508
  : T.sync;
473
- tunnel.sendJson(frameType, 0, frameForTunnel(body));
509
+ tunnel.sendJson(frameType, 0, frameForTunnel(body, relayMirror === false));
474
510
  return send(res, 202, { ok: true });
475
511
  }
476
512
  return send(res, 503, { ok: false, error: 'tunnel not connected' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.4.6",
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": {