dsh-mini-remote 0.1.3 → 0.1.5

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/lib/events.js CHANGED
@@ -3,6 +3,9 @@
3
3
  * 1. 用户真正发出的指令(不是插件注入的合成上下文)
4
4
  * 2. Agent 一轮任务的最终回复(不是中间的工具调用和思考)
5
5
  *
6
+ * 另有一个**只给鲸鱼娘气泡用**的出口 stepSelfTalk():中间步骤的旁白一句话
7
+ * (思考不上手机,见那段注释)。它和上面两样严格分开——不记历史、不顶替回答。
8
+ *
6
9
  * 依据 DSH 0.1.5-rc.2 实测的事件契约:
7
10
  * SessionEvent = { type, seq, time, data }
8
11
  * - turn/start data: { turn }
@@ -59,6 +62,55 @@ export function looksLikeToolMarkup(text) {
59
62
  return typeof text === 'string' && TOOL_MARKUP.test(text)
60
63
  }
61
64
 
65
+ /**
66
+ * 中间步骤的「自言自语」。
67
+ *
68
+ * 2026-09-26 用户要的新功能:让手机上的鲸鱼娘把模型执行步骤时的那句自说自话
69
+ * 说出来。**它不进回答区**——那是 2026-09-22 用户亲自否掉的做法(过程的文字跑进
70
+ * 回答区、来了又撤,他说「就像泄露出来的一样」)。这里取出来的东西只走鲸鱼娘的
71
+ * 气泡,和 latest/history 一个字都不碰。
72
+ *
73
+ * **只念旁白**(2026-09-27 用户两次收紧后定下来的口径):
74
+ * 1. 中间步骤的 text 块(「我先看看这个文件」)——它本来就是一句说给人听的话,
75
+ * 中位 45 字,天生适合气泡。
76
+ * 2. **思维链一个字都不上气泡。** 先是我把「退而取思考首行」那道兜底限定成
77
+ * 「整句没有汉字就不念」(英文的思考摆在中文界面里是噪音),用户随后把话
78
+ * 说到底:**思考首行整个不要展示**——不分中英文。这一条比兜底更干净:气泡里
79
+ * 出现的每一句,都是我特意写给人看的;我的草稿(无论什么语言)只留在电脑上。
80
+ *
81
+ * 没旁白就不念(气泡留着上一句,而不是闪空)。
82
+ */
83
+
84
+ /** 折叠内部空白:一段话里的换行和缩进不必原样带进气泡。 */
85
+ function tidySelfTalk(text) {
86
+ return String(text).replace(/\s+/g, ' ').trim()
87
+ }
88
+
89
+ /**
90
+ * 从一条助手消息的内容里取出「自言自语」。
91
+ *
92
+ * **只有中间步骤才有**:不含工具调用的那一步是最终回答,它归回答区,不归气泡。
93
+ * **只认旁白**(2026-09-27 用户定的):思维链一个字都不取,无论中英文——所以那一步
94
+ * 只想了没写旁白时,返回 null,气泡留着上一句,而不是端一句草稿出来。
95
+ *
96
+ * **不截断**(2026-09-27 用户报的:长句在手机上被切掉半行)。这里原来掐到 80 字、
97
+ * 末尾补省略号;用户要的是「语句完全展现」,于是断句这件事整个交回给手机页面——
98
+ * 那边不限行数、让气泡长高,只留一道 max-height 兜病态长文。两处都掐等于两处都不清。
99
+ *
100
+ * @returns {{kind:'narration', text:string}|null}
101
+ */
102
+ export function stepSelfTalk(content) {
103
+ if (!isIntermediateStep(content)) return null
104
+
105
+ const narration = visibleText(content)
106
+ if (narration && !looksLikeToolMarkup(narration)) {
107
+ const text = tidySelfTalk(narration)
108
+ if (text) return { kind: 'narration', text }
109
+ }
110
+
111
+ return null
112
+ }
113
+
62
114
  /**
63
115
  * 一轮任务的累计器。
64
116
  *
package/lib/index.js CHANGED
@@ -5,9 +5,12 @@
5
5
  * - 不代理 DSH 桌面界面(那是 dsh-pocket 的做法),手机只拿两样东西:
6
6
  * 用户发出的指令 + Agent 的最终回复。
7
7
  * - 不监听 waterfall 事件。只订阅纯 emit 事件,避免误吞 agent 的默认行为。
8
- * - 不 import 任何 @deepseek-ai/* 的运行时值。DSH 事件契约已经用 .d.ts 核实,
9
- * 消息对象自己造(MessageId 的 brand 在运行时是恒等函数、不做校验),
10
- * 这样插件加载期零外部依赖,也不会有 cordis 双副本的坑。
8
+ * - 不 import 任何 @deepseek-ai/* 的运行时值,只有一个例外:@deepseek-ai/schemastery。
9
+ * 它只用来声明 Config(见下面 export const Config)——DSH 0.1.7 起,只有把字段标成
10
+ * volatile,改它才不需要重载插件,而声明 schema 绕不开 schemastery。它由宿主提供
11
+ * (写进 peerDependencies、不进 dependencies,免得出现第二份副本)。
12
+ * 其余照旧:事件契约已经用 .d.ts 核实,消息对象自己造(MessageId 的 brand 在运行时
13
+ * 是恒等函数、不做校验),插件加载期几乎没有外部依赖,也没有 cordis 双副本的坑。
11
14
  *
12
15
  * 事件契约(DSH 0.1.5-rc.2 实测):
13
16
  * session/event(session, event) event = { type, seq, time, data }
@@ -22,8 +25,10 @@ import { homedir } from 'node:os'
22
25
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
23
26
  import { randomBytes, randomUUID } from 'node:crypto'
24
27
 
28
+ import z from '@deepseek-ai/schemastery'
29
+
25
30
  import { createStore } from './store.js'
26
- import { createTurnTracker, visibleText } from './events.js'
31
+ import { createTurnTracker, visibleText, stepSelfTalk } from './events.js'
27
32
  import { replayHistory, lastReply } from './history.js'
28
33
  import { readTailEvents } from './log-tail.js'
29
34
  import {
@@ -108,6 +113,60 @@ const DEFAULTS = {
108
113
  },
109
114
  }
110
115
 
116
+ /**
117
+ * 给 schema 标上 volatile("改了不用重载")。
118
+ *
119
+ * 为什么要绕这一层判断:`.volatile()` 是 schemastery **3.18.3** 才加的方法,3.18.1/3.18.2
120
+ * 都没有。DSH 0.1.5 声明的依赖范围是 `^3.18.2`,是可以落到没有它的那一版上的——那种环境下
121
+ * 直接写 `.volatile()` 会在**加载插件的那一刻**抛异常,整个插件起不来,手机上什么都没有。
122
+ * 这个插件一贯的做法是"少了哪样就关掉哪一块,不整个倒下",所以这里降级处理:没有这个方法
123
+ * 就原样返回,插件照常可用,只是少了"改配置不用重载"这一条(`liveConfig` 那边也会因此
124
+ * 认不出 volatile 字段,读到的就是普通值,行为与从前一致)。
125
+ */
126
+ export function markVolatile(schema) {
127
+ return typeof schema?.volatile === 'function' ? schema.volatile() : schema
128
+ }
129
+
130
+ /**
131
+ * 插件设置的标准声明(DSH 0.1.7 起的做法)。宿主拿它校验 profile 补丁给我们的 config,
132
+ * 设置页那条编辑通道也按它生成表单——**只有标了 volatile 的字段才会出现在那张表单里**。
133
+ *
134
+ * **为什么一个 .default() 都没有**:schema 里的默认值会被 Loader 落进 config,而我们的
135
+ * 合并顺序是 `DEFAULTS < settings.json < config`——那样一来 schema 默认值就会盖住用户存在
136
+ * settings.json 里的值。留空即 undefined,deepMerge 会跳过 undefined,内置默认值照旧只在
137
+ * 下面 DEFAULTS 写一处。
138
+ *
139
+ * **为什么只有两个字段标 .volatile()**:标了就是承诺「改它不用重载插件」,而承诺得在代码里
140
+ * 兑现——这类字段必须用的时候现读(见 liveConfig)。端口和绑定地址要重新监听、隧道要重建、
141
+ * 口令的真相在 token 文件和手机页面的改密入口,这些改完确实要重建插件,就不标:DSH 会替我们
142
+ * 重载,那才是实话。宁可少标,不能乱标。
143
+ */
144
+ export const Config = z.object({
145
+ port: z.number().description('手机上打开的那个端口。改了要重载插件(得重新监听)。'),
146
+ bindAddress: z.string().description("监听哪个地址:'auto'、'127.0.0.1' 或具体某个 IP。改了要重载插件。"),
147
+ tunnel: z.object({
148
+ enabled: z.boolean(),
149
+ cloudflaredPath: z.string(),
150
+ }).description('公网隧道(cloudflared)。改了要重载插件。'),
151
+ token: z.string().role('secret')
152
+ .description('访问口令。留空 = 首次启动自动生成;改口令用手机页面上的改密入口,改完要重载插件。'),
153
+ maxHistory: z.number().description('手机端每个会话保留多少条历史。改了要重载插件。'),
154
+ defaultMode: markVolatile(z.string())
155
+ .description("手机打开时默认进哪个模式:'minimal' 或 'chat'。改了立即生效,不用重载。"),
156
+ notify: markVolatile(z.object({
157
+ enabled: z.boolean(),
158
+ channel: z.string(),
159
+ barkUrl: z.string(),
160
+ ntfyUrl: z.string(),
161
+ ntfyTopic: z.string(),
162
+ telegramBotToken: z.string().role('secret'),
163
+ telegramChatId: z.string(),
164
+ onlyWhenBackground: z.boolean(),
165
+ maxLength: z.number(),
166
+ sound: z.string(),
167
+ })).description('外部推送(Bark / ntfy / Telegram)。改了立即生效,不用重载。'),
168
+ })
169
+
111
170
  function deepMerge(base, ...overrides) {
112
171
  const out = { ...base }
113
172
  for (const patch of overrides) {
@@ -178,13 +237,43 @@ function userMessage(text, files = []) {
178
237
  })
179
238
  }
180
239
 
240
+ /**
241
+ * 把标了 volatile 的配置字段接到 settings 上:读的时候现取,不在加载时抄一份。
242
+ *
243
+ * 为什么必须现取:DSH 对 volatile 字段的处理是「就地提交、不重建插件」——它只把新值写进那个
244
+ * 引用里,再 emit 一次 loader/volatile-update。要是我们加载时抄了一份,就地更新就永远看不见,
245
+ * 那这个声明比不声明更糟:DSH 不再重载我们,而我们手里还是旧值。
246
+ *
247
+ * 取值仍沿用原来的合并顺序:config 里显式写了就用 config(undefined 算没写),没写就用
248
+ * settings.json / 内置默认值那一路(`localBase` 那一份,不能拿合并了 config 的那份,
249
+ * 否则会把 volatile 的引用对象自己也当成"本地值")。
250
+ */
251
+ function liveConfig(settings, config, localBase) {
252
+ for (const [key, ref] of Object.entries(config ?? {})) {
253
+ if (!Config.dict?.[key]?.meta?.volatile) continue
254
+ if (!ref || typeof ref.get !== 'function') continue
255
+ let local = localBase?.[key]
256
+ Object.defineProperty(settings, key, {
257
+ enumerable: true,
258
+ configurable: true,
259
+ get: () => (ref.get() === undefined ? local : ref.get()),
260
+ set: (value) => { local = value },
261
+ })
262
+ }
263
+ }
264
+
181
265
  export function apply(ctx, config = {}) {
182
266
  const dir = dataDir()
183
267
  mkdirSync(dir, { recursive: true })
184
268
 
185
269
  const settingsFile = join(dir, 'settings.json')
186
270
  const stored = readJson(settingsFile)
187
- const settings = deepMerge(DEFAULTS, stored ?? {}, config)
271
+ // 两份来源分清楚:`local` 是"我们这份"(内置默认 + settings.json),`settings` 再叠上
272
+ // profile 补丁给的 config。volatile 字段要拿 `local` 当兜底,所以基线单独留一份。
273
+ const local = deepMerge(DEFAULTS, stored ?? {})
274
+ const settings = deepMerge(local, config)
275
+ // 标了 volatile 的字段改成"读的时候现取"(见 liveConfig):这类字段改了不用重载插件。
276
+ liveConfig(settings, config, local)
188
277
 
189
278
  // 首次启动把默认配置落一份出来,用户才有东西可以改。
190
279
  if (stored === null) {
@@ -797,8 +886,41 @@ export function apply(ctx, config = {}) {
797
886
  return
798
887
  }
799
888
 
889
+ /**
890
+ * 一轮跑完,台词就作废。
891
+ *
892
+ * 不广播也安全:这一轮结束必然跟着一条回复或一条状态推送,手机自己会重画;
893
+ * 而页面那边还认着「这句我念过了」(比 at),所以旧的残留不会诈尸。
894
+ */
895
+ if (event.type === 'turn/end' && sessionId === targetSessionId()) {
896
+ store.setThought(sessionId, null)
897
+ }
898
+
800
899
  const action = tracker.feed(sessionId, event)
801
- if (!action) return
900
+ if (!action) {
901
+ /**
902
+ * 到这里还不是「指令」也不是「回答」,但可能是别的会话都不管的第三种东西:
903
+ * 中间步骤的自言自语(鲸鱼娘的台词)。
904
+ *
905
+ * **必须在这儿接,不能等到下面。** 下面第一件事就是 `if (sessionId !==
906
+ * targetSessionId()) return`——那是给「会震动手机的内容」定的规矩,而台词也一样:
907
+ * 别的会话在想什么,跟手机正遥控的这个会话无关。
908
+ *
909
+ * 它取的是中间步骤的旁白或思考首行,**不落盘、不进历史**,所以回答区的口径
910
+ * (latest/history)一个字都不会因此改变——2026-09-22 用户否掉的是「过程的文字
911
+ * 跑进回答区」,不是「她说了句话」。
912
+ */
913
+ if (event.type === 'assistant/message') {
914
+ if (sessionId !== targetSessionId()) return
915
+ const talk = stepSelfTalk(event.data?.message?.content)
916
+ // 没话可说时**什么都不做**:留着上一句,比让气泡闪一下空要好。
917
+ if (!talk) return
918
+ store.setThought(sessionId, { text: talk.text, at: event.time ?? Date.now() })
919
+ store.touchSession(sessionId)
920
+ server?.broadcast('state', store.snapshot())
921
+ }
922
+ return
923
+ }
802
924
  store.touchSession(sessionId)
803
925
 
804
926
  // 手机还没绑会话时,谁先有动静就绑谁。
@@ -823,7 +945,9 @@ export function apply(ctx, config = {}) {
823
945
  }
824
946
  // 电脑上自己敲的指令也记一笔,聊天模式才完整。
825
947
  // 带上 id:那条也可能是**排队**排进去的,手机上要靠它对上号、标出「排队中」。
826
- store.pushUser({ text: action.text, sessionId, id: action.id })
948
+ // 时间戳用事件自己的 `time`:硬盘重放出来的那份也是这个 `time`,两边同钟,
949
+ // 合成时才认得出「这条记录里已经有了」,不会接成两条(见 store.pushUser)。
950
+ store.pushUser({ text: action.text, sessionId, id: action.id, timestamp: event.time })
827
951
  server?.broadcast('state', store.snapshot())
828
952
  return
829
953
  }
@@ -831,6 +955,8 @@ export function apply(ctx, config = {}) {
831
955
  // action.kind === 'reply'
832
956
  store.pushReply({
833
957
  text: action.text, sessionId, reason: action.reason, interrupted: action.interrupted,
958
+ // 同上:和硬盘那份用同一个钟(事件的 `time`),否则同一条回复会显示两遍。
959
+ timestamp: event.time,
834
960
  })
835
961
  server?.broadcast('reply', {
836
962
  text: action.text,
@@ -1198,13 +1324,37 @@ export function apply(ctx, config = {}) {
1198
1324
  // 能力从 `miniControl` 这个固定取用点拿(服务启动时主动放进去的),
1199
1325
  // **不再经过 createMiniServer 的回执**——实测那个回执兑现出来是 undefined。
1200
1326
  const phoneReady = typeof miniControl.hasPhone === 'function' ? miniControl.hasPhone() : false
1201
- if (!agentId || agentId !== bound || questions.length === 0) return next()
1202
- if (!phoneReady) return next()
1203
- if (typeof miniControl.askPhone !== 'function') return next()
1327
+ /**
1328
+ * **让路的那三种情况,每一种都留一行。**
1329
+ *
1330
+ * 2026-09-27 用户报「电脑上弹出了选项,手机上什么都没有」。当时这条链上只有
1331
+ * 「答好了」和「出错」两句日志——正好把**让路**这半边全留白了,于是只能靠猜:
1332
+ * 是手机没连、还是问的不是手机上绑的那个会话,日志里看不出来。
1333
+ * 现在每次被叫到都记一行「问的是谁、手机绑的是谁、题数、手机在不在」。
1334
+ * **不记题目正文**——那是用户自己的话。
1335
+ */
1336
+ if (!agentId || agentId !== bound || questions.length === 0) {
1337
+ console.log(`[dsh-mini-remote] 提问让路:问的=${agentId ?? '无'},手机绑的=${bound ?? '无'},题目数=${questions.length}`)
1338
+ return next()
1339
+ }
1340
+ if (!phoneReady) {
1341
+ console.log('[dsh-mini-remote] 提问让路:手机上此刻没有连接(页面没开、锁屏或断网),交给电脑')
1342
+ return next()
1343
+ }
1344
+ if (typeof miniControl.askPhone !== 'function') {
1345
+ console.log('[dsh-mini-remote] 提问让路:拿不到 askPhone(手机服务还没起来),交给电脑')
1346
+ return next()
1347
+ }
1204
1348
  // ——— 同步交出"将来会有答案"的东西;下面的等待都在这里,不再有同步判断 ———
1349
+ console.log(`[dsh-mini-remote] 提问推给手机:${questions.length} 题,等它答`)
1205
1350
  return miniControl.askPhone(questions, agentId).then((answer) => {
1206
1351
  // 手机中途没了:把问题还给电脑,那边照常弹窗——不会两头都答不上。
1207
- if (!answer) return next()
1352
+ // 这一句日志是「手机上答了、电脑上没动静」那类现象唯一能落下来的痕迹:
1353
+ // 有它才知道手机那份答案没接上(断线、或者答晚了一步),而不是插件没在跑。
1354
+ if (!answer) {
1355
+ console.log('[dsh-mini-remote] 手机上没答成(连接断了、或者答晚了),这题还给电脑')
1356
+ return next()
1357
+ }
1208
1358
  console.log('[dsh-mini-remote] 手机上答好了,交给电脑')
1209
1359
  return answer
1210
1360
  }).catch((err) => {