dsh-mobile-plugin 0.4.9 → 0.5.0

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/icon.png ADDED
Binary file
@@ -12,6 +12,11 @@
12
12
  */
13
13
 
14
14
  const { cutAssistantBlock, cutToolResultEvent, createToolNameIndex } = require('./wire-cut.js');
15
+ const {
16
+ reasoningStepSummary,
17
+ toolStepSummary,
18
+ toolStepState,
19
+ } = require('./step-index.js');
15
20
 
16
21
  /**
17
22
  * 从 session.list 摘要行取会话标题(与桌面端侧栏同源,不再从 history 窗口猜):
@@ -117,7 +122,14 @@ function buildToolResultMap(events) {
117
122
  return '';
118
123
  }).filter(Boolean).join('');
119
124
  const images = toolResultImages(parts);
120
- map.set(src.callId, { isError, code, ...(output ? { output } : {}), ...(images.length ? { images } : {}) });
125
+ // `seq` 只给「按需取详情」用:手机点开这一步时拿它去回拉那一小窗事件。
126
+ map.set(src.callId, {
127
+ isError,
128
+ code,
129
+ ...(Number.isSafeInteger(e.seq) ? { seq: e.seq } : {}),
130
+ ...(output ? { output } : {}),
131
+ ...(images.length ? { images } : {}),
132
+ });
121
133
  }
122
134
  return map;
123
135
  }
@@ -428,8 +440,59 @@ function deriveMessages(events) {
428
440
  // 命令行按 commandId 唯一:command/run 发出行,command/done 原地升级(保 run 的 name,位置随 done 事件 seq 归位)。
429
441
  const commandAt = new Map();
430
442
 
443
+ /**
444
+ * 每一轮的「过程清单」:一步一行摘要,正文仍然一个字节都不下发。
445
+ *
446
+ * 清单挂在**这一轮开头那条消息**上(App 的头条行就是它派生的),收口后点开清单再按
447
+ * `seq` / `resultSeq` 去 `/session/step` 取那一步的详情 —— 这就是「按需获取」的那一半。
448
+ * 只做摘要不做正文:一条推理正文平均几万字、一次 `write` 的参数就是整份文件,把它们
449
+ * 塞进消息响应里正是 2026-10-27 砍掉它的理由。
450
+ */
451
+ const stepsByTurn = new Map();
452
+ const openerByTurn = new Map();
453
+ let lastOpener = null;
454
+ const collectSteps = (content, e) => {
455
+ if (!Array.isArray(content)) return;
456
+ const turn = e.data && typeof e.data.turn === 'number' ? e.data.turn : undefined;
457
+ if (turn === undefined) return;
458
+ if (!openerByTurn.has(turn) && lastOpener) openerByTurn.set(turn, lastOpener);
459
+ const list = stepsByTurn.get(turn) || [];
460
+ content.forEach((raw, index) => {
461
+ if (!raw || typeof raw !== 'object') return;
462
+ if (raw.type === 'reasoning') {
463
+ const text = typeof raw.text === 'string' ? raw.text : '';
464
+ // 空推理块(只有零宽字符那种)不算一步:App 点开一条什么都没有的行没有意义。
465
+ if (text.trim() === '') return;
466
+ list.push({
467
+ kind: 'think',
468
+ ...(Number.isSafeInteger(e.seq) ? { seq: e.seq } : {}),
469
+ index,
470
+ summary: reasoningStepSummary(text),
471
+ });
472
+ return;
473
+ }
474
+ if (raw.type !== 'tool-call') return;
475
+ const callId = raw.id != null ? String(raw.id) : undefined;
476
+ const result = callId ? toolResults.get(callId) : undefined;
477
+ const rawArgs = typeof raw.arguments === 'string'
478
+ ? raw.arguments
479
+ : (raw.arguments != null ? JSON.stringify(raw.arguments) : '');
480
+ list.push({
481
+ kind: 'tool',
482
+ ...(callId ? { callId } : {}),
483
+ ...(typeof raw.name === 'string' && raw.name ? { name: raw.name } : {}),
484
+ summary: toolStepSummary(raw.name, rawArgs),
485
+ state: toolStepState(result),
486
+ ...(Number.isSafeInteger(e.seq) ? { seq: e.seq } : {}),
487
+ ...(result && Number.isSafeInteger(result.seq) ? { resultSeq: result.seq } : {}),
488
+ });
489
+ });
490
+ if (list.length > 0) stepsByTurn.set(turn, list);
491
+ };
492
+
431
493
  // 普通消息(user/assistant)沿用原折叠逻辑。
432
494
  const pushMessage = (msg, e) => {
495
+ collectSteps(msg.content, e);
433
496
  const parsed = parseBlocks(msg.content, toolResults);
434
497
  // 工具产出的图挂成这条消息的图片附件:App 把它画在气泡上方(缩略图 + 文件名,点开全屏),
435
498
  // 与用户自己发的图同一种形状。工具卡里不再重复画同一张图 —— 一张图两处画会把字节也翻倍。
@@ -501,6 +564,8 @@ function deriveMessages(events) {
501
564
  if (timing) item.timing = timing;
502
565
  }
503
566
  out.push(item);
567
+ // 这一轮的过程清单挂在**开启这一轮的那条消息**上:App 的头条行按它派生。
568
+ if (item.role === 'user') lastOpener = item;
504
569
  };
505
570
 
506
571
  // 放入一行 compaction:同一 compactionId 只保留一行,检查点行原地替换 start 行(最终按 seq 排序归位)。
@@ -747,6 +812,19 @@ function deriveMessages(events) {
747
812
  }
748
813
  // tool/result 等其余类型照旧忽略(工具结果在预扫描里配对回填;compaction 行由 start/end 发出)。
749
814
  }
815
+ // 过程清单按轮回填:`steps` 只带摘要与位置,正文要用户点开某一步时再去取。
816
+ if (stepsByTurn.size > 0) {
817
+ for (const [turn, steps] of stepsByTurn) {
818
+ const opener = openerByTurn.get(turn);
819
+ if (!opener || steps.length === 0) continue;
820
+ opener.turnProcess = {
821
+ ...(opener.turnProcess && typeof opener.turnProcess === 'object' ? opener.turnProcess : {}),
822
+ count: steps.length,
823
+ steps,
824
+ ...(Number.isSafeInteger(opener.seq) ? { startSeq: opener.seq } : {}),
825
+ };
826
+ }
827
+ }
750
828
  // 产出清单按轮回填:这一轮所有 write / edit 的并集,挂到这一轮每条 assistant 消息上。
751
829
  // 挂「整轮」而不是「从写那一刻起」,与桌面 `selectProducedFiles(owner)` 同口径;正文里
752
830
  // 提到这一轮写的文件就能解析成可点(App 侧按文件名或整条路径唯一命中才画,见
@@ -869,13 +947,19 @@ function thinEntryMessages(items, options = {}) {
869
947
  const endSeq = Number.isSafeInteger(last.seq) ? last.seq : opener.seq;
870
948
  const at = kept.indexOf(opener);
871
949
  if (at >= 0) {
950
+ // 清单(`steps`)随入口窗口一起下发:过程正文不在线上,这一行摘要就是用户
951
+ // 「值不值得点开」的全部依据,摘掉它这一轮就没法点开了。
952
+ const steps = opener.turnProcess && Array.isArray(opener.turnProcess.steps)
953
+ ? opener.turnProcess.steps
954
+ : null;
872
955
  kept[at] = {
873
956
  ...kept[at],
874
957
  turnProcess: {
875
958
  startSeq: opener.seq,
876
959
  endSeq,
877
- count: processCount,
960
+ count: steps ? steps.length : processCount,
878
961
  ...(typeof last.time === 'number' ? { endTime: last.time } : {}),
962
+ ...(steps ? { steps } : {}),
879
963
  },
880
964
  };
881
965
  }
@@ -16,6 +16,9 @@ const { unwrap } = require('./sessions.js');
16
16
 
17
17
  const HOST_SETTINGS_MAX_NAMESPACES = 60; // 命名空间数量上限(避免异常宿主把响应撑爆)
18
18
  const HOST_SETTINGS_MAX_MODELS_PER_GROUP = 200; // 单个提供方下的模型条数上限
19
+ const HOST_SETTINGS_MAX_FIELDS = 40; // 单个命名空间透出的字段元数据条数上限
20
+ const HOST_SETTINGS_MAX_CHOICES = 40; // 单个字段的候选值上限
21
+ const HOST_SETTINGS_MAX_EFFORTS = 8; // 单个模型的推理强度档位上限
19
22
  const HOST_SETTINGS_VALUE_DEPTH = 4; // 值递归深度上限
20
23
  const HOST_SETTINGS_VALUE_KEYS = 40; // 每层键/元素数量上限
21
24
  const HOST_SETTINGS_VALUE_CHARS = 4000; // 单个字符串长度上限
@@ -51,6 +54,21 @@ function boundValue(value, depth = 0) {
51
54
  return null;
52
55
  }
53
56
 
57
+ /**
58
+ * 取出序列化 zod 树的根字段表。
59
+ *
60
+ * 宿主 `settings.describe` 里的 schema 是 schemastery 的 `{ uid, refs }` 形状:refs 以数字 id
61
+ * 存放每个节点,根节点的 `dict` 是「字段名 → 子节点 id」。凭据引用与字段控件都要先摸到这张表。
62
+ */
63
+ function schemaDict(schema) {
64
+ const refs = schema && typeof schema === 'object' && !Array.isArray(schema) ? schema.refs : null;
65
+ if (!refs || typeof refs !== 'object' || Array.isArray(refs)) return null;
66
+ const root = refs[String(schema.uid)];
67
+ const dict = root && typeof root === 'object' ? root.dict : null;
68
+ if (!dict || typeof dict !== 'object' || Array.isArray(dict)) return null;
69
+ return { refs, dict };
70
+ }
71
+
54
72
  /**
55
73
  * 从序列化的 zod 树里读出「这个命名空间的密钥写到哪个凭据引用上」。
56
74
  *
@@ -62,11 +80,9 @@ function boundValue(value, depth = 0) {
62
80
  * 没有密钥槽的设置项画密码框。
63
81
  */
64
82
  function credentialRefOf(schema, value) {
65
- const refs = schema && typeof schema === 'object' && !Array.isArray(schema) ? schema.refs : null;
66
- if (!refs || typeof refs !== 'object' || Array.isArray(refs)) return null;
67
- const root = refs[String(schema.uid)];
68
- const dict = root && typeof root === 'object' ? root.dict : null;
69
- if (!dict || typeof dict !== 'object' || Array.isArray(dict)) return null;
83
+ const shape = schemaDict(schema);
84
+ if (!shape) return null;
85
+ const { refs, dict } = shape;
70
86
 
71
87
  let refField = '';
72
88
  let refDefault = '';
@@ -90,6 +106,65 @@ function credentialRefOf(schema, value) {
90
106
  return ref ? { ref, refField } : null;
91
107
  }
92
108
 
109
+ /**
110
+ * 一个字段该画什么控件,按宿主 schema 的形状定。
111
+ *
112
+ * 手机端把设置项直接铺成卡片,靠这张表决定控件,不再一律给文本框:
113
+ * - `boolean` → 开关
114
+ * - `union` 且每一支都是常量(`z.union(['standard', 'compact'])`)→ 点选,候选就是那些常量
115
+ * - `number` → 数字输入(带宿主的 min / max / step)
116
+ * - `string` → 文本框
117
+ *
118
+ * 其它形状(嵌套对象 / 数组 / transform)不出现:那种字段在手机上本来就不给控件。只描述
119
+ * 根那一层的字段,不透整棵树——宿主 schema 里对手机没用的东西仍然留在插件这一侧。
120
+ */
121
+ function describeField(node, refs) {
122
+ const meta = node && typeof node === 'object' && node.meta && typeof node.meta === 'object' ? node.meta : {};
123
+ let type = '';
124
+ const out = {};
125
+ if (node && node.type === 'boolean') type = 'boolean';
126
+ else if (node && node.type === 'string') type = 'string';
127
+ else if (node && node.type === 'number') {
128
+ type = 'number';
129
+ if (typeof meta.min === 'number' && Number.isFinite(meta.min)) out.min = meta.min;
130
+ if (typeof meta.max === 'number' && Number.isFinite(meta.max)) out.max = meta.max;
131
+ if (typeof meta.step === 'number' && Number.isFinite(meta.step)) out.step = meta.step;
132
+ } else if (node && node.type === 'const') {
133
+ type = 'enum';
134
+ out.options = [node.value];
135
+ } else if (node && node.type === 'union') {
136
+ // 只有「每一支都是常量」才算候选集合;带 transform / 对象的联合没法枚举。
137
+ const options = [];
138
+ const list = Array.isArray(node.list) ? node.list : [];
139
+ let allConst = list.length > 0;
140
+ for (const uid of list) {
141
+ const child = refs[String(uid)];
142
+ if (child && child.type === 'const') options.push(child.value);
143
+ else { allConst = false; break; }
144
+ }
145
+ if (!allConst) return null;
146
+ type = 'enum';
147
+ out.options = options.slice(0, HOST_SETTINGS_MAX_CHOICES);
148
+ } else return null;
149
+ if (type === 'enum' && (!Array.isArray(out.options) || out.options.length < 2)) return null;
150
+ if (meta.required === true) out.required = true;
151
+ out.type = type;
152
+ return out;
153
+ }
154
+
155
+ /** 一个命名空间里各字段的控件形状;推不出来(老宿主 / 畸形 schema)就是 null。 */
156
+ function fieldsOf(schema) {
157
+ const shape = schemaDict(schema);
158
+ if (!shape) return null;
159
+ const fields = [];
160
+ for (const key of Object.keys(shape.dict).slice(0, HOST_SETTINGS_MAX_FIELDS)) {
161
+ const node = shape.refs[String(shape.dict[key])];
162
+ const field = describeField(node, shape.refs);
163
+ if (field) fields.push({ key, ...field });
164
+ }
165
+ return fields.length > 0 ? fields : null;
166
+ }
167
+
93
168
  /**
94
169
  * 把 settings.describe 的一条命名空间视图压成手机需要的形状。
95
170
  *
@@ -124,6 +199,10 @@ function normalizeNamespace(item) {
124
199
  };
125
200
  if (item.base !== undefined) out.base = boundValue(item.base);
126
201
  if (item.user !== undefined) out.user = boundValue(item.user);
202
+ // 每个字段该画什么控件(开关 / 点选 / 数字 / 文本框):只描述根这一层,宿主整棵 schema 的
203
+ // 其余部分仍然留在插件这一侧,不透给手机。推不出来就不给,App 回退成文本框。
204
+ const fields = fieldsOf(item.schema);
205
+ if (fields) out.fields = fields;
127
206
  // 只有「宿主确实报了密钥槽」且「schema 里能推导出凭据引用」时才给出引用名;
128
207
  // 两者缺一,App 就不该显示写密钥的入口。
129
208
  if (secrets.length > 0) {
@@ -181,6 +260,21 @@ async function handleLlmProviders(ctx, req, res) {
181
260
  sendJson(res, 200, { ok: true, providers });
182
261
  }
183
262
 
263
+ /**
264
+ * 一个模型支持的推理强度档位。
265
+ *
266
+ * 宿主给的是一张「id → 显示名」的表(如 `{ low: 'low', high: 'high', max: 'max' }`),偶尔是
267
+ * 数组。手机只要档位 id:设置里写的是 id,显示名归 App 的字典。没有就返回空数组。
268
+ */
269
+ function normalizeReasoningEfforts(value) {
270
+ const ids = Array.isArray(value)
271
+ ? value
272
+ : (value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value) : []);
273
+ return ids
274
+ .filter((id) => typeof id === 'string' && id !== '')
275
+ .slice(0, HOST_SETTINGS_MAX_EFFORTS);
276
+ }
277
+
184
278
  /** 把 llm.models 的一组渠道压成手机选择器要的形状;没有模型的组丢掉。 */
185
279
  function normalizeModelGroup(group) {
186
280
  if (!group || typeof group !== 'object') return null;
@@ -193,7 +287,10 @@ function normalizeModelGroup(group) {
193
287
  const id = typeof item.id === 'string' ? item.id.trim() : '';
194
288
  if (!id) return null;
195
289
  const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : id;
196
- return { id, name };
290
+ // 推理强度档位给设置页里的「推理强度」用:那个字段在宿主 schema 里只是普通字符串,
291
+ // 唯一知道有哪些档位的地方就是模型目录这一份。
292
+ const reasoningEfforts = normalizeReasoningEfforts(item.reasoningEfforts);
293
+ return { id, name, ...(reasoningEfforts.length > 0 ? { reasoningEfforts } : {}) };
197
294
  })
198
295
  .filter(Boolean)
199
296
  .slice(0, HOST_SETTINGS_MAX_MODELS_PER_GROUP);
@@ -0,0 +1,356 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 直播时那一轮的过程副本:**只在内存里**,收下一轮就换掉。
5
+ *
6
+ * 为什么需要它:录制里的推理正文与工具参数在落盘之前只存在于直播流里,宿主的
7
+ * `session.history` 取不到跑到一半的那一步 —— 用户点开「正在读取文件」这一行时,
8
+ * 详情接口只能从这份副本里答。落定以后按 `seq` 回拉历史,这份副本就不必再管。
9
+ *
10
+ * 它同时是**跑着那一轮的过程清单**:手机上那条「过程 · 已跑 3 步」要每一行一步的摘要,
11
+ * 而推理块整块不上线路、工具参数也不上(见 `wire-cut.js`),所以摘要与状态只能在这里
12
+ * 算好再按步发出去(`drainLiveSteps` → `session/live-steps` 帧)。**摘要由电脑算**
13
+ * 这条不变:手机上根本没有参数可拼。
14
+ *
15
+ * 容量口径:只留**当前这一轮**(`turn/start` 覆盖),整个进程里最多留几条会话。它复制
16
+ * 的本就是宿主此刻正在攒的东西,所以量级与「这一轮有多长」同一档,收口后不再增长。
17
+ */
18
+
19
+ /** 进程里同时留几条会话的直播副本(多开的会话不会一起跑,留几条够来回切)。 */
20
+ const MAX_SESSIONS = 4;
21
+
22
+ /** 单段文本的上限:详情是给人看的,超长输出照旧截断,别把一份 20MB 的结果塞回手机。 */
23
+ const TEXT_MAX = 200_000;
24
+
25
+ const { liveStepTurnOf } = require('./session-cache.js');
26
+ const { reasoningStepSummary, toolStepSummary } = require('./step-index.js');
27
+
28
+ function bucketOf(ctx) {
29
+ return liveStepTurnOf(ctx);
30
+ }
31
+
32
+ function turnOf(ctx, sessionId) {
33
+ const bucket = bucketOf(ctx);
34
+ let turn = bucket.get(sessionId);
35
+ if (!turn) {
36
+ turn = {
37
+ tools: new Map(),
38
+ /** 推理正文:键是**这一轮里的第几段思考**(不是内容块序号,块序号每步从 0 重数)。 */
39
+ thoughts: new Map(),
40
+ /** 内容块序号 → 那一段思考的编号(只在当前这一步内有效,落定即清)。 */
41
+ thoughtOfBlock: new Map(),
42
+ nextThought: 0,
43
+ callIdByIndex: new Map(),
44
+ activeCallId: '',
45
+ /** 这一轮跑过的步(按发生顺序),一步一条,`key` 是它在 App 侧的稳定身份。 */
46
+ steps: [],
47
+ stepByKey: new Map(),
48
+ /** 还没发出去的那几步(`drainLiveSteps` 取走)。 */
49
+ pending: [],
50
+ ended: false,
51
+ };
52
+ bucket.delete(sessionId);
53
+ bucket.set(sessionId, turn);
54
+ // 只留几条会话(Map 按插入顺序,最老的先丢)。
55
+ while (bucket.size > MAX_SESSIONS) bucket.delete(bucket.keys().next().value);
56
+ }
57
+ return turn;
58
+ }
59
+
60
+ /** 把一段文本接到已有的那段后面,超过上限就只留尾巴。 */
61
+ function appendText(prev, next) {
62
+ if (typeof next !== 'string' || next === '') return prev || '';
63
+ const joined = (prev || '') + next;
64
+ return joined.length > TEXT_MAX ? joined.slice(joined.length - TEXT_MAX) : joined;
65
+ }
66
+
67
+ /**
68
+ * 记一步:没有就追加到清单末尾,有就改它(同一处只占一条)。
69
+ *
70
+ * 只有**真的变了**的那个字段才进待发清单 —— 工具身份帧会来好几发,每发都推送一遍等于
71
+ * 把同一行反复发给你。
72
+ */
73
+ function putStep(turn, key, patch) {
74
+ let entry = turn.stepByKey.get(key);
75
+ if (!entry) {
76
+ entry = { key, step: {} };
77
+ turn.stepByKey.set(key, entry);
78
+ turn.steps.push(entry);
79
+ Object.assign(entry.step, patch);
80
+ turn.pending.push({ key, step: { ...entry.step } });
81
+ return entry;
82
+ }
83
+ const before = entry.step;
84
+ const changed = Object.keys(patch).some((field) => patch[field] !== before[field]);
85
+ if (changed) {
86
+ Object.assign(entry.step, patch);
87
+ turn.pending.push({ key, step: { ...entry.step } });
88
+ }
89
+ return entry;
90
+ }
91
+
92
+ function toolKey(callId) {
93
+ return `tool:${callId}`;
94
+ }
95
+
96
+ function thinkKey(ordinal) {
97
+ return `think:${ordinal}`;
98
+ }
99
+
100
+ /**
101
+ * 工具步的摘要与状态:摘要参数齐了才拼(读到哪算哪的一行「读文件」等于没写)。
102
+ *
103
+ * `error` 是**三态**:没有这一格 = 结果还没来(还在跑);`null` = 结果到了、成功了;
104
+ * `'error'` / `'stopped'` = 结果到了、失败了。别把 `null` 当成「还在跑」。
105
+ */
106
+ function refreshToolStep(turn, callId) {
107
+ const tool = turn.tools.get(callId);
108
+ if (!tool) return;
109
+ const summary = toolStepSummary(tool.name, tool.argsRaw);
110
+ const state = tool.error === undefined
111
+ ? 'running'
112
+ : (tool.error === null ? 'ok' : (tool.error === 'stopped' ? 'stopped' : 'error'));
113
+ putStep(turn, toolKey(callId), {
114
+ kind: 'tool',
115
+ callId,
116
+ ...(tool.name ? { name: tool.name } : {}),
117
+ summary,
118
+ state,
119
+ });
120
+ }
121
+
122
+ /** 一段思考落定:摘要补上,状态收成成功。 */
123
+ function settleThought(turn, ordinal) {
124
+ const text = turn.thoughts.get(ordinal);
125
+ if (typeof text !== 'string' || text === '') {
126
+ putStep(turn, thinkKey(ordinal), { state: 'ok' });
127
+ return;
128
+ }
129
+ putStep(turn, thinkKey(ordinal), { summary: reasoningStepSummary(text), state: 'ok' });
130
+ }
131
+
132
+ /** 这一步的所有思考都收尾(一步的块序号只在这一步内有效,落定即清)。 */
133
+ function closeThoughtsOfStep(turn) {
134
+ for (const ordinal of turn.thoughtOfBlock.values()) settleThought(turn, ordinal);
135
+ turn.thoughtOfBlock.clear();
136
+ }
137
+
138
+ /** 工具结果两种形状都认(与 history-derive 的 toolResultParts 同一口径)。 */
139
+ function resultParts(message) {
140
+ const direct = Array.isArray(message.content) ? message.content : [];
141
+ const envelope = direct.length === 1 && direct[0] && typeof direct[0] === 'object'
142
+ && typeof direct[0].type !== 'string' ? direct[0] : null;
143
+ if (!envelope) return { parts: direct, isError: message.isError === true };
144
+ return {
145
+ parts: Array.isArray(envelope.content) ? envelope.content : [],
146
+ isError: envelope.isError === true || message.isError === true,
147
+ };
148
+ }
149
+
150
+ /**
151
+ * 收一帧直播事件(在 `wire-cut` 之前看原始内容,所以这里什么都拿得到)。
152
+ *
153
+ * 只有这几类事件有料:工具参数增量、推理增量、块边界、助手消息、工具结果、回合结束。
154
+ * 其余一概忽略。
155
+ */
156
+ function noteLiveEvent(ctx, sessionId, event) {
157
+ if (!ctx || typeof sessionId !== 'string' || !sessionId) return;
158
+ if (!event || typeof event !== 'object') return;
159
+ const data = event.data && typeof event.data === 'object' ? event.data : null;
160
+ if (event.type === 'turn/start') {
161
+ // 新的一轮开始:上一轮的过程副本作废(换掉了,就别再让人点开旧内容)。
162
+ bucketOf(ctx).delete(sessionId);
163
+ turnOf(ctx, sessionId);
164
+ return;
165
+ }
166
+ if (!data) return;
167
+ const turn = turnOf(ctx, sessionId);
168
+ if (event.type === 'assistant/message') {
169
+ // 一步落定:这一步的思考按块序号收尾(后面那一步的块序号从 0 重新数)。
170
+ closeThoughtsOfStep(turn);
171
+ // 块序号 → 调用 的对照也随之作废:下一步的 0 号块是另一个块,留着它会让一条正文
172
+ // 的块边界把上一步的工具重新标成「运行中」。
173
+ turn.callIdByIndex.clear();
174
+ turn.activeCallId = '';
175
+ const message = data.message;
176
+ const content = message && Array.isArray(message.content) ? message.content : [];
177
+ for (const block of content) {
178
+ if (!block || block.type !== 'tool-call' || block.id == null) continue;
179
+ const callId = String(block.id);
180
+ const current = turn.tools.get(callId) || { name: '', argsRaw: '' };
181
+ // 参数到这里是齐的 —— 摘要该按它拼出来了。身份帧可能在断线 / 合并里丢过,
182
+ // 所以这里也顺手把这一步补进清单(有就只更新)。
183
+ turn.tools.set(callId, {
184
+ name: typeof block.name === 'string' && block.name ? block.name : current.name,
185
+ argsRaw: typeof block.arguments === 'string'
186
+ ? block.arguments
187
+ : (block.arguments != null ? JSON.stringify(block.arguments) : current.argsRaw),
188
+ });
189
+ refreshToolStep(turn, callId);
190
+ }
191
+ return;
192
+ }
193
+ if (event.type === 'assistant/chunk') {
194
+ const chunk = data.chunk;
195
+ if (!chunk || typeof chunk !== 'object') return;
196
+ if (chunk.type === 'tool-call-delta') {
197
+ // 宿主只在**第一帧**带 `id` / `name`,后面那些帧只剩 `index` 与 `argumentsDelta`
198
+ // (见 wire-cut.js 的同一条注释)。所以按块序号记住「这个 index 是哪个调用」,
199
+ // 没有 id 的帧接到上一次那个调用上;一步里并行两个调用时按 index 各归各的。
200
+ const index = Number.isSafeInteger(chunk.index) ? chunk.index : null;
201
+ let callId = chunk.id != null ? String(chunk.id) : '';
202
+ if (callId) {
203
+ turn.activeCallId = callId;
204
+ if (index !== null) turn.callIdByIndex.set(index, callId);
205
+ } else if (index !== null && turn.callIdByIndex.has(index)) {
206
+ callId = turn.callIdByIndex.get(index);
207
+ } else if (turn.activeCallId) {
208
+ callId = turn.activeCallId;
209
+ }
210
+ if (!callId) return;
211
+ const current = turn.tools.get(callId) || { name: '', argsRaw: '' };
212
+ turn.tools.set(callId, {
213
+ name: typeof chunk.name === 'string' && chunk.name ? chunk.name : current.name,
214
+ argsRaw: appendText(current.argsRaw, chunk.argumentsDelta),
215
+ ...(current.output !== undefined ? { output: current.output } : {}),
216
+ ...(current.diffs !== undefined ? { diffs: current.diffs } : {}),
217
+ ...(current.error !== undefined ? { error: current.error } : {}),
218
+ });
219
+ // 参数还在流:这一步照实说「运行中」,摘要等参数齐了再补。
220
+ const tool = turn.tools.get(callId);
221
+ putStep(turn, toolKey(callId), {
222
+ kind: 'tool',
223
+ callId,
224
+ ...(tool.name ? { name: tool.name } : {}),
225
+ summary: '',
226
+ state: 'running',
227
+ });
228
+ return;
229
+ }
230
+ if (chunk.type === 'reasoning-delta') {
231
+ if (!Number.isSafeInteger(chunk.index)) return;
232
+ let ordinal = turn.thoughtOfBlock.get(chunk.index);
233
+ if (ordinal === undefined) {
234
+ ordinal = turn.nextThought;
235
+ turn.nextThought += 1;
236
+ turn.thoughtOfBlock.set(chunk.index, ordinal);
237
+ turn.thoughts.set(ordinal, '');
238
+ // **思考也算一步**:从第一段推理就开始算,不必等到它调工具。
239
+ putStep(turn, thinkKey(ordinal), { kind: 'think', index: ordinal, summary: '', state: 'running' });
240
+ }
241
+ turn.thoughts.set(ordinal, appendText(turn.thoughts.get(ordinal), chunk.text));
242
+ return;
243
+ }
244
+ if (chunk.type === 'block-end') {
245
+ const index = Number.isSafeInteger(chunk.index) ? chunk.index : null;
246
+ if (index === null) return;
247
+ const callId = turn.callIdByIndex.get(index);
248
+ if (callId) {
249
+ // 参数流完了(接下来才是执行):这一刻的摘要就是用户要看的那个路径 / 命令。
250
+ refreshToolStep(turn, callId);
251
+ return;
252
+ }
253
+ const ordinal = turn.thoughtOfBlock.get(index);
254
+ if (ordinal !== undefined) {
255
+ settleThought(turn, ordinal);
256
+ turn.thoughtOfBlock.delete(index);
257
+ }
258
+ }
259
+ return;
260
+ }
261
+ if (event.type === 'tool/result') {
262
+ const message = data.message;
263
+ const src = message && message.source;
264
+ if (!src || typeof src.callId !== 'string') return;
265
+ const current = turn.tools.get(src.callId) || { name: '', argsRaw: '' };
266
+ const { parts, isError } = resultParts(message);
267
+ const output = parts
268
+ .map((part) => (part && typeof part.text === 'string' ? part.text : ''))
269
+ .filter(Boolean)
270
+ .join('');
271
+ const diffs = data.meta && Array.isArray(data.meta.diffs) ? data.meta.diffs : undefined;
272
+ const code = data.error && typeof data.error.code === 'string' ? data.error.code : undefined;
273
+ // 失败有两种写法:`message.isError` 与 `data.error.code`(宿主两种都发过),任一都算失败。
274
+ const failed = isError || code !== undefined;
275
+ turn.tools.set(src.callId, {
276
+ ...current,
277
+ output: output.length > TEXT_MAX ? output.slice(0, TEXT_MAX) : output,
278
+ ...(failed ? { error: code === 'interrupted' ? 'stopped' : 'error' } : { error: null }),
279
+ ...(diffs ? { diffs } : {}),
280
+ });
281
+ refreshToolStep(turn, src.callId);
282
+ return;
283
+ }
284
+ if (event.type === 'turn/end') {
285
+ turn.ended = true;
286
+ // 这一轮收口:还在跑的那几步按成功收尾(宿主不会再补一条结果给它们)。
287
+ closeThoughtsOfStep(turn);
288
+ for (const [callId, tool] of turn.tools) {
289
+ if (tool.error === undefined) putStep(turn, toolKey(callId), { state: 'ok' });
290
+ }
291
+ }
292
+ }
293
+
294
+ /**
295
+ * 这一轮到目前为止跑过的步(App 侧的过程清单)。
296
+ *
297
+ * 只给「一行摘要 + 状态 + 位置」:正文一个字节都不在这里,点开某一步才走 `/session/step`。
298
+ */
299
+ function liveStepsOf(ctx, sessionId) {
300
+ if (!ctx || typeof sessionId !== 'string' || !sessionId) return [];
301
+ const turn = liveStepTurnOf(ctx).get(sessionId);
302
+ if (!turn) return [];
303
+ return turn.steps.map((entry) => ({ key: entry.key, step: { ...entry.step } }));
304
+ }
305
+
306
+ /**
307
+ * 取走「还没告诉过手机」的那几步。
308
+ *
309
+ * 每条都是 `{ key, step }`:新的一步就是追加,同一步的状态变化就是按 `key` 替换。
310
+ */
311
+ function drainLiveSteps(ctx, sessionId) {
312
+ if (!ctx || typeof sessionId !== 'string' || !sessionId) return [];
313
+ const turn = liveStepTurnOf(ctx).get(sessionId);
314
+ if (!turn || turn.pending.length === 0) return [];
315
+ const out = turn.pending.slice();
316
+ turn.pending.length = 0;
317
+ return out;
318
+ }
319
+
320
+ /**
321
+ * 取一步的直播详情;没有这一份(换了一轮、或这一步已经落盘)返回 null,由调用方转历史。
322
+ */
323
+ function liveStepOf(ctx, sessionId, query) {
324
+ if (!ctx || typeof sessionId !== 'string' || !sessionId) return null;
325
+ const bucket = liveStepTurnOf(ctx);
326
+ const turn = bucket.get(sessionId);
327
+ if (!turn) return null;
328
+ if (query && query.kind === 'think') {
329
+ const index = Number.isSafeInteger(query.index) ? query.index : null;
330
+ if (index === null) return null;
331
+ const text = turn.thoughts.get(index);
332
+ if (typeof text !== 'string' || text === '') return null;
333
+ return { kind: 'think', text, live: true, state: turn.ended ? 'ok' : 'running' };
334
+ }
335
+ const callId = query && typeof query.callId === 'string' ? query.callId : '';
336
+ if (!callId) return null;
337
+ const tool = turn.tools.get(callId);
338
+ if (!tool) return null;
339
+ const state = tool.error === undefined ? (turn.ended ? 'ok' : 'running') : (tool.error || 'ok');
340
+ return {
341
+ kind: 'tool',
342
+ live: true,
343
+ ...(tool.name ? { name: tool.name } : {}),
344
+ state,
345
+ ...(tool.argsRaw ? { argsRaw: tool.argsRaw } : {}),
346
+ ...(typeof tool.output === 'string' && tool.output ? { output: tool.output } : {}),
347
+ ...(Array.isArray(tool.diffs) && tool.diffs.length > 0 ? { diffs: tool.diffs } : {}),
348
+ };
349
+ }
350
+
351
+ /** 丢掉一条会话的直播副本(会话关掉、或者换设备时用)。 */
352
+ function clearLiveSteps(ctx, sessionId) {
353
+ liveStepTurnOf(ctx).delete(sessionId);
354
+ }
355
+
356
+ module.exports = { noteLiveEvent, liveStepOf, liveStepsOf, drainLiveSteps, clearLiveSteps };