dsh-mobile-plugin 0.0.0-stage → 0.1.57

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.
Files changed (87) hide show
  1. package/README.md +128 -2
  2. package/config/agent-presets/standard/agent.cordis.yml +170 -0
  3. package/config/agent-presets/standard/preset.yml +3 -0
  4. package/cordis.mobile-host.yml +48 -0
  5. package/cordis.patch.yml +21 -0
  6. package/dsh-mobile-credentials/lib/credentials.js +135 -0
  7. package/dsh-mobile-credentials/package.json +13 -0
  8. package/host.js +128 -0
  9. package/lib/auto-archive.cjs +98 -0
  10. package/lib/client.js +690 -0
  11. package/lib/desktop-half-stats.cjs +101 -0
  12. package/lib/desktop-view.cjs +109 -0
  13. package/lib/host-guard.cjs +230 -0
  14. package/lib/host-patches.cjs +46 -0
  15. package/lib/http/package.json +3 -0
  16. package/lib/http/routing.js +84 -0
  17. package/lib/impl.js +406 -0
  18. package/lib/mirror/pusher.cjs +706 -0
  19. package/lib/mirror-snapshot.cjs +39 -0
  20. package/lib/mobile/agent-presets.js +182 -0
  21. package/lib/mobile/attachments.js +116 -0
  22. package/lib/mobile/auth.js +78 -0
  23. package/lib/mobile/completion.js +114 -0
  24. package/lib/mobile/credentials.js +150 -0
  25. package/lib/mobile/directory-browse.js +295 -0
  26. package/lib/mobile/envelope.js +51 -0
  27. package/lib/mobile/events.js +293 -0
  28. package/lib/mobile/history-derive.js +608 -0
  29. package/lib/mobile/host-bridge/endpoints.js +189 -0
  30. package/lib/mobile/host-bridge/event-synthesis.js +232 -0
  31. package/lib/mobile/host-bridge/shims.js +218 -0
  32. package/lib/mobile/host-bridge/transport.js +168 -0
  33. package/lib/mobile/host-bridge.js +71 -0
  34. package/lib/mobile/host-settings.js +282 -0
  35. package/lib/mobile/idempotency.js +78 -0
  36. package/lib/mobile/jobs.js +97 -0
  37. package/lib/mobile/local-images.js +153 -0
  38. package/lib/mobile/package.json +3 -0
  39. package/lib/mobile/plugins.js +149 -0
  40. package/lib/mobile/questions.js +71 -0
  41. package/lib/mobile/references.js +209 -0
  42. package/lib/mobile/rpc.js +76 -0
  43. package/lib/mobile/session-cache.js +263 -0
  44. package/lib/mobile/session-goal.js +44 -0
  45. package/lib/mobile/session-log.js +94 -0
  46. package/lib/mobile/session-mirror.js +177 -0
  47. package/lib/mobile/session-models.js +153 -0
  48. package/lib/mobile/session-presets.js +52 -0
  49. package/lib/mobile/session-search.js +65 -0
  50. package/lib/mobile/session-send.js +278 -0
  51. package/lib/mobile/session-snapshot.js +142 -0
  52. package/lib/mobile/sessions.js +462 -0
  53. package/lib/mobile/skills.js +80 -0
  54. package/lib/mobile/stream.js +268 -0
  55. package/lib/mobile/subagents.js +265 -0
  56. package/lib/mobile/workspaces.js +214 -0
  57. package/lib/panel/index.html +33 -0
  58. package/lib/read-receipt.cjs +124 -0
  59. package/lib/reconnect-scheduler.cjs +37 -0
  60. package/lib/relay/core.js +77 -0
  61. package/lib/relay/lan-network.cjs +85 -0
  62. package/lib/relay/lan-proxy.js +593 -0
  63. package/lib/relay/package.json +3 -0
  64. package/lib/relay/pairing-policy.js +45 -0
  65. package/lib/relay/port-allocator.cjs +60 -0
  66. package/lib/relay/preview.js +374 -0
  67. package/lib/relay/protocol.js +177 -0
  68. package/lib/relay/status.js +145 -0
  69. package/lib/relay/tunnel.js +721 -0
  70. package/lib/routes/control-plane.js +311 -0
  71. package/lib/routes/mobile-api.js +208 -0
  72. package/lib/routes/package.json +3 -0
  73. package/lib/session-cleanup.cjs +153 -0
  74. package/lib/shared/credentials-entry.js +48 -0
  75. package/lib/shared/dshtoken.js +62 -0
  76. package/lib/shared/fresh-request.js +35 -0
  77. package/lib/shared/http-util.js +71 -0
  78. package/lib/shared/list-revision.js +51 -0
  79. package/lib/shared/list-signal.js +87 -0
  80. package/lib/shared/package.json +3 -0
  81. package/lib/shared/paths.js +27 -0
  82. package/lib/shared/session-signal.js +110 -0
  83. package/lib/shared/workspace-guard.js +127 -0
  84. package/lib/sidecar-facade-worker.cjs +353 -0
  85. package/lib/sidecar-facade.cjs +141 -0
  86. package/lib/sidecar-lifecycle.cjs +246 -0
  87. package/package.json +74 -4
@@ -0,0 +1,608 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 会话历史的纯派生逻辑:从 host history 事件折叠出 v1 移动端的消息/结构化项。
5
+ *
6
+ * 本模块只做「事件 → 展示结构」的纯计算,不碰 ctx、不发 RPC,因此可以单独测;
7
+ * 原先这些函数与路由 handler 混在 lib/mobile/sessions.js 里(该文件 1964 行),
8
+ * 拆出来后 handler 层只负责 HTTP 与宿主交互。
9
+ */
10
+
11
+ /**
12
+ * 从 session.list 摘要行取会话标题(与桌面端侧栏同源,不再从 history 窗口猜):
13
+ * - 优先读 title 投影(dsh-session-title 在「完整日志」上折叠的最新 session/title
14
+ * 事件,正是桌面端侧栏显示的同一个值;空白会话恒为 '',由前端显示「新会话」占位);
15
+ * - 无正式标题时回退到工作区目录 basename,最后回退到 sessionId
16
+ * (对齐 dsh-client-runtime displayTitleOf 的兜底链)。
17
+ * 注意:不能从 history 的 30 条窗口里找标题——长会话会把标题事件挤出窗口(桌面端无此问题)。
18
+ */
19
+ function listTitle(s) {
20
+ if (!s || s.blank) return '';
21
+ const t = s.projections && s.projections.values ? s.projections.values.title : undefined;
22
+ if (typeof t === 'string' && t !== '') return t;
23
+ if (typeof s.cwd === 'string' && s.cwd !== '') {
24
+ const base = s.cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop();
25
+ if (base !== undefined && base !== '') return base;
26
+ }
27
+ return s.sessionId || '';
28
+ }
29
+
30
+ /** 单文件一行文本的传输上限:整文件 create/overwrite 的 newText 可能极大,超出即截断并标记。 */
31
+ const DIFF_TEXT_MAX_LINES = 400;
32
+ /** 一次工具调用最多回传的 diff 文件数(对齐桌面「一行一张卡」的粒度,避免畸形 meta 撑爆载荷)。 */
33
+ const DIFF_MAX_FILES = 20;
34
+
35
+ /** 按行数截断一段 diff 文本;未超限原样返回,超限保留前 MAX 行并标记 truncated。 */
36
+ function capDiffText(text) {
37
+ if (typeof text !== 'string') return undefined;
38
+ let lines = 0;
39
+ for (let i = 0; i < text.length; i += 1) {
40
+ if (text.charCodeAt(i) === 10) lines += 1;
41
+ if (lines >= DIFF_TEXT_MAX_LINES) {
42
+ return { text: text.slice(0, i), truncated: true };
43
+ }
44
+ }
45
+ return { text, truncated: false };
46
+ }
47
+
48
+ /**
49
+ * 收窄 tool/result 的 `data.meta`(write/edit 的 FsDiffMeta)为可回传的 diff 列表。
50
+ *
51
+ * 桌面 diff 卡的数据源头就是这里:fs 工具把「已应用的分块上下文 diff」挂在
52
+ * `result.meta` 上(dsh-tool-fs 的 computeHunkDiffs,DIFF_CONTEXT=3),
53
+ * agent loop 原样写进 tool/result 事件(dsh-agent-loop/lib/index.js:313
54
+ * `...result.meta !== void 0 ? { meta: result.meta } : {}`),并随会话日志持久化。
55
+ * 此前移动端投影只读 message.content,把 meta 整个丢掉——于是 App 里 write/edit
56
+ * 只剩一坨 JSON 参数,而桌面显示的是真正改了什么。
57
+ *
58
+ * 只接受形状正确的条目(path 非空字符串、newText 字符串、oldText 字符串或 null),
59
+ * 畸形数据返回 undefined 让调用方退回通用卡片,绝不抛错。
60
+ */
61
+ function normalizeResultDiffs(meta) {
62
+ if (!meta || typeof meta !== 'object' || !Array.isArray(meta.diffs)) return undefined;
63
+ const out = [];
64
+ for (const raw of meta.diffs) {
65
+ if (!raw || typeof raw !== 'object') continue;
66
+ const path = typeof raw.path === 'string' && raw.path !== '' ? raw.path : undefined;
67
+ const newText = capDiffText(raw.newText);
68
+ if (path === undefined || newText === undefined) continue;
69
+ const oldText = typeof raw.oldText === 'string' ? capDiffText(raw.oldText) : undefined;
70
+ if (raw.oldText !== null && raw.oldText !== undefined && typeof raw.oldText !== 'string') continue;
71
+ out.push({
72
+ path,
73
+ oldText: oldText === undefined ? null : oldText.text,
74
+ newText: newText.text,
75
+ ...newText.truncated || (oldText && oldText.truncated) ? { truncated: true } : {},
76
+ });
77
+ if (out.length >= DIFF_MAX_FILES) break;
78
+ }
79
+ return out.length > 0 ? out : undefined;
80
+ }
81
+
82
+ /** 预扫描 tool/result 事件,构建 callId → {isError, code} 映射(对接桌面 toolRowModel 状态派生)。 */
83
+ function buildToolResultMap(events) {
84
+ const map = new Map();
85
+ for (const entry of events) {
86
+ const e = entry.event || entry;
87
+ if (e.type !== 'tool/result' || !e.data || !e.data.message) continue;
88
+ const msg = e.data.message;
89
+ const src = msg.source;
90
+ if (!src || typeof src.callId !== 'string') continue;
91
+ const content = Array.isArray(msg.content) ? msg.content[0] : null;
92
+ const isError = content && content.isError === true;
93
+ const code = e.data.error && typeof e.data.error.code === 'string' ? e.data.error.code : undefined;
94
+ const parts = content && Array.isArray(content.content) ? content.content : content ? [content] : [];
95
+ const output = parts.map((part) => {
96
+ if (part && typeof part.text === 'string') return part.text;
97
+ if (part && typeof part === 'object' && part.type === 'image') return '';
98
+ try { return JSON.stringify(part); } catch { return ''; }
99
+ }).filter(Boolean).join('');
100
+ const images = parts
101
+ .filter((part) => part && typeof part === 'object' && part.type === 'image'
102
+ && typeof part.data === 'string' && typeof part.mediaType === 'string')
103
+ .map((part) => ({ mediaType: part.mediaType, data: part.data, ...(typeof part.name === 'string' ? { name: part.name } : {}) }));
104
+ const diffs = normalizeResultDiffs(e.data.meta);
105
+ map.set(src.callId, { isError, code, ...(output ? { output } : {}), ...(images.length ? { images } : {}), ...(diffs ? { diffs } : {}) });
106
+ }
107
+ return map;
108
+ }
109
+
110
+ /** 把 assistant message.content 解析为一个结构块数组(text/reasoning/tool-call/image)。 */
111
+ function parseBlocks(blocks, toolResults) {
112
+ if (!Array.isArray(blocks)) return [];
113
+ const out = [];
114
+ for (const b of blocks) {
115
+ if (!b || typeof b.type !== 'string') continue;
116
+ if (b.type === 'text' && typeof b.text === 'string' && b.text) {
117
+ out.push({ type: 'text', text: b.text });
118
+ } else if (b.type === 'reasoning') {
119
+ // Reasoning blocks may be emitted as empty placeholders when the provider
120
+ // does not expose chain-of-thought. They are known blocks, not unsupported
121
+ // content; silently omit empty placeholders and avoid leaking their JSON.
122
+ if (typeof b.text === 'string' && b.text) out.push({ type: 'reasoning', text: b.text });
123
+ } else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mediaType === 'string') {
124
+ out.push({ type: 'image', mediaType: b.mediaType, data: b.data, name: typeof b.name === 'string' ? b.name : undefined });
125
+ } else if (b.type === 'tool-call') {
126
+ // tool-call 块字段为 id/name/arguments(与 harness toAssistantBlock 同口径)。
127
+ const callId = b.id != null ? String(b.id) : undefined;
128
+ let state;
129
+ let output;
130
+ let images;
131
+ let diffs;
132
+ if (callId && toolResults) {
133
+ const result = toolResults.get(callId);
134
+ if (result) {
135
+ output = result.output;
136
+ images = result.images;
137
+ // write/edit 的已应用分块 diff(桌面 diff 卡的数据源);结果侧权威,
138
+ // 有它就替换掉仅凭 arguments 猜出来的调用态。
139
+ diffs = result.diffs;
140
+ // 对齐桌面 toolRowModel 状态派生:error.code === 'interrupted' → stopped, isError → error, ok
141
+ if (result.isError) {
142
+ state = result.code === 'interrupted' ? 'stopped' : 'error';
143
+ } else {
144
+ state = 'ok';
145
+ }
146
+ }
147
+ }
148
+ out.push({
149
+ type: 'tool-call',
150
+ callId,
151
+ name: typeof b.name === 'string' ? b.name : undefined,
152
+ argsRaw: typeof b.arguments === 'string' ? b.arguments : (b.arguments != null ? JSON.stringify(b.arguments) : undefined),
153
+ ...state ? { state } : {},
154
+ ...typeof output === 'string' && output ? { output } : {},
155
+ ...images && images.length ? { images } : {},
156
+ ...diffs && diffs.length ? { diffs } : {},
157
+ });
158
+ } else {
159
+ // 桌面端对未知 assistant block 使用 JsonBlock 兜底;移动端没有对应组件,
160
+ // 先保留原始内容为可见文本,避免历史重同步后该节点整体消失。
161
+ out.push({ type: 'text', text: `[未支持的内容块]\n${JSON.stringify(b)}` });
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /** 从结构块里拼接纯文本(列表预览/标题回退用,仍只取 text 块)。 */
168
+ function blocksToText(blocks) {
169
+ return blocks.filter((b) => b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n');
170
+ }
171
+
172
+ /** 从 compaction/summary 的 summary 字段提取展示文本(数组=文本块列表,字符串直接取;空→null)。 */
173
+ function compactionSummaryText(summary) {
174
+ if (Array.isArray(summary)) {
175
+ const text = summary.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('');
176
+ return text.trim() === '' ? null : text;
177
+ }
178
+ if (typeof summary === 'string') return summary.trim() === '' ? null : summary;
179
+ return null;
180
+ }
181
+
182
+ /** 从 shadowedSeqs 取被压缩条数(数组长度;缺失→null)。 */
183
+ function shadowedCount(seqs) {
184
+ return Array.isArray(seqs) ? seqs.length : null;
185
+ }
186
+
187
+ /** 取安全非负整数的 token 数(与桌面端同口径:safe integer 且 >= 0,否则 null)。 */
188
+ function shadowedTokens(n) {
189
+ return Number.isSafeInteger(n) && n >= 0 ? n : null;
190
+ }
191
+
192
+ /** retry failure 字段:{code?, message?} 或 null(字段缺失/非字符串则省略)。 */
193
+ function retryFailure(failure) {
194
+ if (!failure || typeof failure !== 'object') return null;
195
+ const out = {};
196
+ if (typeof failure.code === 'string' && failure.code !== '') out.code = failure.code;
197
+ if (typeof failure.message === 'string' && failure.message !== '') out.message = failure.message;
198
+ return out.code !== undefined || out.message !== undefined ? out : null;
199
+ }
200
+
201
+ /** 上下文行的 label 提取(与桌面 contextProvenance 同口径;取不到为 null)。 */
202
+ function contextLabelOf(source) {
203
+ const kind = source.kind;
204
+ if (kind === 'session-reference') {
205
+ for (const ref of Array.isArray(source.references) ? source.references : []) {
206
+ if (ref && typeof ref.label === 'string' && ref.label) return ref.label;
207
+ }
208
+ return null;
209
+ }
210
+ if (kind === 'agent-instructions') {
211
+ for (const change of Array.isArray(source.changes) ? source.changes : []) {
212
+ if (change && typeof change.path === 'string' && change.path) return change.path;
213
+ }
214
+ return null;
215
+ }
216
+ if (kind === 'plugin') return typeof source.plugin === 'string' && source.plugin ? source.plugin : null;
217
+ if (kind === 'skill-invocation') return typeof source.name === 'string' && source.name ? source.name : null;
218
+ return typeof kind === 'string' ? kind : null;
219
+ }
220
+
221
+ /** 列表/概览的 lastMessage 预览只取有 role 的消息项(结构化行不参与预览)。 */
222
+ function lastMessageOf(messages) {
223
+ for (let i = messages.length - 1; i >= 0; i--) {
224
+ if (messages[i] && messages[i].role) return messages[i];
225
+ }
226
+ return null;
227
+ }
228
+
229
+ /**
230
+ * 从 history 事件折叠出消息与「结构化行」(wire v1 扩展,向后兼容):
231
+ * - 普通消息维持现状:{ id, seq, time, role, text, blocks?, images?, clientMessageId? }(user/assistant);
232
+ * - compaction:{ kind:'compaction', id, seq, time, state:'running'|'done', summary?, shadowedItemCount?, shadowedTokenCount? };
233
+ * 压缩检查点 user/message(source.kind==='plugin' && source.plugin==='compact')绝不渲染成用户气泡,
234
+ * 渲染成 compaction 行(id=compactionId,state='done'),并用同 compactionId 的 compaction/summary 补 counts;
235
+ * - retry:{ kind:'retry', id: retryId, seq, time, status:'scheduled'|'started', retry, maxRetries, delayMs?, failure? };
236
+ * - turn-error / max-tokens:{ kind:'turn-error', id:'turn-<turn>', message, code? } / { kind:'max-tokens', id:'turn-<turn>' };
237
+ * - context:{ kind:'context', id:'ctx-<msgId>', contextRole:'inject'|'recall', contextLabel, text };
238
+ * - command:{ kind:'command', id:commandId, name, status:'running'|'done', resultKind?, text? }
239
+ * (command/run 发出行,command/done 原地升级:name 保 run 的,resultKind/text 取 done 的)。
240
+ * 结构化项与消息按事件 seq 升序混排返回;旧载荷字段缺失时跳过对应项,绝不抛错。
241
+ */
242
+ function deriveMessages(events) {
243
+ const out = [];
244
+ // 预扫描 tool/result 事件(同批次事件内,结果可能在调用块之前/之后出现)。
245
+ const toolResults = buildToolResultMap(events);
246
+ // 预扫描 compaction/summary:检查点 user/message 与 summary 事件在日志里的先后不固定,
247
+ // 用 compactionId → summary 数据把 counts 补全到同一压缩行上。
248
+ const summaries = new Map();
249
+ for (const entry of events) {
250
+ const e = entry.event || entry;
251
+ if (e.type !== 'compaction/summary' || !e.data || typeof e.data.compactionId !== 'string') continue;
252
+ summaries.set(e.data.compactionId, e.data);
253
+ }
254
+ // 单步计时预扫描:为每条 assistant 消息派生「本轮」的 ttft / decode / 输出 token
255
+ // (对齐桌面 turn-metrics/assistant-timing:ttftMs = 首个非空 delta − step/start;
256
+ // decodeMs = 落定 − 首个 delta;outputTokens = assistant/message.usage.outputTokens)。
257
+ // 会话累计 stats 属于输入区常驻统计条,绝不能拿来冒充单轮指标(否则每条历史消息
258
+ // 都会显示同一份累计行并随轮询刷新——正是移动端此前「统计行一直出现」的根因)。
259
+ const stepStartTime = new Map();
260
+ const turnStartTime = new Map();
261
+ const turnEndTime = new Map();
262
+ const firstDeltaTime = new Map();
263
+ const stepOutputTokens = new Map();
264
+ const stepKey = (turn, step) => `${turn}:${step}`;
265
+ for (const entry of events) {
266
+ const e = entry.event || entry;
267
+ if (!e || typeof e.data !== 'object' || e.data === null) continue;
268
+ const turn = e.data.turn;
269
+ const step = e.data.step;
270
+ if (typeof turn !== 'number') continue;
271
+ if (e.type === 'turn/start') turnStartTime.set(turn, e.time);
272
+ if (e.type === 'turn/end') turnEndTime.set(turn, e.time);
273
+ if (typeof step !== 'number') continue;
274
+ if (e.type === 'step/start') {
275
+ const k = stepKey(turn, step);
276
+ if (!stepStartTime.has(k)) stepStartTime.set(k, e.time);
277
+ } else if (e.type === 'assistant/chunk' && e.data.chunk) {
278
+ const c = e.data.chunk;
279
+ if (c.type === 'text-delta' || c.type === 'reasoning-delta' || c.type === 'tool-call-delta') {
280
+ const k = stepKey(turn, step);
281
+ if (!firstDeltaTime.has(k)) firstDeltaTime.set(k, e.time);
282
+ }
283
+ } else if (e.type === 'assistant/message' && e.data.usage) {
284
+ const tokens = e.data.usage.outputTokens;
285
+ if (typeof tokens === 'number' && tokens > 0) {
286
+ const k = stepKey(turn, step);
287
+ if (!stepOutputTokens.has(k)) stepOutputTokens.set(k, tokens);
288
+ }
289
+ }
290
+ }
291
+ /** 派生一条 assistant 消息所在 step 的单轮计时;缺记录(非流式落定/无 step 边界)返回 undefined。 */
292
+ const timingOf = (e) => {
293
+ if (!e || typeof e.data !== 'object' || e.data === null) return undefined;
294
+ const turn = e.data.turn;
295
+ const step = e.data.step;
296
+ if (typeof turn !== 'number' || typeof step !== 'number') return undefined;
297
+ const k = stepKey(turn, step);
298
+ const start = stepStartTime.get(k);
299
+ const first = firstDeltaTime.get(k);
300
+ const timing = {};
301
+ const turnStart = turnStartTime.get(turn);
302
+ const turnEnd = turnEndTime.get(turn);
303
+ if (turnStart != null && turnEnd != null && turnEnd >= turnStart) timing.runMs = turnEnd - turnStart;
304
+ if (start == null || first == null) return Object.keys(timing).length > 0 ? timing : undefined;
305
+ const ttftMs = Math.max(0, first - start);
306
+ if (ttftMs > 0) timing.ttftMs = ttftMs;
307
+ const decodeMs = Math.max(0, e.time - first);
308
+ if (decodeMs > 0) timing.decodeMs = decodeMs;
309
+ const outputTokens = stepOutputTokens.get(k);
310
+ if (typeof outputTokens === 'number' && outputTokens > 0) timing.outputTokens = outputTokens;
311
+ return Object.keys(timing).length > 0 ? timing : undefined;
312
+ };
313
+ // 结构化行按 id 唯一:compactionId → 行索引(检查点行原地替换 start 行)、retryId → 行索引(原地升级)。
314
+ const compactionAt = new Map();
315
+ const retryAt = new Map();
316
+ // 命令行按 commandId 唯一:command/run 发出行,command/done 原地升级(保 run 的 name,位置随 done 事件 seq 归位)。
317
+ const commandAt = new Map();
318
+
319
+ // 普通消息(user/assistant)沿用原折叠逻辑。
320
+ const pushMessage = (msg, e) => {
321
+ const blocks = parseBlocks(msg.content, toolResults);
322
+ const text = blocksToText(blocks);
323
+ const images = blocks.filter((b) => b.type === 'image').map(({ mediaType, data, name }) => ({ mediaType, data, name }));
324
+ // 只要存在可识别或保留的 block 就入列表;纯推理/工具调用也必须保留,
325
+ // 否则流式标签在重同步后会消失。
326
+ const hasContent = blocks.length > 0;
327
+ if (!hasContent) return;
328
+ const clientMessageId = msg.role === 'user' ? clientMessageIdFromSource(msg.source, msg) : undefined;
329
+ const item = {
330
+ id: String(msg.id),
331
+ role: msg.role || 'assistant',
332
+ text,
333
+ blocks,
334
+ images: images.length > 0 ? images : undefined,
335
+ ...(clientMessageId ? { clientMessageId } : {}),
336
+ seq: e.seq,
337
+ time: e.time,
338
+ };
339
+ const producedFiles = blocks.filter((block) => block.type === 'tool-call' && (block.name === 'write' || block.name === 'edit') && block.state === 'ok').map((block) => {
340
+ try {
341
+ const args = JSON.parse(block.argsRaw || '{}');
342
+ return typeof args.path === 'string' ? args.path : typeof args.file_path === 'string' ? args.file_path : null;
343
+ } catch { return null; }
344
+ }).filter((path) => typeof path === 'string');
345
+ if (producedFiles.length > 0) item.producedFiles = [...new Set(producedFiles)];
346
+ // 单轮计时只挂 assistant 消息(user/message 无 step 计时)。
347
+ if (item.role !== 'user') {
348
+ const timing = timingOf(e);
349
+ if (timing) item.timing = timing;
350
+ }
351
+ out.push(item);
352
+ };
353
+
354
+ // 放入一行 compaction:同一 compactionId 只保留一行,检查点行原地替换 start 行(最终按 seq 排序归位)。
355
+ const placeCompaction = (item) => {
356
+ const existing = compactionAt.get(item.id);
357
+ if (existing !== undefined) out[existing] = item;
358
+ else {
359
+ compactionAt.set(item.id, out.length);
360
+ out.push(item);
361
+ }
362
+ };
363
+
364
+ for (const entry of events) {
365
+ const e = entry.event || entry;
366
+ const seq = e.seq;
367
+ const time = e.time;
368
+ if (e.type === 'user/message' && e.data) {
369
+ const msg = e.data;
370
+ const src = msg.source;
371
+ if (src && typeof src === 'object' && src.kind === 'plugin' && src.plugin === 'compact') {
372
+ // 压缩检查点本身是一条 user/message,绝不能渲染成用户气泡,渲染成 compaction 行。
373
+ const id = typeof src.compactionId === 'string' && src.compactionId !== ''
374
+ ? src.compactionId
375
+ : (msg.id != null ? String(msg.id) : null);
376
+ if (id === null) continue;
377
+ const s = summaries.get(id);
378
+ placeCompaction({
379
+ id,
380
+ kind: 'compaction',
381
+ state: 'done',
382
+ summary: s ? compactionSummaryText(s.summary) : null,
383
+ shadowedItemCount: s ? shadowedCount(s.shadowedSeqs) : null,
384
+ shadowedTokenCount: s ? shadowedTokens(s.shadowedTokenCount) : null,
385
+ seq,
386
+ time,
387
+ });
388
+ continue;
389
+ }
390
+ if (src && typeof src === 'object' && src.kind !== 'user') {
391
+ // 上下文注入/召回(session-reference→召回,其余 kind→注入);无文本或无可唯一 id 则跳过该项。
392
+ const text = blocksToText(parseBlocks(msg.content));
393
+ if (text === '' || msg.id == null) continue;
394
+ out.push({
395
+ id: `ctx-${String(msg.id)}`,
396
+ kind: 'context',
397
+ contextRole: src.kind === 'session-reference' ? 'recall' : 'inject',
398
+ contextLabel: contextLabelOf(src),
399
+ text,
400
+ seq,
401
+ time,
402
+ });
403
+ continue;
404
+ }
405
+ // 普通用户消息(source 缺失或 kind==='user')沿用原折叠。
406
+ pushMessage(msg, e);
407
+ } else if (e.type === 'assistant/message' && e.data && e.data.message && e.data.message.content) {
408
+ pushMessage(e.data.message, e);
409
+ } else if (e.type === 'compaction/start' && e.data && typeof e.data.compactionId === 'string' && e.data.compactionId !== '') {
410
+ // 行已由检查点发出(done)时跳过,避免重复行。
411
+ if (!compactionAt.has(e.data.compactionId)) {
412
+ placeCompaction({
413
+ id: e.data.compactionId,
414
+ kind: 'compaction',
415
+ state: 'running',
416
+ summary: null,
417
+ shadowedItemCount: null,
418
+ shadowedTokenCount: null,
419
+ seq,
420
+ time,
421
+ });
422
+ }
423
+ } else if (e.type === 'compaction/summary' && e.data && typeof e.data.compactionId === 'string' && e.data.compactionId !== '') {
424
+ // 已有同 compactionId 的行(start 或检查点发出)→ 升级为 done 并补 counts;否则新增 done 行。
425
+ const id = e.data.compactionId;
426
+ const existing = compactionAt.has(id) ? out[compactionAt.get(id)] : null;
427
+ if (existing) {
428
+ existing.state = 'done';
429
+ existing.summary = compactionSummaryText(e.data.summary);
430
+ existing.shadowedItemCount = shadowedCount(e.data.shadowedSeqs);
431
+ existing.shadowedTokenCount = shadowedTokens(e.data.shadowedTokenCount);
432
+ } else {
433
+ placeCompaction({
434
+ id,
435
+ kind: 'compaction',
436
+ state: 'done',
437
+ summary: compactionSummaryText(e.data.summary),
438
+ shadowedItemCount: shadowedCount(e.data.shadowedSeqs),
439
+ shadowedTokenCount: shadowedTokens(e.data.shadowedTokenCount),
440
+ seq,
441
+ time,
442
+ });
443
+ }
444
+ } else if (e.type === 'llm/retry' && e.data && typeof e.data.retryId === 'string' && e.data.retryId !== '' && typeof e.data.retry === 'number') {
445
+ // 同 retryId 的后续重试(retry=2…)原地更新,保持一行。
446
+ const id = e.data.retryId;
447
+ const existing = retryAt.has(id) ? out[retryAt.get(id)] : null;
448
+ if (existing) {
449
+ existing.status = 'scheduled';
450
+ existing.retry = e.data.retry;
451
+ if (typeof e.data.maxRetries === 'number') existing.maxRetries = e.data.maxRetries;
452
+ if (typeof e.data.delayMs === 'number') existing.delayMs = e.data.delayMs;
453
+ existing.failure = retryFailure(e.data.failure);
454
+ } else {
455
+ const item = {
456
+ id,
457
+ kind: 'retry',
458
+ status: 'scheduled',
459
+ retry: e.data.retry,
460
+ maxRetries: typeof e.data.maxRetries === 'number' ? e.data.maxRetries : null,
461
+ failure: retryFailure(e.data.failure),
462
+ seq,
463
+ time,
464
+ };
465
+ if (typeof e.data.delayMs === 'number') item.delayMs = e.data.delayMs;
466
+ retryAt.set(id, out.length);
467
+ out.push(item);
468
+ }
469
+ } else if (e.type === 'llm/retry-started' && e.data && typeof e.data.retryId === 'string' && e.data.retryId !== '' && typeof e.data.retry === 'number') {
470
+ const id = e.data.retryId;
471
+ const existing = retryAt.has(id) ? out[retryAt.get(id)] : null;
472
+ if (existing) existing.status = 'started';
473
+ else {
474
+ retryAt.set(id, out.length);
475
+ out.push({
476
+ id,
477
+ kind: 'retry',
478
+ status: 'started',
479
+ retry: e.data.retry,
480
+ maxRetries: typeof e.data.maxRetries === 'number' ? e.data.maxRetries : null,
481
+ failure: null,
482
+ seq,
483
+ time,
484
+ });
485
+ }
486
+ } else if (e.type === 'command/run' && e.data && typeof e.data.commandId === 'string' && e.data.commandId !== '' && typeof e.data.name === 'string') {
487
+ // 命令行:root 行(running)。同 commandId 重复 run(罕见)只更新位置字段。
488
+ const id = e.data.commandId;
489
+ const item = { id, kind: 'command', name: e.data.name, status: 'running', seq, time };
490
+ const existing = commandAt.has(id) ? out[commandAt.get(id)] : null;
491
+ if (existing) Object.assign(existing, { status: 'running', seq, time });
492
+ else {
493
+ commandAt.set(id, out.length);
494
+ out.push(item);
495
+ }
496
+ } else if (e.type === 'command/done' && e.data && typeof e.data.commandId === 'string' && e.data.commandId !== '') {
497
+ // 升级同 commandId 行(name 保 run 的);done 先到(罕见)则按 done 内容建行。
498
+ const id = e.data.commandId;
499
+ const done = { kind: 'command', status: 'done', seq, time };
500
+ if (e.data.kind === 'error' || e.data.kind === 'success') done.resultKind = e.data.kind;
501
+ if (typeof e.data.text === 'string' && e.data.text !== '') done.text = e.data.text;
502
+ const existing = commandAt.has(id) ? out[commandAt.get(id)] : null;
503
+ if (existing) Object.assign(existing, done);
504
+ else {
505
+ commandAt.set(id, out.length);
506
+ out.push(done);
507
+ }
508
+ } else if (e.type === 'turn/end' && e.data && e.data.reason && e.data.turn != null) {
509
+ // turn/end 每轮一次,一条消息只对应一条行:error → turn-error;max-tokens → max-tokens;其余 kind 忽略。
510
+ if (e.data.reason.kind === 'error') {
511
+ const err = e.data.reason.error;
512
+ if (!err || typeof err.message !== 'string') continue;
513
+ const item = { id: `turn-${e.data.turn}`, kind: 'turn-error', message: err.message, seq, time };
514
+ if (typeof err.code === 'string' && err.code !== '') item.code = err.code;
515
+ out.push(item);
516
+ } else if (e.data.reason.kind === 'max-tokens') {
517
+ out.push({ id: `turn-${e.data.turn}`, kind: 'max-tokens', seq, time });
518
+ }
519
+ }
520
+ // tool/result、compaction/end 等其余类型照旧忽略(compaction 行已由 start 发出)。
521
+ }
522
+ // 严格按事件 seq 升序(与消息混排;compaction 行升级/替换后位置以最终事件为准)。
523
+ out.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
524
+ return out;
525
+ }
526
+
527
+ // 信封拆解的唯一实现在 lib/mobile/envelope.js。这里只是为了让既有
528
+ // `require('./history-derive.js').unwrap` 继续可用(对外导出面不变)。
529
+ const { unwrap } = require('./envelope.js');
530
+
531
+ const MODEL_UNAVAILABLE_ERROR = {
532
+ code: 'model-unavailable',
533
+ message: '当前没有可用的模型,请先在模型选择器中选择一个可用模型',
534
+ };
535
+
536
+ /**
537
+ * 宿主错误的 details 原样透传。移动端据此把 attachment-error 映射成中文提示,
538
+ * 读的是与桌面端 attachmentErrorText 同一个 details.reason 契约
539
+ * (MODEL_DOES_NOT_SUPPORT_IMAGES / TOO_MANY_IMAGES / IMAGE_TOO_LARGE …)。
540
+ * 只接受纯对象:Error 实例、数组一律丢弃,避免污染 JSON 响应。
541
+ */
542
+ function errorDetails(error) {
543
+ const details = error && typeof error === 'object' ? error.details : undefined;
544
+ if (!details || typeof details !== 'object' || Array.isArray(details)) return undefined;
545
+ const proto = Object.getPrototypeOf(details);
546
+ if (proto !== Object.prototype && proto !== null) return undefined;
547
+ return details;
548
+ }
549
+
550
+ function promptErrorObject(error) {
551
+ if (error && typeof error === 'object') {
552
+ const code = typeof error.code === 'string' ? error.code : '';
553
+ const message = typeof error.message === 'string' ? error.message : '';
554
+ const text = `${code} ${message}`.toLowerCase();
555
+ if (
556
+ ['no-model', 'model-unavailable', 'model-not-found', 'model-not-routable', 'no-provider', 'provider-unavailable', 'not-routable'].includes(code.toLowerCase())
557
+ || /no (?:usable |available |selected |configured )?model|model[^\n]*(?:unavailable|not found|not configured|not routable|missing|required)|no provider|provider[^\n]*(?:unavailable|not found|not configured|missing|required)|没有(?:可用的)?模型|模型[^\n]*(?:不可用|未配置|不存在|缺失|必需)|提供商[^\n]*(?:不可用|未配置|缺失)/i.test(text)
558
+ ) return { ...MODEL_UNAVAILABLE_ERROR };
559
+ if (code || message) {
560
+ const details = errorDetails(error);
561
+ return {
562
+ ...(code ? { code } : { code: 'prompt-failed' }),
563
+ message: message || '消息发送失败',
564
+ ...(details ? { details } : {}),
565
+ };
566
+ }
567
+ }
568
+ const message = error instanceof Error ? error.message : String(error || '');
569
+ const text = message.toLowerCase();
570
+ if (/no (?:usable |available |selected |configured )?model|no provider|没有(?:可用的)?模型|模型[^\n]*(?:不可用|未配置|不存在|缺失)/i.test(text)) return { ...MODEL_UNAVAILABLE_ERROR };
571
+ return { code: 'prompt-failed', message: message || '消息发送失败' };
572
+ }
573
+
574
+ /**
575
+ * 移动端提示词用的 rpcId 前缀。宿主把 source.rpcId 原样回带在 user 消息上,
576
+ * 移动端据此把乐观行和历史里的真消息对上号。
577
+ */
578
+ const MOBILE_PROMPT_RPC_PREFIX = 'mobile:';
579
+
580
+ /**
581
+ * 从 user 消息里取回移动端的 clientMessageId。优先用显式字段;老宿主只把它塞在
582
+ * source.rpcId 里,两条路都认,历史重同步才不会把乐观行变成重复行。
583
+ */
584
+ function clientMessageIdFromSource(source, message) {
585
+ if (message && typeof message.clientMessageId === 'string' && message.clientMessageId) return message.clientMessageId;
586
+ if (!source || typeof source !== 'object' || source.kind !== 'user' || typeof source.rpcId !== 'string') return undefined;
587
+ return source.rpcId.startsWith(MOBILE_PROMPT_RPC_PREFIX)
588
+ ? source.rpcId.slice(MOBILE_PROMPT_RPC_PREFIX.length) || undefined
589
+ : undefined;
590
+ }
591
+
592
+ module.exports = {
593
+ listTitle,
594
+ deriveMessages,
595
+ unwrap,
596
+ errorDetails,
597
+ promptErrorObject,
598
+ MODEL_UNAVAILABLE_ERROR,
599
+ capDiffText,
600
+ normalizeResultDiffs,
601
+ buildToolResultMap,
602
+ parseBlocks,
603
+ blocksToText,
604
+ contextLabelOf,
605
+ lastMessageOf,
606
+ MOBILE_PROMPT_RPC_PREFIX,
607
+ clientMessageIdFromSource,
608
+ };