dsh-mini-remote 0.1.5 → 0.1.7

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/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  本文件记录每个版本改了什么。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [未发布]
6
+
7
+ ### 重画的
8
+
9
+ - **九张动画(18 帧)全部重画,两帧之间不再有"上半身和下半身割裂"。** 起因是老工艺——一张图里并排画两格,模型两格各画一遍同一个角色,裙子上的图案、袜口条纹、腿和发丝都会各差一点;后来靠"把第二帧手臂以下整段取自第一帧"去补,补丁落在腰上、胳膊上,于是又出现"书举起来和举起前割裂"。这版换了工艺:**先出第一帧,再由第一帧改出第二帧**(只喂第一帧、只描述该动的那一处),两帧出自同一张画;九张的形象统一照同一张母版。裙子以下那一段的帧间差异,除跑、冲刺(按设计动腿)外都在 5% 以下,多数 1% 上下。
10
+ - **"跑"那张返工过一次。** 第一版两帧都是"抬起同一条腿"的镜像姿势,外轮廓几乎重合,手机上翻起来像没动(用户报「腿部没有摆动起来」——量出来的下半身帧间差其实有 52.8%,问题在剪影,不在有没有动)。现在一膝抬到大腿近水平、脚提到膝高,另一条腿伸直踩地,第二帧换边。
11
+ - **新增两个出图工具。** `tools/whale-draw.py` 直连 OpenRouter 出图、可带参考图——插件自带的改图接口在 OpenRouter 上用不了(它固定打不存在的 `/images/edits`,实测 404),带参考图得走 `/images/generations` 的 `input_references`。`tools/whale-pair.py` 把两张单人格拼成两格并排的洋红底图再交给原来的切图脚本,两帧共用同一缩放,并自检底色是不是纯洋红。
12
+
13
+ ### 改了的
14
+
15
+ - **手机上终于能看到上下文用到多少了。** 顶栏那句「上下文 42%」一直是空的:原方案是去问 DSH 的 `contextPressure` 投影,实测这一组合里拿不到——接口通、不报错、投影里就是没有那三个数(第 27 轮就担心过这件事,见 `tasks/todo.md`)。现在改成从事件里量:分母取每次请求前 `request/context` 报的窗口大小,分子取每一步 `assistant/message` 带的用量,而且**把缓存读、缓存写一起加上**——三块计数互不重叠,少加一块量出来会偏小,越长的一轮偏得越多。两个数缺任一个就整段不显示,不编、不放占位符。
16
+
17
+ ### 新加的
18
+
19
+ - **图没显示出来时,手机会说一声。** 正文里写错图片地址(文件不在、或者类型不是图片),原来只画一个破图标,用户只看到"图没了"、不知道原因。现在手机弹一句「这张图手机看不到:那个位置没有这张图,或者它不是 PNG/JPG/WEBP/GIF」,图的位置也画一圈虚线标出来。
20
+
21
+ ### 修了的
22
+
23
+ - 构建指纹不再把 `lib/art/out-*.webp`(给手机看图的展示图,不进版本库)算进去。那批文件一增一删指纹就变,重启时"跑的是不是新代码"这条校验会因此白报一次红——2026-09-28 连着踩了两回。
24
+ - **手机上显示图片不再那么挑写法。** 原来正文里只有两种写法能用:完整网址,或者一个裸名字。写全路径(哪怕是 `lib/art/out-demo.webp` 这种本机路径)都只会画一个破图标——2026-09-28 用户为此第三次来问。现在第三种写法也认:电脑上任意一张图的位置(绝对路径,或相对项目根),PNG/JPG/WEBP/GIF 都行,不必先拷进 `lib/art`。这条通道只发图片类型(扩展名是唯一的闸门)、单张不超过 20MB、照旧要 token;地址里没有构建指纹,所以不让浏览器缓存。
25
+ - 两处会把「会话睡着了」说成「这个会话已经结束了,请重新选一个会话」的提示改准:撤回排队时说「队列里排着的也跟着没了——重新发一条吧」,停止时说「现在没在跑,不用停」。会话会醒,手机上还挂着它,说"结束了"会把人引到重选会话上去。
26
+
27
+ ## [0.1.7] - 2026-09-29
28
+
29
+ ### 修了的
30
+
31
+ - **预览动图不再进发布包,包体积从 5.4 MB 降到约 0.95 MB。** 0.1.6 的包里混进了 11 张预览动图(合计 5.4 MB,`lib/art/out-*.gif`)——它们是发给本机手机看的运行期截图,不属于插件的资产。起因是 `package.json` 的 `files` 白名单整个列了 `lib` 目录,根目录那条 `.npmignore` 因此在打包时不起作用(子目录里的仍然生效)。现在在 `lib/art/` 下放一条 `.npmignore`,内容只有一行 `out-*`,这一类文件从此不进包。
32
+
33
+ ## [0.1.6] - 2026-09-29
34
+
35
+ ### 改了的
36
+
37
+ - **放开对 DSH 宿主的版本上限:`>=0.1.5-rc.2 <0.2.0` 改成 `>=0.1.5-rc.2 <0.3.0`。** 原来的上限把 0.2 以上整个排除掉;宿主的校验带 `includePrerelease`,该选项下 `0.2.0-rc.1` 小于 `0.2.0`,所以当时是侥幸通过——0.2.0 正式版一发布,插件就会不加载。抬到 `<0.3.0` 后 `0.1.5-rc.2`、`0.2.0-rc.1`、`0.2.0` 都通过,`0.3.0` 仍然拦住。
38
+ - **Node 口径与上游对齐:`>=22` 改成 `^22.19.0 || >=24.0.0`。** 这条不是门禁(宿主的校验只看 peer 依赖),只是把要求写成上游那一份。
39
+
5
40
  ## [0.1.5] - 2026-09-27
6
41
 
7
42
  ### 新加的
package/README.md CHANGED
@@ -127,7 +127,7 @@ dsh plugin --profile web add <你放源码的目录>
127
127
 
128
128
  **等结论。** 跑的时候上面显示「运行中…」。这期间你可以接着发,指令会排队。任务结束,结论直接出现在页面上,同时响一声、震一下。
129
129
 
130
- **她干活的时候会开口。** 电脑上的模型在步骤之间常会自言自语一句(「我先看一下这个文件」这类),这一句现在由鲸鱼娘念出来——说话的动作是新画的一张立绘,轮播会为它停一下,说完接着从停下的地方往下走。它**只在那只气泡里**,不会混进下面的回答区。气泡里只念**它特意写给人看的那一句**;它自己心里想的那些(常是英文、也常成段)一个字都不上手机。万一那句旁白本身就是英文,就照原样显示、不翻译——翻译等于替它编一句它没说过的话。
130
+ **鲸鱼娘动画。** 电脑上的模型在步骤之间常会自言自语一句(「我先看一下这个文件」这类),这一句现在由鲸鱼娘念出来——说话的动作是新画的一张立绘,轮播会为它停一下,说完接着从停下的地方往下走。
131
131
 
132
132
  **两套外观,随手换。** 设置里有一行「外观」:**深色**(默认)、**浅色**、**跟随系统**。深色是深海墨蓝打底;浅色那套的色是从宣传图那张画上取的——近白的冰蓝地、深宝蓝的字,主按钮也换成同一个宝蓝。选哪套记在手机上,下次打开还是它。
133
133
 
@@ -199,6 +199,13 @@ dsh plugin --profile web add <你放源码的目录>
199
199
 
200
200
  你可以更加自由地支配自己的时间,而不是当你和女儿玩耍时,抑或出游时,被困在那个小小的屏幕里。
201
201
 
202
+ ## 相关文档
203
+
204
+ - [立绘与动画工艺](docs/character.md) —— 三个出图工具、共享提示词块、尺寸与验收口径。
205
+ - [立绘基准帧(2026-09-28 定稿)](docs/art-refs/2026-09-28/) —— 九张帧图、全部提示词原文,
206
+ 以及「以后改动画先对照它们」的规矩。
207
+ - [变更记录](CHANGELOG.md) —— 每版改了什么、为什么。
208
+
202
209
  ---
203
210
 
204
211
  ## 致谢
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/lib/build.js CHANGED
@@ -39,7 +39,10 @@ function sources() {
39
39
  files.push(['client/client.js', join(HERE, '..', 'client', 'client.js')])
40
40
  try {
41
41
  for (const f of readdirSync(join(HERE, 'art')).sort()) {
42
- if (f.endsWith('.webp')) files.push(['art/' + f, join(HERE, 'art', f)])
42
+ // `out-` 开头的是给手机看图用的展示图(.gitignore 里专门让路的那批),随时会增删替换,
43
+ // 不是这份代码的一部分。把它们算进指纹的后果是:每贴一张看图指纹就变一次,重启校验
44
+ // 会因此白报一次红——2026-09-28 连着踩了两回。
45
+ if (f.endsWith('.webp') && !f.startsWith('out-')) files.push(['art/' + f, join(HERE, 'art', f)])
43
46
  }
44
47
  } catch {
45
48
  // 没有 art 目录(比如只拷了 .js 的旧安装)就当没有立绘,照常起。
package/lib/index.js CHANGED
@@ -30,7 +30,7 @@ import z from '@deepseek-ai/schemastery'
30
30
  import { createStore } from './store.js'
31
31
  import { createTurnTracker, visibleText, stepSelfTalk } from './events.js'
32
32
  import { replayHistory, lastReply } from './history.js'
33
- import { readTailEvents } from './log-tail.js'
33
+ import { readTailEvents, findSessionLog } from './log-tail.js'
34
34
  import {
35
35
  parseSlashLine, describeCommands, commandRow, commandPatch,
36
36
  invalidCommandMessage, unknownCommandMessage,
@@ -42,7 +42,8 @@ import { buildPairing, tunnelUrlFor, tunnelProbeVerdict } from './pairing.js'
42
42
  import { serveState, serveEnable, serveDisable } from './serve.js'
43
43
  import { ensureCloudflared } from './cloudflared.js'
44
44
  import { startTunnel, TUNNEL_HINT } from './tunnel.js'
45
- import { listWorkspaces, listSessionsOf, createSessionIn, createWorkspaceAt, sessionScale } from './tree.js'
45
+ import { listWorkspaces, listSessionsOf, createSessionIn, createWorkspaceAt } from './tree.js'
46
+ import { createTitleCache } from './title-cache.js'
46
47
  import { listRoots, listDirectory, makeDirectory } from './browse.js'
47
48
  import { listPresets, setPreset, available as presetsAvailable } from './permissions.js'
48
49
  import { buildId } from './build.js'
@@ -281,6 +282,15 @@ export function apply(ctx, config = {}) {
281
282
  }
282
283
  const token = loadOrCreateToken(dir, settings.token)
283
284
  const store = createStore({ file: join(dir, 'state.json'), maxHistory: settings.maxHistory })
285
+ /**
286
+ * 会话标题的落盘缓存(见 lib/title-cache.js)。
287
+ *
288
+ * 放在插件自己的数据目录里,和 token / settings.json / state.json 并排——
289
+ * 那是这个插件唯一的"自己的地方",DSH 升级、插件重装都不动它。
290
+ * 为什么值得单独落一份:读一条标题要加载整份会话日志(本机 1.4 GB / 1419 个文件),
291
+ * 而这件事**每重启一次就得重付一遍**——标题却是定下来基本不变的东西。
292
+ */
293
+ const titles = createTitleCache({ file: join(dir, 'titles.json') })
284
294
  const tracker = createTurnTracker()
285
295
  // 队列的真相在 agent 的收件箱里,store 拿不到 ctx,所以给它一个回调去问。
286
296
  // 不缓存、每次取快照现读:没有第二份状态,也就不会和电脑上不一致。
@@ -691,14 +701,59 @@ export function apply(ctx, config = {}) {
691
701
  return rows
692
702
  }
693
703
 
704
+ /**
705
+ * 把一个"睡着"的会话叫醒,拿回它的活动实例。
706
+ *
707
+ * **睡着不等于结束。** 用户 2026-09-28 实机踩到:手机上切到没在跑的会话,
708
+ * 一发指令就被回「这个会话已经结束了」——可那个会话好端端躺在硬盘上。
709
+ * 一个工作区常有上百个会话,真正"活着"的只有三两个(2026-09-25 查过:147 个里 3 个),
710
+ * 而手机能选的偏偏是全部。所以这条路不是边角,是主路:**不修的话,手机只能发给
711
+ * 那两三个正跑着的会话,其余全被这句错话挡回来。**
712
+ *
713
+ * DSH 自己给的就是 `sessionController.resolveAgent`,注释原话
714
+ * 「Resolve **or resume** one ordinary Session」。它返回 `{agent}` 或 `{error}`,
715
+ * 而 error 里区分三件事,**必须分开报**:
716
+ * - `session/not-found`:真没了(会话不存在),该让用户另选一个;
717
+ * - `session/agent-busy` / `session/writer-held`:正忙、或被写锁占着,等一会儿能成;
718
+ * - 其他(服务缺席、抛错):叫不醒,如别说成"没了"。
719
+ * 把"暂时叫不醒"也说成"已经结束了",就是把用户支去重选一个其实还在的会话。
720
+ */
721
+ async function wakeSession(sessionId) {
722
+ const controller = ctx.get('sessionController')
723
+ if (typeof controller?.resolveAgent !== 'function') return { agent: null, reason: 'unavailable' }
724
+ try {
725
+ const res = await controller.resolveAgent(sessionId)
726
+ if (res && res.agent) return { agent: res.agent, reason: null }
727
+ return { agent: null, reason: (res && res.error && res.error.code) || 'unknown' }
728
+ } catch (err) {
729
+ return { agent: null, reason: 'threw', message: err && err.message ? err.message : String(err) }
730
+ }
731
+ }
732
+
733
+ /** 叫不醒时该跟用户说的那句话(三种情况分开说)。 */
734
+ function wakeFailureText(reason) {
735
+ if (reason === 'session/not-found') return '这个会话已经不在了,请在手机上重新选一个会话。'
736
+ if (reason === 'session/agent-busy' || reason === 'session/writer-held') {
737
+ return '这个会话现在被电脑那边占着,等一会儿再发。'
738
+ }
739
+ return '这个会话现在叫不醒(电脑那边没答应),等一会儿再试。'
740
+ }
741
+
694
742
  async function onInstruction(text, uploadIds) {
695
743
  const sessionId = targetSessionId()
696
744
  if (!sessionId) {
697
745
  return { ok: false, error: '电脑上还没有可遥控的会话,请先在 DSH 里开一个。' }
698
746
  }
699
- const agent = ctx.agents.get(sessionId)
747
+ // 睡着的会话先叫醒(见 wakeSession)。**只有真没了才让用户另选一个。**
748
+ let agent = ctx.agents.get(sessionId)
700
749
  if (!agent) {
701
- return { ok: false, error: '这个会话已经结束了,请在手机上重新选一个会话。' }
750
+ const woke = await wakeSession(sessionId)
751
+ agent = woke.agent
752
+ if (!agent) {
753
+ console.log('[dsh-mini-remote] 手机上发指令:这个会话叫不醒(' + sessionId + ',原因 ' + woke.reason + ')')
754
+ return { ok: false, error: wakeFailureText(woke.reason) }
755
+ }
756
+ console.log('[dsh-mini-remote] 手机上发指令:这个会话原本在睡着,已经把它叫起来了(' + sessionId + ')')
702
757
  }
703
758
 
704
759
  // 先把小票换成真正的附件引用。认不出来的小票会被丢掉——宁可当没传过,
@@ -767,7 +822,9 @@ export function apply(ctx, config = {}) {
767
822
  const sessionId = targetSessionId()
768
823
  if (!sessionId) return { ok: false, error: '电脑上还没有可遥控的会话。' }
769
824
  const agent = ctx.agents.get(sessionId)
770
- if (!agent) return { ok: false, error: '这个会话已经结束了,请在手机上重新选一个会话。' }
825
+ // 睡着的会话没有收件箱,排着的东西也跟着没了。**不能照旧说"这个会话已经结束了"**
826
+ // ——它会醒,而且手机上还挂着它(2026-09-28 用户就是被这句误导过)。
827
+ if (!agent) return { ok: false, error: '这个会话睡下了,队列里排着的也跟着没了——重新发一条吧。' }
771
828
  if (!id) return { ok: false, error: '没说要撤哪一条。' }
772
829
  let removed = false
773
830
  try {
@@ -804,7 +861,9 @@ export function apply(ctx, config = {}) {
804
861
  const sessionId = targetSessionId()
805
862
  if (!sessionId) return { ok: false, error: '电脑上还没有可遥控的会话。' }
806
863
  const agent = ctx.agents.get(sessionId)
807
- if (!agent) return { ok: false, error: '这个会话已经结束了,请在手机上重新选一个会话。' }
864
+ // 会话睡着 = 没有一轮在跑,本来就没有可停的东西。照旧说"已经结束了"会让人以为
865
+ // 得重选会话(2026-09-28 用户被这句误导过)。
866
+ if (!agent) return { ok: false, error: '这个会话现在没在跑,不用停。' }
808
867
  // 子 Agent 不归手机管:导航栏里不显示它们,手机上也不该能停它们。
809
868
  // DSH 自己那条路也是这么挡的(hasApiSessionSubagentOwner)。
810
869
  if (agent.session?.header?.origin === 'subagent') {
@@ -818,9 +877,92 @@ export function apply(ctx, config = {}) {
818
877
  return { ok: true, sessionId }
819
878
  }
820
879
 
880
+ // 最近见过的工具调用:调用编号 → 一行能读的命令。
881
+ //
882
+ // **为什么要留这份**:审批请求(`approval/request`)只带工具名和一个调用编号,
883
+ // 参数不在里面;而手机上要敢点「同意」,得先看见到底要跑什么。参数原本就在会话
884
+ // 事件 `tool/call` 的 `arguments` 里(模型原样产出的 JSON),顺手记一份即可。
885
+ // **只留最近 40 条**:够审批用,长会话也不会越攒越多。
886
+ const toolCalls = new Map()
887
+ const TOOL_CALL_KEEP = 40
888
+
889
+ /**
890
+ * 把模型原样产出的那段参数 JSON 变成卡上能读的一行。
891
+ *
892
+ * 只做「取字段 + 限长」,不做美化:命令就该原样给人看,他才敢拍板。
893
+ * 常见字段按优先级取;都取不到就把短字段摊成几行;再不行给原文——
894
+ * 宁可难看,不能"看不到"。
895
+ */
896
+ function commandPreview(rawArgs) {
897
+ if (typeof rawArgs !== 'string' || !rawArgs.trim()) return ''
898
+ let args = null
899
+ try {
900
+ args = JSON.parse(rawArgs)
901
+ } catch (err) {
902
+ return rawArgs.trim().slice(0, 1200)
903
+ }
904
+ if (!args || typeof args !== 'object') return rawArgs.trim().slice(0, 1200)
905
+ for (const key of ['command', 'cmd', 'script', 'code', 'pattern', 'file_path', 'path', 'url']) {
906
+ const v = args[key]
907
+ if (typeof v === 'string' && v.trim()) return v.trim().slice(0, 1200)
908
+ }
909
+ const lines = []
910
+ for (const [k, v] of Object.entries(args)) {
911
+ if (typeof v === 'string' && v.length <= 200) lines.push(k + ': ' + v)
912
+ else if (typeof v === 'number' || typeof v === 'boolean') lines.push(k + ': ' + String(v))
913
+ if (lines.length >= 6) break
914
+ }
915
+ return lines.length ? lines.join('\n').slice(0, 1200) : rawArgs.trim().slice(0, 1200)
916
+ }
821
917
  // ------------------------------------------------------------------
822
918
  // 事件订阅:只取「最后一帧」
823
919
  // ------------------------------------------------------------------
920
+ /**
921
+ * 上下文窗口用量:分母按会话记着,分子每步现算。
922
+ *
923
+ * 分母来自 `request/context`——DSH 每次发请求前报一份当前路由的信息,里面有
924
+ * contextWindow。分子来自 `assistant/message` 的 usage。**三块计数互不重叠**:
925
+ * `inputTokens` 只是没走缓存的那部分,缓存读、缓存写各算各的,
926
+ * 「billed input = 三者之和」(dsh-llm 的 TokenUsage 注释原话)。少加一块,
927
+ * 量出来的用量就偏小,而且越长的一轮偏得越多。
928
+ *
929
+ * 为什么不用 dsh-token-meter 的 contextPressure 投影:2026-09-28 实测它在这一组合里
930
+ * 拿不到——接口通、不报错、投影里就是没有那三个数,手机上的用量一直空着(直接问
931
+ * /mini/api/state,整个状态里连一个 token/window 字段都没有)。事件是同一份事实的一手
932
+ * 来源,不依赖任何可选服务。
933
+ */
934
+ const contextWindows = new Map()
935
+
936
+ function usageInputTokens(usage) {
937
+ const n = (v) => (typeof v === 'number' && v > 0 ? v : 0)
938
+ return n(usage.inputTokens) + n(usage.cacheReadTokens) + n(usage.cacheWriteTokens)
939
+ }
940
+
941
+ /**
942
+ * 这个会话的窗口有多大(分母)。
943
+ *
944
+ * **两条来源,第二条是必须的。** `request/context` 那条事件只在**路由变了**的时候
945
+ * 才写一次(见 dsh-agent-loop 的追加条件:和日志里上一条 request/context 不一样才写)。
946
+ * 实测本机三个会话,从几十步到两千多步,`request/context` 都**只有一条**,就在会话
947
+ * 第一次发请求那一刻;而 `request/header` 有两百多条。插件要是晚一步起来——重启过、
948
+ * 或者是早就开着的会话——那一条早进日志了,光等事件永远等不到,分母一直缺,
949
+ * 手机上就永远空着(2026-09-28 用户报的正是这个)。
950
+ *
951
+ * 所以第二条走 `session.requestContext()`:DSH 自己把日志里最后那条 request/context
952
+ * 折叠在会话对象上,重启之后照样在(agent-loop 判断「要不要再写一条」用的就是它,
953
+ * 正因为它在,那条事件才不会每次重启都重写一遍)。拿到就顺手记进 map,
954
+ * 后面每一步都省一次折叠。
955
+ */
956
+ function contextWindowOf(session, sessionId) {
957
+ const known = contextWindows.get(sessionId)
958
+ if (typeof known === 'number' && known > 0) return known
959
+ const folded = typeof session?.requestContext === 'function' ? session.requestContext() : undefined
960
+ const win = folded?.contextWindow
961
+ if (typeof win !== 'number' || !(win > 0)) return undefined
962
+ contextWindows.set(sessionId, win)
963
+ return win
964
+ }
965
+
824
966
  ctx.on('session/event', (session, event) => {
825
967
  // 子 agent 的会话不进手机——用户遥控的是自己的主会话,不是它派出去的小弟。
826
968
  if (session.header?.origin === 'subagent') return
@@ -830,11 +972,24 @@ export function apply(ctx, config = {}) {
830
972
  // 只对「当前绑定的那一个」做——手机上没在看的会话,读了也没人看,白花 CPU。
831
973
  if (sessionId === targetSessionId()) ensureHistory(sessionId)
832
974
 
975
+ // 命令原文:记下这次要跑的是什么,审批卡上要显示。**不进日志**——正文是用户自己的东西。
976
+ if (event.type === 'tool/call') {
977
+ const data = event.data ?? {}
978
+ if (data.callId) {
979
+ toolCalls.set(String(data.callId), commandPreview(data.arguments))
980
+ while (toolCalls.size > TOOL_CALL_KEEP) toolCalls.delete(toolCalls.keys().next().value)
981
+ }
982
+ return
983
+ }
984
+
833
985
  // 会话标题(可选事件,拿到就用,拿不到就用 id 前 8 位兜底)
834
986
  if (event.type === 'session/title') {
835
987
  const title = event.data?.title ?? event.data?.text
836
988
  if (typeof title === 'string' && title.trim()) {
837
989
  store.touchSession(sessionId, { title: title.trim() })
990
+ // 同时盖掉落盘那份里的旧说法。**不能只等下次展开工作区时才写**:
991
+ // 那时如果插件已经重启过(内存清零),手机上看到的就是盘上那个旧值了。
992
+ titles.set(sessionId, title.trim())
838
993
  }
839
994
  return
840
995
  }
@@ -896,6 +1051,25 @@ export function apply(ctx, config = {}) {
896
1051
  store.setThought(sessionId, null)
897
1052
  }
898
1053
 
1054
+ // 上下文窗口用量:分母跟着 request/context 记,分子每步现量(见 usageInputTokens)。
1055
+ // 缺任一半就什么都不写——宁可空着,也不编一个数出来。
1056
+ if (event.type === 'request/context') {
1057
+ const win = event.data?.contextWindow
1058
+ if (typeof win === 'number' && win > 0) contextWindows.set(sessionId, win)
1059
+ }
1060
+ if (event.type === 'assistant/message' && event.data?.usage) {
1061
+ const used = usageInputTokens(event.data.usage)
1062
+ // 分母从 `contextWindowOf` 拿:事件里那份优先,事件没赶上就问会话自己折叠的那份
1063
+ // (理由见那个函数)。
1064
+ const win = contextWindowOf(session, sessionId)
1065
+ if (used > 0 && win > 0) {
1066
+ store.touchSession(sessionId, { context: { used, window: win } })
1067
+ // 只有手机正在看的那个会话值得推一遍;别的会话照样记着,等它被切过来时
1068
+ // /mini/api/state 会给最新的一份。
1069
+ if (sessionId === targetSessionId()) server?.broadcast('state', store.snapshot())
1070
+ }
1071
+ }
1072
+
899
1073
  const action = tracker.feed(sessionId, event)
900
1074
  if (!action) {
901
1075
  /**
@@ -1123,33 +1297,34 @@ export function apply(ctx, config = {}) {
1123
1297
  /** 多久重读一次。会话跑着的时候历史会变长,一分钟够跟上;睡着的不变,重读也只是白读一次。 */
1124
1298
  const HISTORY_TTL_MS = 60000
1125
1299
 
1126
- /**
1127
- * 整份读的两条红线(2026-09-25 事故后加的护栏)。
1128
- *
1129
- * 事故:手机绑定的会话是**带 310 个子会话的父会话**(26MB 压缩 / 2.2 万事件),
1130
- * 开机预热历史时 `readSession` 触发 v3→v4 迁移,把每个子会话整份解出来,
1131
- * 默认约 4GB 堆 20 秒 OOM。这两条阈值是「手机上宁可少看一点,也别把 DSH 拖死」
1132
- * 的取舍,都写在这儿方便调。
1133
- */
1134
- const HISTORY_MAX_CHILDREN = 20
1135
- const HISTORY_MAX_BYTES = 8 * 1024 * 1024
1136
-
1137
1300
  /**
1138
1301
  * 手机端读历史的**上限**(2026-09-25 用户要求:读历史要有上限)。
1139
1302
  *
1140
- * 上面两条红线决定「要不要绕开内核那条整份读」,这里三条决定
1141
- * 「自己读的时候最多读多少、最多给手机多少」。三层**都跟会话总量无关**:
1303
+ * 三层**都跟会话总量无关**:
1142
1304
  * - 窗口:只读日志末尾这么多**压缩**字节;
1143
1305
  * - 事件:窗口里最多留多少条事件(再向前对齐到一条 user 消息,让重放从一轮的开头起);
1144
1306
  * - 条目:重放成聊天记录后,最多给手机多少条(≈ 轮数 × 2)。
1145
1307
  */
1146
1308
  // 窗口按「本机最坏的那条日志」实测选的(26MB / 2.2 万事件):16MB 窗口 → 约 1.2 万事件、
1147
- // 197 条聊天记录、472ms、堆峰 ~400MB;4MB 窗口只有 29 条(太少)。事件上限只作极端兜底。
1309
+ // 197 条聊天记录、472ms、堆峰 ~400MB;4MB 窗口只有 29 条(太少)。日志比窗口小就是全读。
1310
+ // 事件上限只作极端兜底。
1311
+ //
1312
+ // **这条有上限的路现在是日常路**,理由见 ensureHistory:内核那条整份读要先把全部会话
1313
+ // 列两遍,1MB 的日志也要 1.7–3.6 秒。原来那两条「子会话超过 20 个 / 日志超过 8MB 才
1314
+ // 走这里」的红线(以及为它们服务的 tree.js sessionScale)随之删掉——触发它们的那条
1315
+ // 整份读已经只剩「盘上还没有日志的会话」这一种情况了,那时它给的是内存里现成的那份,
1316
+ // 不会去做 v3→v4 迁移,所以 2026-09-25 那次 OOM 的路已经走不通。
1148
1317
  const HISTORY_TAIL_WINDOW_BYTES = 16 * 1024 * 1024
1149
1318
  const HISTORY_TAIL_MAX_EVENTS = 20000
1150
1319
  const HISTORY_MAX_ENTRIES = 200
1151
1320
 
1152
- /** 去 DSH 的会话记录里读一次,重放成聊天记录。读不出来返回 null。 */
1321
+ /**
1322
+ * 去 DSH 的会话记录里读一次,重放成聊天记录。读不出来返回 null。
1323
+ *
1324
+ * **只在盘上找不到这个会话的日志时才用**(会话刚建、还没落盘;或者是子 agent 的会话,
1325
+ * 那种要读出来才知道该不该显示)。这时内核给的是内存里现成的那份,不读盘、不迁移,
1326
+ * 正好免费。盘上有日志时请走 readTailHistory——理由是实测数字,见 ensureHistory。
1327
+ */
1153
1328
  async function readHistory(sessionId) {
1154
1329
  const q = treeServices.query
1155
1330
  if (typeof q?.readSession !== 'function') return null
@@ -1189,8 +1364,7 @@ export function apply(ctx, config = {}) {
1189
1364
  /**
1190
1365
  * 有上限的读法:只解日志**尾部**的若干 zstd 帧,自己重放(见 lib/log-tail.js)。
1191
1366
  *
1192
- * 什么时候用它:会话「大」的时候。那条整份读的路会把整份日志连同**所有直接子会话**
1193
- * 解出来(v3→v4 迁移要它们),代价一旦付出就收不回来——所以宁可少读一点。
1367
+ * 日志比窗口小就是整份都读到(`tail.whole`),所以小会话走这里也不会少看东西。
1194
1368
  * 拿不到就返回 null:由调用方如实说「没读到」,**不编内容**。
1195
1369
  */
1196
1370
  function readTailHistory(sessionId) {
@@ -1209,13 +1383,20 @@ export function apply(ctx, config = {}) {
1209
1383
  const capped = capEntries(entries)
1210
1384
  if (!capped.entries.length) return null
1211
1385
  const windowMb = (HISTORY_TAIL_WINDOW_BYTES / 1048576).toFixed(0)
1386
+ // 「不是全部」有两处会砍:readTailEvents 那层(窗口 / 事件上限)和这里 capEntries 那层
1387
+ // (聊天记录条数上限)。**两层都要算进去**——小会话的窗口盖得住整份日志,唯一会砍它的
1388
+ // 就是条数上限,漏了这一层就会「少给了内容却不说」,用户会以为这个会话就这么点。
1389
+ const truncated = tail.truncated || capped.dropped > 0
1212
1390
  return {
1213
1391
  entries: capped.entries,
1214
1392
  latest: lastReply(capped.entries, sessionId),
1215
- // 老实说清「读到的是不是全部」:窗口把整份都盖住了就不该再说「不是全部」。
1216
- truncated: tail.truncated,
1217
- note: tail.truncated
1218
- ? `这个会话很大,只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条 / ${windowMb}MB 窗口)`
1393
+ truncated,
1394
+ // 这话得分开说:① 窗口只够日志的尾部——那是「这个会话很大」;② 窗口盖住了整份,
1395
+ // 只是条数到了上限——那跟大小无关,不能也对用户说「很大」。
1396
+ note: truncated
1397
+ ? (tail.whole
1398
+ ? `只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条)`
1399
+ : `这个会话很大,只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条 / ${windowMb}MB 窗口)`)
1219
1400
  : null,
1220
1401
  }
1221
1402
  }
@@ -1235,19 +1416,23 @@ export function apply(ctx, config = {}) {
1235
1416
  if (hit && Date.now() - hit.at < HISTORY_TTL_MS) return
1236
1417
 
1237
1418
  // 先占坑:手机显示「正在读」靠的就是这个标记,**必须同步到位**——
1238
- // 不能等下面那次异步量规模回来(否则刚绑定那一下会显示成空会话)。
1419
+ // 同步部分走完之前不能让快照说「读完了」,否则刚绑定那一下会显示成空会话。
1239
1420
  historyPending.add(sessionId)
1240
1421
 
1241
- // 读之前先量规模:内核那条整份读会把子会话一起解出来,代价收不回来。
1242
- // 「大」→ 走有上限的尾部读;「小」→ 仍走内核的完整读(日志权威,会话活着还免读盘)。
1243
- const scale = await sessionScale({ query: treeServices.query, sessionId })
1244
- const tooBigForWholeRead =
1245
- scale.children > HISTORY_MAX_CHILDREN || (scale.bytes ?? 0) > HISTORY_MAX_BYTES
1422
+ // 盘上有日志就自己读那个窗口,不请内核那条「整份读」。2026-09-28 在本机实测
1423
+ // (1432 份会话日志的副本上跑的,见提交信息):内核那条每读一个会话都要先把
1424
+ // **全部会话列两遍**——`corpus.load` 里一遍、v3→v4 迁移前又一遍,每遍 0.8–1.2 秒
1425
+ // ——再解整份日志、逐条克隆。1MB 的会话也要 1.7–3.6 秒;**贵的不是日志,是那两遍枚举**。
1426
+ // 自己读同一个会话:1MB 约 0.17 秒、5MB 约 0.24 秒,跟日志大小成正比、跟会话总数无关。
1427
+ // 手机点一个没选中的会话等的就是这一段,所以这条是日常路。
1428
+ //
1429
+ // 只有「盘上找不到日志」才回退内核那条:那会儿是刚建、还没落盘的会话,它给的是
1430
+ // 内存里现成的那份,不读盘、也不做迁移,正好免费。会话活着(跑着)时内核那条也不贵,
1431
+ // 但不为它分叉——大日志的活会话本来就一直走窗口读,行为是一致的;窗口读拿到的是
1432
+ // 日志尾部,刚发生、还没落盘的条目由 store 的 mergeHistory 按时间戳接在后面。
1246
1433
  let got = null
1247
1434
  try {
1248
- if (tooBigForWholeRead) {
1249
- const size = scale.bytes == null ? '未知' : (scale.bytes / 1048576).toFixed(1) + 'MB'
1250
- log.info(`规模上限:${sessionId} 改走尾部读(子会话 ${scale.children},日志 ${size})`)
1435
+ if (findSessionLog(sessionId)) {
1251
1436
  got = readTailHistory(sessionId)
1252
1437
  } else {
1253
1438
  got = await readHistory(sessionId)
@@ -1260,9 +1445,7 @@ export function apply(ctx, config = {}) {
1260
1445
  entries: got?.entries ?? null,
1261
1446
  latest: got?.latest ?? null,
1262
1447
  truncated: got?.truncated === true,
1263
- note:
1264
- got?.note ??
1265
- (tooBigForWholeRead ? '这个会话很大,最近这一段也没读出来' : null),
1448
+ note: got?.note ?? null,
1266
1449
  })
1267
1450
  historyPending.delete(sessionId)
1268
1451
  server?.broadcast('state', store.snapshot())
@@ -1363,6 +1546,47 @@ export function apply(ctx, config = {}) {
1363
1546
  return next()
1364
1547
  })
1365
1548
  }, { prepend: true })
1549
+ // ------------------------------------------------------------------
1550
+ // 命令确认(审批)也推到手机上
1551
+ // ------------------------------------------------------------------
1552
+ // 形状照抄上面那条提问链,**连同它踩过的坑一起抄**:
1553
+ // ①监听器不能写成 async——「接不接」必须在同步那一瞬间定下来,异步只用来产答案;
1554
+ // 写成先 await 再判断,会让路的动作交不回派发的链条,表现是"完全没接管、且不报错"。
1555
+ // ②让路的每一种情况都留一行日志,否则下次报"手机上什么都没有"又只能靠猜。
1556
+ // ③**不记正文**:只记工具名,命令原文是用户自己的东西。
1557
+ // ④出错一律 next(),绝不把命令确认吞掉。
1558
+ ctx.on('approval/request', function (request, next) {
1559
+ const agentId = request?.agent?.id ?? null
1560
+ const bound = store.snapshot().boundSessionId
1561
+ const toolName = request?.toolName ?? '某个工具'
1562
+ // 这次到底要跑什么:审批请求只带调用编号,参数在会话事件里记着的那份。
1563
+ const command = request?.callId ? (toolCalls.get(String(request.callId)) ?? '') : ''
1564
+ const phoneReady = typeof miniControl.hasPhone === 'function' ? miniControl.hasPhone() : false
1565
+ if (!agentId || agentId !== bound) {
1566
+ console.log(`[dsh-mini-remote] 审批让路:批的=${agentId ?? '无'},手机绑的=${bound ?? '无'}`)
1567
+ return next()
1568
+ }
1569
+ if (!phoneReady) {
1570
+ console.log('[dsh-mini-remote] 审批让路:手机上此刻没有连接(页面没开、锁屏或断网),交给电脑')
1571
+ return next()
1572
+ }
1573
+ if (typeof miniControl.askApproval !== 'function') {
1574
+ console.log('[dsh-mini-remote] 审批让路:拿不到 askApproval(手机服务还没起来),交给电脑')
1575
+ return next()
1576
+ }
1577
+ console.log(`[dsh-mini-remote] 审批推给手机:${toolName},等它拍板`)
1578
+ return miniControl.askApproval(request, agentId, command).then((outcome) => {
1579
+ if (!outcome) {
1580
+ console.log('[dsh-mini-remote] 手机上没拍成(连接断了、或者拍晚了),这条还给电脑')
1581
+ return next()
1582
+ }
1583
+ console.log('[dsh-mini-remote] 手机上拍板了:' + outcome)
1584
+ return outcome
1585
+ }).catch((err) => {
1586
+ console.log('[dsh-mini-remote] 接管审批出错,让回电脑:' + String(err))
1587
+ return next()
1588
+ })
1589
+ }, { prepend: true })
1366
1590
 
1367
1591
  // ------------------------------------------------------------------
1368
1592
  // 手机端「新建会话」要用的服务
@@ -1530,6 +1754,15 @@ export function apply(ctx, config = {}) {
1530
1754
  + (presetsAvailable(presets.service) ? presets.service.names.join(' / ') : '拿不到'))
1531
1755
  })
1532
1756
 
1757
+ // 审批通道。同款做法:**单独一条 inject**,因为它也可能不到场(headless 组合里就没有)。
1758
+ // 到场了,手机才有机会接住命令确认;不到场就什么都不做,电脑端原来的弹窗照旧。
1759
+ const approvals = { service: null }
1760
+ ctx.inject(['approval'], (approvalCtx) => {
1761
+ approvals.service = approvalCtx.approval ?? null
1762
+ console.log('[dsh-mini-remote] 审批通道已就绪:'
1763
+ + (approvals.service ? '拿得到' : '拿不到'))
1764
+ })
1765
+
1533
1766
  /**
1534
1767
  * 按 id 取会话对象。
1535
1768
  *
@@ -1555,6 +1788,7 @@ export function apply(ctx, config = {}) {
1555
1788
  query: treeServices.query,
1556
1789
  agents: ctx.agents,
1557
1790
  knownTitles: knownTitles(),
1791
+ persisted: titles,
1558
1792
  workspaceId,
1559
1793
  limit,
1560
1794
  force,
@@ -1620,6 +1854,8 @@ export function apply(ctx, config = {}) {
1620
1854
  //
1621
1855
  // 用 `sessionQuery.listEvents`:它的说明是「列出原始日志事件」,并且注明
1622
1856
  // **优先取活着的、没有就读硬盘**——两件事一个方法全包,不用我自己分支。
1857
+ // 用量不再需要主动去问:它跟着事件自己更新(见 usageInputTokens)。
1858
+
1623
1859
  sessionModel: async (sessionId) => {
1624
1860
  if (!sessionId) return { ok: false, reason: 'no-id' }
1625
1861
  const shape = (cfg) => ({
@@ -1671,6 +1907,58 @@ export function apply(ctx, config = {}) {
1671
1907
  cache.set(sessionId, { at: Date.now(), value })
1672
1908
  return value
1673
1909
  },
1910
+
1911
+ /**
1912
+ * 某个会话「上下文用了多少」——**只在内存里那份缺分母时**才用得上的兜底。
1913
+ *
1914
+ * 正常情况不用走到这儿:会话跑起来时每一步的 usage 都会把 {used, window} 记进
1915
+ * store(见上面 usageInputTokens / contextWindowOf),手机读到的是最鲜活的一份。
1916
+ * 这条路是给「插件晚一步起来」的会话准备的:窗口那条事件一个会话只写一次
1917
+ * (理由见 contextWindowOf),插件没赶上就永远等不到,内存里于是永远缺分母。
1918
+ * 而日志里两半都齐——最后一条 `request/context` 是分母,最后一次
1919
+ * `assistant/message` 的 usage 是分子,都是 DSH 自己记下的事实,不是我们估的。
1920
+ *
1921
+ * 读整份日志是贵的,所以和 sessionModel 一样缓存 60 秒。**只在内存里没有时才问**,
1922
+ * 所以会话一跑起来(store 有了值)就再也不会读盘。
1923
+ *
1924
+ * 两半缺任一个都返回 null:宁可空着,也不拿别的数凑。
1925
+ */
1926
+ sessionContext: async (sessionId) => {
1927
+ if (!sessionId) return null
1928
+ const cache = treeServices.contextCache || (treeServices.contextCache = new Map())
1929
+ const hit = cache.get(sessionId)
1930
+ if (hit && Date.now() - hit.at < 60000) return hit.value
1931
+
1932
+ const q = treeServices.query
1933
+ let value = null
1934
+ if (typeof q?.readSession === 'function') {
1935
+ try {
1936
+ const snap = await q.readSession(sessionId)
1937
+ const events = Array.isArray(snap?.events) ? snap.events : []
1938
+ // 一遍扫到底、各自留最后一条:窗口在日志开头(那时才写),用量在末尾,
1939
+ // 从后往前找反而要白走几千条。两个数都是「最近一次」才算数。
1940
+ let window = 0
1941
+ let used = 0
1942
+ for (const e of events) {
1943
+ if (!e) continue
1944
+ if (e.type === 'request/context') {
1945
+ const w = e.data?.contextWindow
1946
+ if (typeof w === 'number' && w > 0) window = w
1947
+ } else if (e.type === 'assistant/message' && e.data?.usage) {
1948
+ const u = usageInputTokens(e.data.usage)
1949
+ if (u > 0) used = u
1950
+ }
1951
+ }
1952
+ if (used > 0 && window > 0) value = { used, window }
1953
+ } catch {
1954
+ // 读不出来就当没有——手机上少一个数,好过报一个错。
1955
+ value = null
1956
+ }
1957
+ }
1958
+ if (cache.size > 50) cache.clear()
1959
+ cache.set(sessionId, { at: Date.now(), value })
1960
+ return value
1961
+ },
1674
1962
  }
1675
1963
 
1676
1964
  // ------------------------------------------------------------------