dsh-mini-remote 0.1.8 → 0.1.9

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.
@@ -0,0 +1,570 @@
1
+ /**
2
+ * 执行轨迹:把 DSH 会话事件里**过程**那部分(思考、工具调用、工具结果)重放成
3
+ * 手机「完整」模式要的一组一组条目。
4
+ *
5
+ * 和 lib/history.js 并列,吃的是**同一批原始事件**:那边产出「聊天记录」(你说了什么、
6
+ * 它答了什么),这边产出「过程」(它想了什么、调了哪个工具、那个工具返回了什么)。
7
+ *
8
+ * **实时那条路和重放那条路必须用同一个提取器**(下面 createTrajectoryTracker):
9
+ * 两边各写一套判断,「哪条算这一步的思考」「哪次调用配哪条结果」迟早会走散,
10
+ * 同一个会话在手机上和重开后就会长得不一样。lib/history.js 开头立过这条纪律。
11
+ *
12
+ * 边界(一个字都不许越):
13
+ * - 这里产出的一切**只走「完整」模式**:latest / history / 回答气泡一个字节都不碰。
14
+ * - 思考(reasoning 块)只在这里出现;lib/events.js 的 visibleText 仍然只取 text 块,
15
+ * stepSelfTalk 仍然只念旁白(2026-09-27 用户定的:思维链一个字都不上气泡)。
16
+ * - **旁白(say)2026-10-04 起进轨迹**(用户裁决):带工具调用那一步的 text 块就是
17
+ * 「对用户说的话」,别的模式由鲸鱼娘气泡念;完整模式把气泡藏了,它塞回轨迹——
18
+ * 照 PC,它是过程里的一段正文。收尾那条回答不带工具调用,它是回答,不进轨迹。
19
+ * - **不编时间**:事件上没有 `time` 就是 null。
20
+ * - **不编耗时**:一步一行没有耗时(桌面 ui-tool 里 duration / elapsed 各出现 0 次,
21
+ * 2026-10-01 实测),轮次级的耗时也不做。
22
+ * - 参数摘要只截**那一行的预览**,不丢内容:原始参数整段在同一张卡里(`args`)。
23
+ *
24
+ * 条目形状(一条 = 手机上一次折叠好的「一行的东西」):
25
+ * {
26
+ * id: string, // 稳定身份:工具步就是 callId(结果靠它配对);思考是
27
+ * // r<事件 seq>-<块序号>,没有 seq 时退回 t<轮>s<步>r<序号>。
28
+ * // 必须是**同一个事件在两条路上算出同一个 id**,否则
29
+ * // 「插件亲眼看见的」和「日志重放的」会对成两行。
30
+ * turn, step: number|null,
31
+ * kind: 'think'|'say'|'tool',
32
+ * name: string|null, // 工具名;思考为 null
33
+ * args: string|null, // 模型原样产出的参数 JSON(截断后)
34
+ * summary: string|null, // 折叠态那一行的参数摘要(一行、有长度上限;内容不丢,args 里有全文)
35
+ * output: string|null, // 这一条的正文:思考的内容 / 工具的结果文本(截断后)
36
+ * state: 'running'|'ok'|'error'|'stopped',
37
+ * error: {name, code, reason}|null, // 失败身份;reason 取不到就是 null(**不编原因**)
38
+ * truncated: {think?|args?|output?: {chars, total}}|null,
39
+ * timestamp: number|null
40
+ * }
41
+ *
42
+ * 「一步」不单独落一条条目:步号写在每条条目上,再落一条空条目的话,手机上的过程组里
43
+ * 会多出一行没有内容的行。step/start / step/end 只用来给「当前是第几步」定边界。
44
+ */
45
+ import { visibleText } from './events.js'
46
+
47
+ /** 单条正文(思考的内容 / 工具的参数 / 工具的输出)的硬上限。超出才截。 */
48
+ export const TRAJECTORY_MAX_CHARS = 4000
49
+
50
+ /** 每个会话保留的轨迹条目数上限,**从尾部留**(手机上看的是刚发生的过程)。 */
51
+ export const TRAJECTORY_MAX_ENTRIES = 120
52
+
53
+ /**
54
+ * 折叠态那一行的参数摘要上限。
55
+ *
56
+ * 和正文那条 4000 不是一回事:这一行是给「一眼扫过去」用的,原始参数整段在同一张卡里,
57
+ * 所以截它**不丢内容**,只是不让折叠行被一段 JSON 撑破。
58
+ */
59
+ const SUMMARY_MAX_CHARS = 200
60
+
61
+ /**
62
+ * 把一段正文按硬上限截断,并如实标出原始长度。
63
+ *
64
+ * 措辞照桌面端的 `json.truncated`:「… 已截断,共 {total} 字符」。
65
+ * **只兜极端值**:实测单条工具输出最大 49,930 字符、单条思考最大 31,210,而 4000 已经
66
+ * 是手机一屏的十倍——再长的只能靠页面那边滚动去读,这里只保证「不会一次性甩一兆到手机上」。
67
+ */
68
+ export function clipForTrajectory(text, limit = TRAJECTORY_MAX_CHARS) {
69
+ if (typeof text !== 'string' || text === '') return { text: '', truncated: null }
70
+ if (text.length <= limit) return { text, truncated: null }
71
+ return {
72
+ text: `${text.slice(0, limit)}\n… 已截断,共 ${text.length} 字符`,
73
+ truncated: { chars: limit, total: text.length },
74
+ }
75
+ }
76
+
77
+ /** 压成一行:摘要要的是「一眼」,换行和缩进不该带进来。 */
78
+ function oneLine(text) {
79
+ return String(text).replace(/\s+/g, ' ').trim()
80
+ }
81
+
82
+ /**
83
+ * 折叠行的参数摘要。**取值顺序照抄 DSH 桌面端**(dsh-client-ui-tool 的
84
+ * SUMMARY_KEYS / deriveSummary):命令类先取人写的「说明」(description),
85
+ * 命令原文排第二——折叠行给的是一眼能扫的短说明,具体的东西在展开里。
86
+ *
87
+ * (2026-10-04 用户真机报「词条右边把整条命令铺出来」,根因就是这里原来把
88
+ * command 排了第一。折叠行是给人扫的,不是给人读的。)
89
+ *
90
+ * 压成一行、限长:原始参数整段在 `args` 里,不丢内容。这不是审批卡上那个
91
+ * commandPreview(那边要的是完整的命令原文、上限 1200),两件事,
92
+ * 别把其中一个的上限套到另一个上。
93
+ */
94
+ const SUMMARY_KEYS = {
95
+ bash: ['description', 'command'],
96
+ read: ['path', 'file_path', 'url'],
97
+ search: ['query', 'pattern', 'url'],
98
+ write: ['path', 'file_path'],
99
+ edit: ['path', 'file_path'],
100
+ code: ['description'],
101
+ others: [],
102
+ }
103
+
104
+ /** 工具名 → 变体(归类同 PC 的 TOOL_VARIANTS)。 */
105
+ function summaryVariant(name) {
106
+ const n = String(name || '')
107
+ if (n === 'bash' || n === 'pwsh' || n === 'exec_command' || n === 'write_stdin'
108
+ || /^terminal_/.test(n)) return 'bash'
109
+ if (n === 'read' || n === 'read_image' || n === 'web_fetch') return 'read'
110
+ if (n === 'web_search' || n === 'grep' || n === 'glob' || /_inspect$/.test(n)) return 'search'
111
+ if (n === 'write') return 'write'
112
+ if (n === 'edit' || n === 'apply_patch') return 'edit'
113
+ if (n === 'run_code') return 'code'
114
+ return 'others'
115
+ }
116
+
117
+ function clipSummary(text) {
118
+ return text.length > SUMMARY_MAX_CHARS ? `${text.slice(0, SUMMARY_MAX_CHARS)}…` : text
119
+ }
120
+
121
+ export function argSummary(rawArgs, toolName) {
122
+ if (typeof rawArgs !== 'string' || !rawArgs.trim()) return ''
123
+ let args = null
124
+ try {
125
+ args = JSON.parse(rawArgs)
126
+ } catch {
127
+ // 参数不是合法 JSON(模型有时吐不完整):原样压成一行,**不假装解析成功**。
128
+ return oneLine(rawArgs).slice(0, SUMMARY_MAX_CHARS)
129
+ }
130
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
131
+ return oneLine(rawArgs).slice(0, SUMMARY_MAX_CHARS)
132
+ }
133
+ const variant = summaryVariant(toolName)
134
+ // search 变体先看 queries(一串查询词,照 PC 原样逗号连)。
135
+ if (variant === 'search' && Array.isArray(args.queries)) {
136
+ const qs = args.queries.filter((q) => typeof q === 'string' && q !== '')
137
+ if (qs.length) return clipSummary(qs.map(oneLine).join(', '))
138
+ }
139
+ for (const key of SUMMARY_KEYS[variant]) {
140
+ const v = args[key]
141
+ if (typeof v === 'string' && v.trim()) return clipSummary(oneLine(v))
142
+ }
143
+ // 兜底(照 PC):取第一个非空字符串;一个都没有就原样一行,不自造拼法。
144
+ for (const v of Object.values(args)) {
145
+ if (typeof v === 'string' && v !== '') return clipSummary(oneLine(v))
146
+ }
147
+ return oneLine(rawArgs).slice(0, SUMMARY_MAX_CHARS)
148
+ }
149
+
150
+ /** 失败身份:只认事件里真有的字段,取不到就是 null(页面据此写「失败」)。 */
151
+ function readError(err) {
152
+ if (!err || typeof err !== 'object') return { name: null, code: null, reason: null }
153
+ return {
154
+ name: typeof err.name === 'string' ? err.name : null,
155
+ code: typeof err.code === 'string' ? err.code : null,
156
+ reason: typeof err.reason === 'string' ? err.reason : null,
157
+ }
158
+ }
159
+
160
+ /**
161
+ * 一轮跑完之后,这一轮算「什么状态」。
162
+ *
163
+ * 桌面端那一档是 `location.turn.status === 'open' || reason.kind === 'aborted' || 'error'`,
164
+ * 手机这里对应四种:正在跑 / 已停 / 失败 / 完成。
165
+ *
166
+ * `blocked` / `max-tokens` 和没见过的一律算「已停」:那一轮确实没有正常跑完,而说成
167
+ * 「失败」会把「被挡下」和「出错了」混成一句话——这是两件不同的事。
168
+ */
169
+ export function turnStateOf(reasonKind) {
170
+ if (reasonKind === 'completed') return 'done'
171
+ if (reasonKind === 'error') return 'error'
172
+ return 'stopped'
173
+ }
174
+
175
+ /** 事件上的轮 / 步号。不是数字就是「不知道」,**不回退成 0**。 */
176
+ function numOrNull(v) {
177
+ return typeof v === 'number' && Number.isFinite(v) ? v : null
178
+ }
179
+
180
+ /** id 里的块序号:同一条消息里可能有不止一个 reasoning 块。 */
181
+ function thinkId(turn, step, index, seq) {
182
+ // 事件自己的 `seq` 在同一个会话里唯一,而且**两条路看到的是同一个值**——
183
+ // 这是「实时那份和重放那份能把同一条思考认成同一条」的关键。
184
+ if (typeof seq === 'number' && Number.isFinite(seq) && seq > 0) return `r${seq}-${index}`
185
+ return `t${turn}s${step}r${index}`
186
+ }
187
+
188
+ /** 旁白(say)的 id:和思考同一套编号法,前缀错开(y),实时与重放照样对得上。 */
189
+ function sayId(turn, step, index, seq) {
190
+ if (typeof seq === 'number' && Number.isFinite(seq) && seq > 0) return `y${seq}-${index}`
191
+ return `t${turn}s${step}y${index}`
192
+ }
193
+
194
+ /**
195
+ * 轨迹提取器。实时那条路(lib/index.js 的 session/event)和重放那条路
196
+ * (下面的 replayTrajectory)都喂它,按 sessionId 各自累计。
197
+ *
198
+ * `feed` 只回「这一口带来的变化」,不自己存条目:条目要落在哪个缓冲里、要不要推给手机,
199
+ * 是调用方的事(实时那条路落在 store 的内存缓冲里,重放那条路直接攒成一组一组)。
200
+ */
201
+ export function createTrajectoryTracker() {
202
+ /** @type {Map<string, {turn:number|null, step:number|null, running:Set<string>}>} */
203
+ const bySession = new Map()
204
+
205
+ function slot(sessionId) {
206
+ let s = bySession.get(sessionId)
207
+ if (!s) {
208
+ s = { turn: null, step: null, running: new Set() }
209
+ bySession.set(sessionId, s)
210
+ }
211
+ return s
212
+ }
213
+
214
+ return {
215
+ /**
216
+ * 喂一条会话事件。
217
+ * @returns {null | {turn:number|null, entries:Array<object>, updates:Array<object>,
218
+ * state:string|null, reason:string|null}}
219
+ * null = 这件事与轨迹无关(或者不该单独成一条),什么都不用做。
220
+ */
221
+ feed(sessionId, event) {
222
+ if (!event || typeof event.type !== 'string') return null
223
+ const s = slot(sessionId)
224
+ const data = event.data ?? {}
225
+ const at = typeof event.time === 'number' ? event.time : null
226
+
227
+ switch (event.type) {
228
+ case 'turn/start': {
229
+ const turn = numOrNull(data.turn) ?? s.turn
230
+ s.turn = turn
231
+ s.step = null
232
+ s.running.clear()
233
+ // 一轮开始也要说一声:手机上那一组靠它先立起来(状态「正在跑」= 不折起来)。
234
+ return { turn, entries: [], updates: [], state: 'running', reason: null }
235
+ }
236
+
237
+ case 'step/start':
238
+ case 'step/end': {
239
+ s.turn = numOrNull(data.turn) ?? s.turn
240
+ s.step = numOrNull(data.step) ?? s.step
241
+ return null
242
+ }
243
+
244
+ case 'assistant/message': {
245
+ const turn = numOrNull(data.turn) ?? s.turn
246
+ const step = numOrNull(data.step) ?? s.step
247
+ s.turn = turn
248
+ s.step = step
249
+ const content = data.message?.content
250
+ if (!Array.isArray(content)) return null
251
+ // 被按停的那一轮,这条消息是「已经吐出来的那半句」(interrupted: true):
252
+ // 内容留着(那是它真想过的东西),但状态如实说「停了」。
253
+ const interrupted = data.interrupted === true
254
+ // 带工具调用的那一步,它的正文是**旁白**(对用户说的话,别的模式由鲸鱼娘
255
+ // 气泡念)。2026-10-04 用户裁决把它塞回轨迹(完整模式把气泡藏了,它没处去):
256
+ // 照 PC,它是过程里的一段正文。收尾那条回答不带工具调用——它是回答,
257
+ // 回答区已经有它,**不进轨迹**(events.js 的「哪条算最终回答」是同一把尺)。
258
+ const hasToolCall = content.some((b) => b && b.type === 'tool-call')
259
+ const entries = []
260
+ let thinkIndex = 0
261
+ let sayIndex = 0
262
+ for (const block of content) {
263
+ if (!block) continue
264
+ if (block.type === 'reasoning') {
265
+ const i = thinkIndex++
266
+ if (typeof block.text !== 'string' || !block.text.trim()) continue
267
+ const clipped = clipForTrajectory(block.text)
268
+ entries.push({
269
+ id: thinkId(turn, step, i, event.seq),
270
+ turn,
271
+ step,
272
+ kind: 'think',
273
+ name: null,
274
+ args: null,
275
+ summary: null,
276
+ // 思考的正文放 output:这一条「想的内容」和工具「返回的内容」在页面上
277
+ // 是同一个位置(折叠行的正文),不另造一个字段。
278
+ output: clipped.text,
279
+ state: interrupted ? 'stopped' : 'ok',
280
+ error: null,
281
+ truncated: clipped.truncated ? { think: clipped.truncated } : null,
282
+ timestamp: at,
283
+ })
284
+ continue
285
+ }
286
+ if (block.type === 'text' && hasToolCall) {
287
+ const i = sayIndex++
288
+ if (typeof block.text !== 'string' || !block.text.trim()) continue
289
+ const clipped = clipForTrajectory(block.text)
290
+ entries.push({
291
+ id: sayId(turn, step, i, event.seq),
292
+ turn,
293
+ step,
294
+ kind: 'say',
295
+ name: null,
296
+ args: null,
297
+ summary: null,
298
+ output: clipped.text,
299
+ state: interrupted ? 'stopped' : 'ok',
300
+ error: null,
301
+ truncated: clipped.truncated ? { output: clipped.truncated } : null,
302
+ timestamp: at,
303
+ })
304
+ }
305
+ }
306
+ if (!entries.length) return null
307
+ return { turn, entries, updates: [], state: null, reason: null }
308
+ }
309
+
310
+ case 'tool/call': {
311
+ const turn = numOrNull(data.turn) ?? s.turn
312
+ const step = numOrNull(data.step) ?? s.step
313
+ s.turn = turn
314
+ s.step = step
315
+ const raw = typeof data.arguments === 'string' ? data.arguments : ''
316
+ const clipped = clipForTrajectory(raw)
317
+ const callId = data.callId === undefined || data.callId === null ? '' : String(data.callId)
318
+ // callId 是结果回来时配对的钥匙。真缺了就按事件位置造一个——**不假装有配对**,
319
+ // 那条工具步会一直停在「正在跑」(它确实没有结果可配)。
320
+ const id = callId || `k${typeof event.seq === 'number' ? event.seq : turn}.${step}`
321
+ s.running.add(id)
322
+ return {
323
+ turn,
324
+ entries: [{
325
+ id,
326
+ turn,
327
+ step,
328
+ kind: 'tool',
329
+ name: typeof data.name === 'string' && data.name ? data.name : null,
330
+ args: clipped.text,
331
+ summary: argSummary(raw, data.name),
332
+ output: null,
333
+ state: 'running',
334
+ error: null,
335
+ truncated: clipped.truncated ? { args: clipped.truncated } : null,
336
+ timestamp: at,
337
+ }],
338
+ updates: [],
339
+ state: null,
340
+ reason: null,
341
+ }
342
+ }
343
+
344
+ case 'tool/result': {
345
+ const turn = numOrNull(data.turn) ?? s.turn
346
+ const step = numOrNull(data.step) ?? s.step
347
+ s.turn = turn
348
+ s.step = step
349
+ const message = data.message ?? {}
350
+ const id = String(message.toolCallId ?? data.callId ?? data.toolCallId ?? '')
351
+ // 没有编号就配不上任何一条,**不硬塞一条新的**:那会在手机上多出一行
352
+ // 没有名字、也没有参数的空壳。
353
+ if (!id) return null
354
+ // 工具的正文只有文本那一份:拿不到文本块(比如只有一张图)就是空——
355
+ // 照桌面「不受支持的输入用压平后的工具结果文本」那条 fallback,**不编内容**。
356
+ const clipped = clipForTrajectory(visibleText(message.content))
357
+ const failed = message.isError === true || data.error !== undefined && data.error !== null
358
+ // 结果到了(不管成没成)就不再是「正在跑」:不然收尾那一轮会把这条
359
+ // 已经落定的失败/成功改写成「停了」。
360
+ s.running.delete(id)
361
+ const patch = {
362
+ id,
363
+ state: failed ? 'error' : 'ok',
364
+ output: clipped.text,
365
+ error: failed ? readError(data.error) : null,
366
+ }
367
+ if (clipped.truncated) patch.truncated = { output: clipped.truncated }
368
+ return { turn, entries: [], updates: [patch], state: null, reason: null }
369
+ }
370
+
371
+ case 'turn/end': {
372
+ const turn = numOrNull(data.turn) ?? s.turn
373
+ // 这一轮里还在「正在跑」的工具步,结果永远不会来了(被按停 / 出错 / 会话断了):
374
+ // 如实标成「停了」,否则手机上那一行会一直转圈。
375
+ const updates = []
376
+ for (const id of s.running) updates.push({ id, state: 'stopped' })
377
+ s.running.clear()
378
+ s.turn = turn
379
+ s.step = null
380
+ return {
381
+ turn,
382
+ entries: [],
383
+ updates,
384
+ state: turnStateOf(data.reason?.kind),
385
+ reason: data.reason?.kind ?? null,
386
+ }
387
+ }
388
+
389
+ default:
390
+ return null
391
+ }
392
+ },
393
+
394
+ /** 会话销毁时清掉累计器,避免长期运行下 Map 无限增长。 */
395
+ forget(sessionId) {
396
+ bySession.delete(sessionId)
397
+ },
398
+ }
399
+ }
400
+
401
+ /**
402
+ * 把一串原始事件重放成**一组一轮**的轨迹(形状和实时那条路攒出来的完全一样)。
403
+ *
404
+ * 这一步就是「点开一个老会话时,完整模式里看到的过程」的来源。窗口是从日志尾部读的,
405
+ * 所以第一组可能是「半轮」——那由调用方如实标注(读取上限),不在这里假装完整。
406
+ */
407
+ export function replayTrajectory(events) {
408
+ const tracker = createTrajectoryTracker()
409
+ const groups = []
410
+ const byTurn = new Map()
411
+ for (const event of events ?? []) {
412
+ // 一个会话的日志只会喂给一个 tracker,传个固定值就行(同 lib/history.js)。
413
+ const change = tracker.feed('replay', event)
414
+ if (!change) continue
415
+ let group = byTurn.get(change.turn)
416
+ if (!group) {
417
+ group = { turn: change.turn, state: 'running', reason: null, entries: [] }
418
+ byTurn.set(change.turn, group)
419
+ groups.push(group)
420
+ }
421
+ for (const entry of change.entries) group.entries.push(entry)
422
+ for (const patch of change.updates) {
423
+ const hit = entryById(group.entries, patch.id)
424
+ // 结果落在窗口之外(读日志只读了尾部,那条 tool/call 没进来):那一条工具步
425
+ // 本来就不在手机上,没有「一直转圈」的一面,丢掉即可。**不补一条空壳。**
426
+ if (hit) applyTrajectoryPatch(hit, patch)
427
+ }
428
+ if (change.state) {
429
+ group.state = change.state
430
+ group.reason = change.reason ?? null
431
+ }
432
+ }
433
+ return groups
434
+ }
435
+
436
+ /** 从后往前找(结果总是紧跟着它的调用回来,尾部命中率最高)。 */
437
+ function entryById(entries, id) {
438
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
439
+ if (entries[i]?.id === id) return entries[i]
440
+ }
441
+ return null
442
+ }
443
+
444
+ /**
445
+ * 把一条增量补丁贴到条目上。**只改补丁里真有的字段**——「停了」那种补丁不该顺手
446
+ * 把输出清空。截断信息是**合并**而不是替换:一条工具步可能参数超长、结果也超长,
447
+ * 两个都要留。
448
+ */
449
+ export function applyTrajectoryPatch(entry, patch) {
450
+ if (!entry || !patch || !patch.id) return
451
+ if (typeof patch.state === 'string') entry.state = patch.state
452
+ if (Object.prototype.hasOwnProperty.call(patch, 'output')) entry.output = patch.output
453
+ if (Object.prototype.hasOwnProperty.call(patch, 'error')) entry.error = patch.error
454
+ if (patch.truncated) entry.truncated = { ...(entry.truncated ?? {}), ...patch.truncated }
455
+ }
456
+
457
+ /**
458
+ * 条目数上限:**从尾部留**。
459
+ *
460
+ * 整轮整轮地数,只有在「这一轮整个放不下」时才把它截开——所以还活着的那一轮、
461
+ * 以及一条条目都还没产生的新一轮(turn/start 刚到)都不会因为上限被丢掉。
462
+ * 返回被砍掉的条数(只为如实说「这不是全部」)。
463
+ */
464
+ export function capTrajectory(turns, max = TRAJECTORY_MAX_ENTRIES) {
465
+ const list = Array.isArray(turns) ? turns : []
466
+ let total = 0
467
+ for (const g of list) total += g?.entries?.length ?? 0
468
+ if (total <= max) return { turns: list, dropped: 0 }
469
+
470
+ let budget = max
471
+ let start = list.length
472
+ for (let i = list.length - 1; i >= 0; i -= 1) {
473
+ const n = list[i]?.entries?.length ?? 0
474
+ if (n > budget) break
475
+ budget -= n
476
+ start = i
477
+ }
478
+
479
+ const kept = []
480
+ const partial = start - 1
481
+ if (partial >= 0 && budget > 0) {
482
+ const g = list[partial]
483
+ kept.push({ ...g, entries: (g.entries ?? []).slice(-budget) })
484
+ }
485
+ for (let i = start; i < list.length; i += 1) kept.push(list[i])
486
+
487
+ let shown = 0
488
+ for (const g of kept) shown += g?.entries?.length ?? 0
489
+ return { turns: kept, dropped: total - shown }
490
+ }
491
+
492
+ /**
493
+ * 把「插件亲眼看见的那份」和「会话记录里重放出来的那份」合成一张表。
494
+ *
495
+ * 两个来源覆盖的是同一段过程的不同部分:重放那份来自日志(完整,但最多 60 秒前读的),
496
+ * 内存那份是刚发生的(实时,但插件可能中途才起来)。按 `id` 认人——**同一个 id 只留一条**,
497
+ * 内容以内存那份为准(它是后发生的,见 mergeInto 的那条例外),重放那份补上更早的轮次。
498
+ *
499
+ * 产出的条目是**副本**:调用方(GET 接口)会往上贴补丁,不能回头污染重放缓存。
500
+ */
501
+ export function mergeTrajectory(liveTurns, replayTurns) {
502
+ const live = Array.isArray(liveTurns) ? liveTurns : []
503
+ const replay = Array.isArray(replayTurns) ? replayTurns : []
504
+ if (!live.length) return replay.map(copyGroup)
505
+ const out = []
506
+ const byTurn = new Map()
507
+ for (const g of replay.map(copyGroup)) {
508
+ byTurn.set(g.turn, g)
509
+ out.push(g)
510
+ }
511
+ for (const g of live) {
512
+ const entries = (g.entries ?? []).map((e) => ({ ...e }))
513
+ let hit = byTurn.get(g.turn)
514
+ if (!hit) {
515
+ hit = { turn: g.turn, state: g.state ?? 'running', reason: g.reason ?? null, entries: [] }
516
+ byTurn.set(g.turn, hit)
517
+ out.push(hit)
518
+ }
519
+ const known = new Set(hit.entries.map((e) => e.id))
520
+ for (const e of entries) {
521
+ if (!known.has(e.id)) {
522
+ hit.entries.push(e)
523
+ known.add(e.id)
524
+ continue
525
+ }
526
+ mergeInto(hit.entries.find((x) => x.id === e.id), e)
527
+ }
528
+ // 同一条纪律也管整轮:状态以内心里那份为准,但「正在跑」不是一个更晚的结论,
529
+ // 不许把重放那份已经落定的那一轮改回转圈。
530
+ if (g.state && (g.state !== 'running' || hit.state === 'running')) {
531
+ hit.state = g.state
532
+ hit.reason = g.reason ?? null
533
+ }
534
+ }
535
+ // 轮的先后按轮号排。内存那份可能是从中间开始的,直接首尾相接会乱;
536
+ // 轮号不是数字的(极少见)排在最后,它们之间保持原顺序。
537
+ const rank = (turn) => (typeof turn === 'number' ? turn : Number.MAX_SAFE_INTEGER)
538
+ return out.sort((a, b) => rank(a.turn) - rank(b.turn))
539
+ }
540
+
541
+ function copyGroup(g) {
542
+ return {
543
+ turn: g?.turn ?? null,
544
+ state: g?.state ?? 'running',
545
+ reason: g?.reason ?? null,
546
+ entries: (g?.entries ?? []).map((e) => ({ ...e })),
547
+ }
548
+ }
549
+
550
+ /**
551
+ * 把「实时那份」的一条合并进「重放那份」的同一条上(同一个 id = 同一次调用、同一段思考)。
552
+ *
553
+ * 以内心里那份为准:它是亲眼看见的、后发生的,而日志最长可能是一分钟前读的。
554
+ * 只有一条例外——**「正在跑」是「还不知道」,不是一种更晚的结论**:重放那份已经落定
555
+ * (结果回来了、或者被标成停了)而实时那份还停在「正在跑」时,不许把它改回转圈。
556
+ * 两边的正文也各取有内容的那个:实时那份可能还没等到结果,重放那份可能读得早了一点。
557
+ */
558
+ function mergeInto(target, fresh) {
559
+ if (!target || !fresh) return
560
+ const keepSettled = fresh.state === 'running' && target.state !== 'running'
561
+ target.state = keepSettled ? target.state : (fresh.state ?? target.state)
562
+ target.name = fresh.name ?? target.name
563
+ target.args = fresh.args ?? target.args
564
+ target.summary = fresh.summary ?? target.summary
565
+ target.output = fresh.output ?? target.output
566
+ target.error = fresh.error ?? target.error
567
+ target.timestamp = fresh.timestamp ?? target.timestamp
568
+ const truncated = { ...(target.truncated ?? {}), ...(fresh.truncated ?? {}) }
569
+ target.truncated = Object.keys(truncated).length ? truncated : null
570
+ }
package/lib/tree.js CHANGED
@@ -284,21 +284,39 @@ async function fillTitles(query, cache, ids, persisted, workspaceId, onTitle) {
284
284
  }
285
285
  }
286
286
 
287
- async function safeRecords(query, { force = false } = {}) {
287
+ async function safeRecords(query, { force = false, meta } = {}) {
288
288
  if (!query) return []
289
289
  let entry = caches.get(query)
290
290
  if (!entry) {
291
291
  entry = { at: 0, records: null, inflight: null }
292
292
  caches.set(query, entry)
293
293
  }
294
+ if (!force && !entry.records && meta?.all) {
295
+ const cached = meta.all()
296
+ if (cached.length) {
297
+ entry.records = cached
298
+ // 这份数据只负责冷启动首屏,后台立刻用 DSH 清单校正。
299
+ entry.at = 0
300
+ }
301
+ }
294
302
  if (!force && entry.records && Date.now() - entry.at < CACHE_TTL_MS) return entry.records
295
- // 同一时刻来两个请求就共用一趟,别让它们各扫一遍盘。
303
+ // 有旧记录时直接返回,同时只启动一趟后台校正;没有旧记录才等待首次读取。
304
+ if (entry.records && !force) {
305
+ if (!entry.inflight) entry.inflight = refreshRecords(query, entry, meta)
306
+ return entry.records
307
+ }
296
308
  if (entry.inflight) return entry.inflight
309
+ entry.inflight = refreshRecords(query, entry, meta)
310
+ return entry.inflight
311
+ }
312
+
313
+ async function refreshRecords(query, entry, meta) {
297
314
  entry.inflight = (async () => {
298
315
  try {
299
316
  const records = (await query.listSessions()) ?? []
300
317
  entry.at = Date.now()
301
318
  entry.records = records
319
+ meta?.replace(records)
302
320
  return records
303
321
  } catch (err) {
304
322
  // 读不到就当作没有:导航栏少几项,不该影响遥控本身。失败不进缓存,下次还会重试。
@@ -350,8 +368,8 @@ function titleText(value) {
350
368
  * (闸门脚本就是这么逮到的:接口回 created=true,列表里却找不到。)
351
369
  * 默认仍然是「滤掉」,桌面端那些老调用方的行为一点不变。
352
370
  */
353
- export async function listWorkspaces({ registry, query, runningIds, force, includeEmpty }) {
354
- const byId = toRecordMap(await safeRecords(query, { force }))
371
+ export async function listWorkspaces({ registry, query, runningIds, force, includeEmpty, meta }) {
372
+ const byId = toRecordMap(await safeRecords(query, { force, meta }))
355
373
  const out = []
356
374
  for (const ws of safeList(registry)) {
357
375
  const ids = (ws.sessionIds ?? []).filter((id) => visible(byId.get(id)))
@@ -388,7 +406,7 @@ export async function listWorkspaces({ registry, query, runningIds, force, inclu
388
406
  * - `no-workspace` 工作区不在了(桌面端刚把它删掉)
389
407
  * - `failed` DSH 拒绝了,原因原样带回
390
408
  */
391
- export async function createSessionIn({ controller, registry, workspaceId }) {
409
+ export async function createSessionIn({ controller, registry, workspaceId, agentPreset }) {
392
410
  if (!controller || typeof controller.create !== 'function') {
393
411
  return { ok: false, reason: 'no-controller' }
394
412
  }
@@ -397,7 +415,11 @@ export async function createSessionIn({ controller, registry, workspaceId }) {
397
415
 
398
416
  let created
399
417
  try {
400
- created = await controller.create({ workspaceId })
418
+ // agentPreset(2026-10-04,手机上也能选模式):**选了才带**——没选就让 DSH 用它
419
+ // 自己的默认,不猜、不塞一个假值。
420
+ created = await controller.create(agentPreset
421
+ ? { workspaceId, agentPreset }
422
+ : { workspaceId })
401
423
  } catch (err) {
402
424
  return { ok: false, reason: 'failed', error: String(err?.message ?? err) }
403
425
  }
@@ -479,12 +501,12 @@ function shapeWorkspace(ws) {
479
501
  */
480
502
  export async function listSessionsOf({
481
503
  registry, query, agents, knownTitles, persisted, workspaceId,
482
- limit = SESSIONS_PER_WORKSPACE, force, onTitle,
504
+ limit = SESSIONS_PER_WORKSPACE, force, onTitle, meta,
483
505
  }) {
484
506
  const ws = safeList(registry).find((w) => w.id === workspaceId)
485
507
  if (!ws) return null
486
508
 
487
- const byId = toRecordMap(await safeRecords(query, { force }))
509
+ const byId = toRecordMap(await safeRecords(query, { force, meta }))
488
510
  const rows = (ws.sessionIds ?? [])
489
511
  .map((id) => byId.get(id))
490
512
  .filter(visible)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mini-remote",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "DSH 极简移动端遥控器:只把「你的指令」和「AI 的最终回复」送到手机,屏蔽全部中间过程。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",