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/lib/tree.js CHANGED
@@ -15,8 +15,6 @@
15
15
  * 它的会话(要读日志折标题,贵)。
16
16
  */
17
17
  import { basename } from 'node:path'
18
- import { statSync } from 'node:fs'
19
- import { findSessionLog } from './log-tail.js'
20
18
 
21
19
  /** 一个工作区默认最多列多少会话。手机上再多也划不动。 */
22
20
  export const SESSIONS_PER_WORKSPACE = 20
@@ -52,6 +50,180 @@ const CACHE_TTL_MS = 30_000
52
50
  */
53
51
  const caches = new WeakMap()
54
52
 
53
+ /**
54
+ * 已经读出来的**会话标题**,也按 query 分开存一份。
55
+ *
56
+ * 为什么单独缓存它:清单本身(safeRecords)有 30 秒缓存,但冷会话的标题要去加载它的日志
57
+ * 才能折出来(DSH 的 readTitleSnapshots 就是逐个 load),实测展开一个工作区要 3 秒——
58
+ * 而这一步原先每次展开都重付一次。标题定下来基本不变,读过就该记住。
59
+ *
60
+ * 同样用 WeakMap 认"这份数据是谁的",理由见上面 caches 的注释。
61
+ * **空标题也记**:会话确实没有标题时,没理由每次再去重读一遍;将来它有了标题,
62
+ * 事件流那条路(knownTitles)会先命中并覆盖掉。
63
+ */
64
+ /**
65
+ * 展开工作区时,标题**最多**等这么久(毫秒)。
66
+ *
67
+ * 快的读取(几条会话、日志不大)在这个上限内就回来了,行为和原来一样;
68
+ * 慢的读取(实测有工作区要 19 秒)到点就放手,响应先走,标题留给后台补齐。
69
+ * 为什么是 250:这是"人感觉不到"和"人开始看表"之间的分界,也远小于手机页一次渲染的间隔。
70
+ */
71
+ const TITLE_WAIT_MS = 250
72
+
73
+ /**
74
+ * 内存里这份标题缓存放多久。
75
+ *
76
+ * 原来是 10 分钟,因为那时它是**唯一**的一份——过期就真的没了,得回去重读日志。
77
+ * 现在落盘那份(lib/title-cache.js)才是长期记忆,内存这份只是它的快照,
78
+ * 过期了从盘上再捞一次即可(见 titleCacheFor)。所以两边统一成同一个七天,
79
+ * 免得出现"盘上有、内存说没有、又去读一遍日志"这种白花的活。
80
+ */
81
+ const TITLE_TTL_MS = 7 * 24 * 60 * 60 * 1000
82
+ const TITLE_CACHE_MAX = 500
83
+ const titleCaches = new WeakMap()
84
+
85
+ /**
86
+ * 建内存缓存时**先把落盘那份灌进来**。
87
+ *
88
+ * 这一步就是"标题落盘"的全部意义所在:DSH 一重启,内存清零,而盘上那份还在,
89
+ * 于是展开一个读过的工作区依然是毫秒级——不必把 1.4 GB 日志重付一遍。
90
+ */
91
+ function titleCacheFor(query, persisted) {
92
+ if (!query) return null
93
+ let entry = titleCaches.get(query)
94
+ if (!entry) {
95
+ entry = new Map()
96
+ for (const [id, row] of persisted?.all() ?? []) entry.set(id, row)
97
+ titleCaches.set(query, entry)
98
+ }
99
+ return entry
100
+ }
101
+
102
+ /**
103
+ * 记下一条标题。重插一次是为了让 Map 的顺序等于"最近更新在后",好按容量淘汰最老的。
104
+ *
105
+ * 同时写进落盘那份(如果有)——**事件流里见到的新标题也走这条**,
106
+ * 于是盘上那个旧值当场就被覆盖,不会留着一个过时的说法。
107
+ */
108
+ function rememberTitle(cache, id, title, persisted) {
109
+ if (!cache || !id) return
110
+ cache.delete(id)
111
+ cache.set(id, { title, at: Date.now() })
112
+ if (cache.size > TITLE_CACHE_MAX) {
113
+ const oldest = cache.keys().next().value
114
+ if (oldest !== undefined) cache.delete(oldest)
115
+ }
116
+ persisted?.set(id, title)
117
+ }
118
+
119
+ /**
120
+ * 后台读标题的**全局**并发上限。
121
+ *
122
+ * 一趟 readTitleSnapshots 就是"把这几份会话日志解压 + 折标题",实测单条约 5 秒且吃 CPU。
123
+ * 列表不再干等之后,用户连续展开几个工作区就会有几趟同时在跑、互相抢。原先只有
124
+ * "按 query 去重"(见 titleInflight 的注释)——那只挡住同一个会话被读两遍,**不是闸门**:
125
+ * 五个工作区各读各的,照样是五趟并行。
126
+ *
127
+ * 为什么是 2:一台电脑上同时解两份日志,用户感觉不到卡;再多就会和别的事(模型请求、
128
+ * 手机页面自己)抢。而且这些标题本来就没人等着,晚几百毫秒没有任何代价。
129
+ */
130
+ export const TITLE_CONCURRENCY = 2
131
+
132
+ let titleRunning = 0
133
+ const titleWaiting = []
134
+
135
+ /** 排队等一个名额。fn 跑完(成了、抛了都算)把名额让给下一个。 */
136
+ function withTitleSlot(fn) {
137
+ return new Promise((resolve, reject) => {
138
+ const run = () => {
139
+ titleRunning += 1
140
+ Promise.resolve()
141
+ .then(fn)
142
+ .then(
143
+ (value) => {
144
+ titleRunning -= 1
145
+ runNextTitle()
146
+ resolve(value)
147
+ },
148
+ (err) => {
149
+ titleRunning -= 1
150
+ runNextTitle()
151
+ reject(err)
152
+ },
153
+ )
154
+ }
155
+ if (titleRunning < TITLE_CONCURRENCY) run()
156
+ else titleWaiting.push(run)
157
+ })
158
+ }
159
+
160
+ function runNextTitle() {
161
+ const next = titleWaiting.shift()
162
+ if (next) next()
163
+ }
164
+
165
+ /**
166
+ * 取一条缓存标题。
167
+ *
168
+ * 「过期的当场删掉;"没查到过"和"查到是空标题"要分得出来」——后者返回的是空串,
169
+ * 前者返回 null,调用方(尤其是 pending 那个数)全靠这个区别。
170
+ *
171
+ * **内存里没有就去盘上找一眼**:内存这份上限 500 条,而本机 36 个工作区全展开一遍
172
+ * 就可能把它挤满;标题的长期记忆本来就在盘上(lib/title-cache.js),
173
+ * 被挤掉一条不该等于"回去重读 5 秒日志"。找到就顺手回灌进内存,只回灌不写盘——
174
+ * 盘上那份本来就是它。
175
+ */
176
+ function cachedTitle(cache, id, persisted) {
177
+ const hit = cache?.get(id)
178
+ if (hit) {
179
+ if (Date.now() - hit.at <= TITLE_TTL_MS) return hit.title
180
+ cache.delete(id)
181
+ }
182
+ const fromDisk = persisted?.get(id)
183
+ if (typeof fromDisk !== 'string') return null
184
+ if (cache) rememberTitle(cache, id, fromDisk)
185
+ return fromDisk
186
+ }
187
+
188
+ /**
189
+ * query → 正在读日志的会话 id 集合。
190
+ *
191
+ * 不 await 之后有个新风险:用户连点两下、或两个标签页同时展开同一个工作区,
192
+ * 同一批冷会话会被读两遍。这里挡住重复的在途请求,读完即释放(失败也释放)。
193
+ */
194
+ const titleInflight = new WeakMap()
195
+
196
+ /**
197
+ * 后台把缺的标题读出来,写进缓存。**调用方不 await 它**。
198
+ *
199
+ * 为什么不 await:标题是列表里的装饰,没有它界面退回用日期(见 titleText 的注释),
200
+ * 而读它要加载会话日志——实测一个工作区里几条冷会话就是十几秒。那十几秒是用户在
201
+ * 盯着"正在读取…"干等,不该发生。
202
+ */
203
+ async function fillTitles(query, cache, ids, persisted) {
204
+ if (!query?.readTitleSnapshots || !ids.length) return
205
+ let flying = titleInflight.get(query)
206
+ if (!flying) {
207
+ flying = new Set()
208
+ titleInflight.set(query, flying)
209
+ }
210
+ const todo = ids.filter((id) => !flying.has(id))
211
+ if (!todo.length) return
212
+ for (const id of todo) flying.add(id)
213
+ try {
214
+ // 读之前先过全局闸门(见 TITLE_CONCURRENCY):这一步是这次改动里唯一真正吃 CPU 的地方。
215
+ const items = await withTitleSlot(() => query.readTitleSnapshots(todo))
216
+ for (const item of items ?? []) {
217
+ if (item?.status !== 'fulfilled') continue // 读失败不进缓存,下次还会重试
218
+ rememberTitle(cache, item.sessionId, titleText(item.value?.title), persisted)
219
+ }
220
+ } catch (err) {
221
+ // 读不出来就没有标题,界面退回用日期,不影响切换。
222
+ } finally {
223
+ for (const id of todo) flying.delete(id)
224
+ }
225
+ }
226
+
55
227
  async function safeRecords(query, { force = false } = {}) {
56
228
  if (!query) return []
57
229
  let entry = caches.get(query)
@@ -241,8 +413,14 @@ function shapeWorkspace(ws) {
241
413
  *
242
414
  * 标题分两步拿:插件自己在事件流里见过的(内存里就有,免费)优先;
243
415
  * 剩下的冷会话才去读日志。**只在展开一个工作区时才付这个代价**,所以有上限。
416
+ *
417
+ * `persisted` 是标题的落盘缓存(lib/title-cache.js)——读过一次的标题跨重启也算数。
418
+ * 它是可选的:没给就是纯内存的老行为,测试和 headless 组合都走这条路。
244
419
  */
245
- export async function listSessionsOf({ registry, query, agents, knownTitles, workspaceId, limit = SESSIONS_PER_WORKSPACE, force }) {
420
+ export async function listSessionsOf({
421
+ registry, query, agents, knownTitles, persisted, workspaceId,
422
+ limit = SESSIONS_PER_WORKSPACE, force,
423
+ }) {
246
424
  const ws = safeList(registry).find((w) => w.id === workspaceId)
247
425
  if (!ws) return null
248
426
 
@@ -254,21 +432,51 @@ export async function listSessionsOf({ registry, query, agents, knownTitles, wor
254
432
  .slice(0, limit)
255
433
 
256
434
  // 内存里已知的标题先用着,缺的再去读日志。
435
+ const cache = titleCacheFor(query, persisted)
257
436
  const titles = new Map()
258
437
  const missing = []
259
438
  for (const r of rows) {
260
- const known = knownTitles?.get(r.header.id)
261
- if (known) titles.set(r.header.id, known)
262
- else missing.push(r.header.id)
439
+ const id = r.header.id
440
+ const known = knownTitles?.get(id)
441
+ if (known) {
442
+ titles.set(id, known)
443
+ rememberTitle(cache, id, known, persisted)
444
+ continue
445
+ }
446
+ const hit = cachedTitle(cache, id, persisted)
447
+ if (hit === null) missing.push(id)
448
+ else if (hit) titles.set(id, hit)
263
449
  }
450
+ // 标题只等一个很短的窗口:等得到就用上(和原来一样),等不到就放手、交给后台,
451
+ // 那几条先显示日期。缓存里已有的照旧直接用,所以反复展开同一个工作区始终是快的。
264
452
  if (missing.length && query?.readTitleSnapshots) {
265
- try {
266
- for (const item of await query.readTitleSnapshots(missing)) {
267
- const title = item?.status === 'fulfilled' ? titleText(item.value?.title) : ''
268
- if (title) titles.set(item.sessionId, title)
269
- }
270
- } catch (err) {
271
- // 读不出来就没有标题,界面会退回用日期,不影响切换。
453
+ await Promise.race([
454
+ fillTitles(query, cache, missing, persisted),
455
+ new Promise((resolve) => setTimeout(resolve, TITLE_WAIT_MS)),
456
+ ])
457
+ for (const id of missing) {
458
+ const hit = cachedTitle(cache, id, persisted)
459
+ if (hit) titles.set(id, hit)
460
+ }
461
+ }
462
+
463
+ /**
464
+ * 「还有几条标题没读出来」——服务端把这件事**如实**告诉手机,手机据此决定要不要再问一次。
465
+ *
466
+ * 为什么要这个数:页面原来是"固定补三次"(1.5 秒 / 3 秒 / 3 秒),而一个工作区里
467
+ * 四五个冷会话实测要 19 秒才读完——三次补拉盖不住,标题还是长不出来,
468
+ * 用户只能退出去再进来。**猜次数不如问事实**:这个数归零,就是真的一条都不剩了。
469
+ *
470
+ * 两件事必须分开:
471
+ * - "读过了、确实没有标题"(缓存里记的是空串)再等多久也不会有,不计入 pending;
472
+ * - "还没读到"(cachedTitle 给 null)才算。
473
+ * 另外,拿不到 readTitleSnapshots 这个能力时一律 0:谁也不去读,却让页面一直问,那是骗人。
474
+ */
475
+ let pending = 0
476
+ if (typeof query?.readTitleSnapshots === 'function') {
477
+ for (const r of rows) {
478
+ const id = r.header.id
479
+ if (!titles.has(id) && cachedTitle(cache, id, persisted) === null) pending += 1
272
480
  }
273
481
  }
274
482
 
@@ -276,6 +484,7 @@ export async function listSessionsOf({ registry, query, agents, knownTitles, wor
276
484
  workspaceId,
277
485
  total: (ws.sessionIds ?? []).length,
278
486
  truncated: (ws.sessionIds ?? []).length > rows.length,
487
+ pending,
279
488
  sessions: rows.map((r) => {
280
489
  const id = r.header.id
281
490
  const status = agents?.get(id)?.status
@@ -289,39 +498,3 @@ export async function listSessionsOf({ registry, query, agents, knownTitles, wor
289
498
  }),
290
499
  }
291
500
  }
292
-
293
- /**
294
- * 会话日志的压缩体积(字节)。拿不到就返回 null——**不猜**。
295
- *
296
- * 为什么要它:`sessionQuery.readSession()` 会把整份事件解出来(还要做两次全量克隆),
297
- * 而且 0.1.7-rc.2 起 v3 日志要走 v3→v4 迁移,迁移过程会把这个父会话的
298
- * **每个直接子会话整份解码**。实测:26MB / 2.2 万事件的父会话带 310 个子会话时,
299
- * 默认约 4GB 堆 20 秒就 OOM(见 2026-09-25 事故)。所以读之前先量体积。
300
- */
301
- function logBytes(sessionId) {
302
- const file = findSessionLog(sessionId)
303
- if (!file) return null
304
- try {
305
- return statSync(file).size
306
- } catch {
307
- return null
308
- }
309
- }
310
-
311
- /**
312
- * 一次「整份读」值不值得。子会话数用已缓存的 `listSessions()` 记录算(**无额外扫盘**)。
313
- *
314
- * 读不动有两类原因:① **子会话太多**(迁移要把它们全解出来);② **自己太大**
315
- * (解 + 两次克隆 ≈ 体积的 5–8 倍)。两个信号都给出去,阈值由调用方定。
316
- *
317
- * @param {{query:object, sessionId:string}} args
318
- * @returns {Promise<{children:number, bytes:number|null}>} children = 以它为 parentSession 的会话数
319
- */
320
- export async function sessionScale({ query, sessionId }) {
321
- const records = await safeRecords(query)
322
- let children = 0
323
- for (const r of records) {
324
- if (r?.header?.parentSession === sessionId) children += 1
325
- }
326
- return { children, bytes: logBytes(sessionId) }
327
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mini-remote",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "DSH 极简移动端遥控器:只把「你的指令」和「AI 的最终回复」送到手机,屏蔽全部中间过程。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -23,7 +23,7 @@
23
23
  "test": "node --test \"test/*.test.mjs\""
24
24
  },
25
25
  "engines": {
26
- "node": ">=22",
26
+ "node": "^22.19.0 || >=24.0.0",
27
27
  "dsh": ">=0.1.5-rc.2"
28
28
  },
29
29
  "author": "xingzhen199186",
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@deepseek-ai/cordis": "^4.0.2",
40
- "@deepseek-ai/dsh": ">=0.1.5-rc.2 <0.2.0",
40
+ "@deepseek-ai/dsh": ">=0.1.5-rc.2 <0.3.0",
41
41
  "@deepseek-ai/schemastery": "^3.18.3"
42
42
  },
43
43
  "peerDependenciesMeta": {