dsh-plugin-mobile-link 0.6.6 → 0.7.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/README.md +1 -1
- package/cordis.patch.yml +6 -0
- package/lib/dir-listing.js +167 -0
- package/lib/dlp.js +18 -1
- package/lib/enroll.js +2 -2
- package/lib/files.js +118 -12
- package/lib/git.js +20 -5
- package/lib/hello.js +22 -0
- package/lib/history/compress.js +40 -0
- package/lib/history/coverage.js +21 -0
- package/lib/history/fold-summary.js +472 -0
- package/lib/history/fold.js +184 -0
- package/lib/history/session-snapshot.js +75 -24
- package/lib/history/summary-window.js +168 -0
- package/lib/history.js +171 -9
- package/lib/index.js +29 -0
- package/lib/jobs-roster.js +377 -0
- package/lib/link.js +46 -3
- package/lib/logs.js +242 -0
- package/lib/pairing.js +1 -1
- package/lib/router.js +170 -5
- package/lib/session-digest.js +59 -4
- package/lib/state.js +3 -3
- package/lib/unicode.js +116 -0
- package/package.json +1 -1
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `_link/sessionWindow` 的**概要 v2**投影(能力 `history-fold-summary-v2`)。
|
|
3
|
+
*
|
|
4
|
+
* 与 v1(`fold.js`,`fold: true`)的分工:v1 只把"过程段"换成一条 `+link/fold`
|
|
5
|
+
* 摘要,记录里的**原始事件**基本照旧下发(工具正文、思考正文、完整 args 都在)。
|
|
6
|
+
* v2 是给手机首屏用的取数模式:整段过程一个字节都不下发,只留
|
|
7
|
+
* - 人类 `user/message`(含人发的图片);
|
|
8
|
+
* - `assistant/message` 的**正文块**:text 块(中间讲话也在内,思考块摘掉)+ image /
|
|
9
|
+
* file 块(**只留 attachment 元数据**:id / url / mime / 字节数 / 宽高 / 名字,
|
|
10
|
+
* 绝不内联 base64);图片是正式回答的一部分,不能因为折叠就消失;
|
|
11
|
+
* - agent 代发的图片消息(`agent/inbox/spliced` 里带 attachment 的那条:这是宿主
|
|
12
|
+
* 把"agent 发图"送进会话的唯一通道,见 DSHKit `Timeline.joinedImages` 的注释);
|
|
13
|
+
* - `turn/start` / `turn/end` / 压缩通知 / 错误通知;
|
|
14
|
+
* - 每个过程段一条 `+link/fold`(`v: 2`),带分组、步骤标题、参数预览、状态与结果字节数。
|
|
15
|
+
*
|
|
16
|
+
* 展开轮次/分组不发请求:标题与预览都在摘要里;点开单条才按 seq 取那一条正文
|
|
17
|
+
* (现有不带 fold 的窗口请求就够)。设计见
|
|
18
|
+
* `maintainers/设计/20261025-过程折叠-对齐桌面.md` §3.4 / §4.4 / M6 / §十一。
|
|
19
|
+
*
|
|
20
|
+
* 两个口径与桌面保持一致(不是自己发明的):
|
|
21
|
+
* - 活动类别 = 桌面 `processActivity`/`activity(name)` 的那张表(`activityKind`);
|
|
22
|
+
* - 分组 = 中间讲话切开的连续过程段(桌面 `TurnGroups.rebuild` 在 reply / user /
|
|
23
|
+
* turn 边界处 flush)。所以本文件按"段"产摘要,`groups` 是段内的分组(今天一段一组,
|
|
24
|
+
* 字段保留是为了与设计里的线格式一致)。
|
|
25
|
+
*
|
|
26
|
+
* 只做纯函数:IO 由 `summary-window.js` 负责。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { FOLD_RECORD_TYPE, isHumanMessage } from './fold.js'
|
|
30
|
+
import { bytesOf, trimRecordForWire } from './records.js'
|
|
31
|
+
import { truncateSafe } from '../unicode.js'
|
|
32
|
+
|
|
33
|
+
/// `_link/hello` 声明的新能力位。老客户端不认识它,继续用 `fold: true`。
|
|
34
|
+
export const SUMMARY_FOLD_CAPABILITY = 'history-fold-summary-v2'
|
|
35
|
+
/// `fold` 的取值:只有这个字符串才切到 v2;`true` 仍是 v1,其余一律当没折(老行为)。
|
|
36
|
+
export const SUMMARY_FOLD_MODE = 'summary'
|
|
37
|
+
/// 摘要记录的版本号。客户端据此区分 v1(`foldedCount` 那套)与 v2。
|
|
38
|
+
export const SUMMARY_FOLD_VERSION = 2
|
|
39
|
+
/// `preview` 限长(字符)。设计 §十一 写的是"约 80B/步",实现取 80 字。
|
|
40
|
+
export const SUMMARY_PREVIEW_CHARS = 80
|
|
41
|
+
/// 只有这些事件在 v2 里原样(去掉 `stream`)下发;其余要么折进摘要,要么丢掉。
|
|
42
|
+
const KEEP_TYPES = new Set([
|
|
43
|
+
'compaction/start', 'compaction/end', 'compaction/summary', 'agent/error', 'error',
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
/// attachment 上可以下发的**元数据**键。白名单而不是黑名单:宁可少一个字段,
|
|
47
|
+
/// 也不许把 `data` / `base64` / `content` 这类内联负载带出去(一张图的 base64
|
|
48
|
+
/// 有几十万字符,比一整页概要还大)。
|
|
49
|
+
const ATTACHMENT_METADATA_KEYS = [
|
|
50
|
+
'attachmentId', 'id', 'url', 'mediaType', 'mimeType', 'mime', 'bytes', 'size',
|
|
51
|
+
'width', 'height', 'name', 'originalDimensions',
|
|
52
|
+
]
|
|
53
|
+
/// 任何"元数据"字符串超过这个长度就当作内联负载丢掉(例如不带 `data:` 前缀的
|
|
54
|
+
/// base64 或超长签名 URL)。
|
|
55
|
+
const MAX_METADATA_STRING = 512
|
|
56
|
+
/// 不认识的块类型:原样保留(客户端也是"不认识就原样留",见 DSHKit
|
|
57
|
+
/// `ContentBlock.unknown`),但只在它足够小的时候——大块未知负载按内联字节处理。
|
|
58
|
+
const MAX_UNKNOWN_BLOCK_BYTES = 1024
|
|
59
|
+
/// 带 attachment 的块类型。图片与文件都是"正式回答的一部分"。
|
|
60
|
+
const ATTACHMENT_BLOCK_TYPES = new Set(['image', 'file'])
|
|
61
|
+
|
|
62
|
+
/// 工具名 → 桌面活动类别。与 `dsh-client-ui-chat` 的 `activity(name)` 逐条对齐:
|
|
63
|
+
/// 手机分组标题、活动计数都按这张表算,否则同一轮在桌面与手机上标题会不一样。
|
|
64
|
+
///
|
|
65
|
+
/// 表驱动而不是分支链:精确名、同义名集合、前缀三类各一张表,新增工具只加一行。
|
|
66
|
+
const ACTIVITY_EXACT = new Map([
|
|
67
|
+
['read', 'read'], ['read_image', 'readImage'],
|
|
68
|
+
['write', 'write'], ['edit', 'edit'], ['apply_patch', 'edit'],
|
|
69
|
+
['run_code', 'code'], ['web_search', 'webSearch'], ['web_fetch', 'webFetch'],
|
|
70
|
+
['ask_user_question', 'questions'], ['request_user_input', 'questions'],
|
|
71
|
+
])
|
|
72
|
+
const ACTIVITY_GROUPS = [
|
|
73
|
+
[new Set(['grep', 'glob']), 'search'],
|
|
74
|
+
[new Set(['bash', 'pwsh', 'exec_command', 'write_stdin']), 'commands'],
|
|
75
|
+
[new Set(['todo_write', 'create_goal', 'update_goal', 'get_goal']), 'plan'],
|
|
76
|
+
]
|
|
77
|
+
const ACTIVITY_PREFIXES = [
|
|
78
|
+
['terminal_', 'commands'],
|
|
79
|
+
['subagent_', 'subagents'],
|
|
80
|
+
]
|
|
81
|
+
const ACTIVITY_INSPECT_SUFFIX = '_inspect'
|
|
82
|
+
|
|
83
|
+
export function activityKind(name) {
|
|
84
|
+
if (typeof name !== 'string') return 'tools'
|
|
85
|
+
const exact = ACTIVITY_EXACT.get(name)
|
|
86
|
+
if (exact !== undefined) return exact
|
|
87
|
+
for (const [names, kind] of ACTIVITY_GROUPS) if (names.has(name)) return kind
|
|
88
|
+
for (const [prefix, kind] of ACTIVITY_PREFIXES) if (name.startsWith(prefix)) return kind
|
|
89
|
+
if (name === 'subagent') return 'subagents'
|
|
90
|
+
if (name.endsWith(ACTIVITY_INSPECT_SUFFIX)) return 'search'
|
|
91
|
+
return 'tools'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// 一条 `assistant/message` 的内容块。三种形态都认(生产形态 / 旧形态 / 裸文本),
|
|
95
|
+
/// 与 v1 的 `hasTextBlock`、搜索的 `docText` 同一枚举顺序。
|
|
96
|
+
function blocksOf(event) {
|
|
97
|
+
const data = event?.data ?? {}
|
|
98
|
+
const blocks = [data.message?.content, data.content].find((value) => Array.isArray(value))
|
|
99
|
+
if (blocks !== undefined) return blocks
|
|
100
|
+
if (typeof data.text === 'string' && data.text.trim() !== '') return [{ type: 'text', text: data.text }]
|
|
101
|
+
return []
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function reasoningTextOf(blocks) {
|
|
105
|
+
return blocks
|
|
106
|
+
.filter((block) => block?.type === 'reasoning' || block?.type === 'thinking')
|
|
107
|
+
.map((block) => (typeof block?.text === 'string' ? block.text : ''))
|
|
108
|
+
.filter((text) => text.trim() !== '')
|
|
109
|
+
.join('\n')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// attachment 的元数据副本:只留白名单键,且拒绝内联字节(`data:` URI、超长字符串、
|
|
113
|
+
/// 超大对象)。没有任何元数据时返回 undefined(宁可给个空 attachment 也不编字段)。
|
|
114
|
+
function attachmentMetadata(source) {
|
|
115
|
+
if (source === null || typeof source !== 'object') return undefined
|
|
116
|
+
const out = {}
|
|
117
|
+
for (const key of ATTACHMENT_METADATA_KEYS) {
|
|
118
|
+
const value = source[key]
|
|
119
|
+
if (value === undefined || value === null) continue
|
|
120
|
+
if (typeof value === 'string') {
|
|
121
|
+
if (value.length > MAX_METADATA_STRING) continue
|
|
122
|
+
if (/^data:/i.test(value)) continue
|
|
123
|
+
out[key] = value
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (typeof value === 'object') {
|
|
127
|
+
let encoded
|
|
128
|
+
try { encoded = JSON.stringify(value) } catch { continue }
|
|
129
|
+
if (typeof encoded !== 'string' || encoded.length > MAX_METADATA_STRING) continue
|
|
130
|
+
out[key] = value
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
out[key] = value
|
|
134
|
+
}
|
|
135
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// `image` / `file` 块的 v2 形态:`{type, attachment:{元数据}}`,字节一律不内联。
|
|
139
|
+
function attachmentBlockForWire(block) {
|
|
140
|
+
const merged = {
|
|
141
|
+
...(metadataForWire(block) ?? {}),
|
|
142
|
+
...(attachmentMetadata(block?.attachment) ?? {}),
|
|
143
|
+
}
|
|
144
|
+
const out = { type: block.type }
|
|
145
|
+
if (Object.keys(merged).length > 0) out.attachment = merged
|
|
146
|
+
return out
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/// 块级别的元数据(有些形态把 id/url 直接放在块上而不是 `attachment` 里)。
|
|
150
|
+
function metadataForWire(block) {
|
|
151
|
+
return attachmentMetadata(block)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/// 内容块的 v2 白名单,**保持原顺序**(客户端按块序渲染,图片与文字的前后关系要留住):
|
|
155
|
+
/// - `text` 留;空的丢;
|
|
156
|
+
/// - `image` / `file` 只留 attachment 元数据;
|
|
157
|
+
/// - `reasoning` / `thinking` / `tool-call` / `tool-result` 丢(过程已进摘要);
|
|
158
|
+
/// - 其它未知块原样留(小的时候),大块当作内联负载丢。
|
|
159
|
+
function contentForWire(blocks) {
|
|
160
|
+
const out = []
|
|
161
|
+
for (const block of blocks) {
|
|
162
|
+
if (block === null || typeof block !== 'object') continue
|
|
163
|
+
const type = block.type
|
|
164
|
+
if (type === 'text') {
|
|
165
|
+
if (typeof block.text === 'string' && block.text.trim() !== '') {
|
|
166
|
+
out.push({ type: 'text', text: block.text })
|
|
167
|
+
}
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
if (ATTACHMENT_BLOCK_TYPES.has(type)) {
|
|
171
|
+
out.push(attachmentBlockForWire(block))
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
if (type === 'reasoning' || type === 'thinking' || type === 'tool-call' || type === 'tool-result') {
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
let encoded
|
|
178
|
+
try { encoded = JSON.stringify(block) } catch { continue }
|
|
179
|
+
if (typeof encoded !== 'string' || encoded.length > MAX_UNKNOWN_BLOCK_BYTES) continue
|
|
180
|
+
out.push(block)
|
|
181
|
+
}
|
|
182
|
+
return out
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// 这批块里有没有带 attachment 的内容(图片/文件)。判定"这条消息算不算回答"要用它。
|
|
186
|
+
function hasAttachmentBlock(blocks) {
|
|
187
|
+
return blocks.some((block) => ATTACHMENT_BLOCK_TYPES.has(block?.type))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/// 参数预览:折叠空白(步骤行只有一行)后截到 `SUMMARY_PREVIEW_CHARS` 个字符。
|
|
191
|
+
/// 完整 args 不下发——它是体积大户,也是"展开零请求"唯一可以省掉的一项。
|
|
192
|
+
///
|
|
193
|
+
/// 截断点落在 emoji 中间时丢掉那个高代理项(少 1 个码元),不把半个 emoji 发出去:
|
|
194
|
+
/// 落单代理项会让 iOS 侧整条 JSON 解码失败。
|
|
195
|
+
export function previewOf(args) {
|
|
196
|
+
if (typeof args !== 'string') return ''
|
|
197
|
+
const flat = args.replace(/\s+/g, ' ').trim()
|
|
198
|
+
return truncateSafe(flat, SUMMARY_PREVIEW_CHARS)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/// 工具结果里的**正文**(与搜索的 `docText` 同一组键)。只用来量字节数,不下发。
|
|
202
|
+
function resultTextOf(data) {
|
|
203
|
+
const parts = []
|
|
204
|
+
const blocks = [data?.message?.content, data?.content].find((value) => Array.isArray(value)) ?? []
|
|
205
|
+
for (const block of blocks) {
|
|
206
|
+
if (typeof block?.text === 'string' && block.text.length > 0) parts.push(block.text)
|
|
207
|
+
}
|
|
208
|
+
for (const key of ['output', 'result', 'stdout', 'stderr', 'text']) {
|
|
209
|
+
if (typeof data?.[key] === 'string' && data[key].length > 0) parts.push(data[key])
|
|
210
|
+
}
|
|
211
|
+
return parts.join('\n')
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isErrorResult(data) {
|
|
215
|
+
if (data?.message?.isError === true || data?.isError === true) return true
|
|
216
|
+
const error = data?.error ?? data?.message?.error
|
|
217
|
+
return error !== undefined && error !== null && error !== false
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function record(event) {
|
|
221
|
+
return { type: 'event', event }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/// `assistant/message` 的 v2 形态:正文块(text + image/file 元数据)。
|
|
225
|
+
/// `source`(含宿主的 `replayState` 回放日志)整块不要——那是另一个体积大户,
|
|
226
|
+
/// 手机侧没有读者。
|
|
227
|
+
function messageWithContent(event, blocks) {
|
|
228
|
+
const data = {}
|
|
229
|
+
if (Number.isInteger(event?.data?.turn)) data.turn = event.data.turn
|
|
230
|
+
if (Number.isInteger(event?.data?.step)) data.step = event.data.step
|
|
231
|
+
data.message = { role: 'assistant', content: blocks }
|
|
232
|
+
return { type: 'assistant/message', seq: event.seq, time: event.time, data }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/// `user/message` 的 v2 形态。纯文本消息原样(只去掉 `stream`),不重建形状;
|
|
236
|
+
/// 带图片 / 文件、或带会被白名单摘掉的块(思考、工具块)时才重建 `content`,
|
|
237
|
+
/// 并把 attachment 里**内联字节**清掉——图片元数据留着,base64 不出去。
|
|
238
|
+
function userMessageForWire(event, blocks) {
|
|
239
|
+
const needsRewrite = blocks.some((block) => ATTACHMENT_BLOCK_TYPES.has(block?.type)
|
|
240
|
+
|| block?.type === 'reasoning' || block?.type === 'thinking'
|
|
241
|
+
|| block?.type === 'tool-call' || block?.type === 'tool-result')
|
|
242
|
+
if (!needsRewrite) return trimRecordForWire(record(event))
|
|
243
|
+
const content = contentForWire(blocks)
|
|
244
|
+
const data = { ...event.data }
|
|
245
|
+
if (Array.isArray(event?.data?.content)) {
|
|
246
|
+
data.content = content
|
|
247
|
+
} else if (Array.isArray(event?.data?.message?.content)) {
|
|
248
|
+
data.message = { ...event.data.message, content }
|
|
249
|
+
} else {
|
|
250
|
+
data.content = content
|
|
251
|
+
}
|
|
252
|
+
return trimRecordForWire(record({ ...event, data }))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/// 分组的活动计数。**只数工具步骤**,与桌面 `processActivity` 一致:桌面那张表
|
|
256
|
+
/// (commands/read/search/…)是工具类别,思考不算一种"已完成的活动"——一个只有思考的
|
|
257
|
+
/// 分组在桌面标题是 `已完成分析`(`counts` 为空时的兜底),如果这里把思考算成
|
|
258
|
+
/// `thinking: 1`,手机标题就会变成"已分析"之外的东西,与桌面不一致。
|
|
259
|
+
/// 思考本身仍以 `kind: "reasoning"` 的步骤留在 `steps[]` 里(步骤卡要用)。
|
|
260
|
+
function countActivities(steps) {
|
|
261
|
+
const counts = {}
|
|
262
|
+
for (const step of steps) {
|
|
263
|
+
if (step.kind !== 'tools') continue
|
|
264
|
+
const kind = activityKind(step.name)
|
|
265
|
+
counts[kind] = (counts[kind] ?? 0) + 1
|
|
266
|
+
}
|
|
267
|
+
return counts
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 把**一轮**(`turn/start` … `turn/end`,或运行中到上界)的事件投影成 v2 的记录。
|
|
272
|
+
*
|
|
273
|
+
* @param {Array<object>} events 该轮的原始事件,按 seq 升序
|
|
274
|
+
* @returns {{
|
|
275
|
+
* turn: number|null, durationMs: number|null, endReason: string|null,
|
|
276
|
+
* records: Array<{type:string, event:object}>,
|
|
277
|
+
* summaries: Array<object>, bytes: number,
|
|
278
|
+
* startSeq: number|null, endSeq: number|null,
|
|
279
|
+
* }}
|
|
280
|
+
*/
|
|
281
|
+
export function projectTurnUnit(events) {
|
|
282
|
+
const input = (Array.isArray(events) ? events : []).filter((event) => Number.isInteger(event?.seq))
|
|
283
|
+
const startEvent = input.find((event) => event.type === 'turn/start')
|
|
284
|
+
const endEvent = [...input].reverse().find((event) => event.type === 'turn/end')
|
|
285
|
+
|
|
286
|
+
// 轮次号:优先 turn/start;没有边界(日志被裁剪)时用最近一条带 turn 的事件。
|
|
287
|
+
// 都没有就如实给 null,不编一个 0 之外的号。
|
|
288
|
+
let turn = Number.isInteger(startEvent?.data?.turn) ? startEvent.data.turn : null
|
|
289
|
+
if (turn === null) {
|
|
290
|
+
for (let index = input.length - 1; index >= 0; index -= 1) {
|
|
291
|
+
const value = input[index]?.data?.turn
|
|
292
|
+
if (Number.isInteger(value)) { turn = value; break }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const durationMs = startEvent !== undefined && endEvent !== undefined
|
|
296
|
+
&& Number.isFinite(startEvent.time) && Number.isFinite(endEvent.time)
|
|
297
|
+
? Math.max(0, endEvent.time - startEvent.time)
|
|
298
|
+
: null
|
|
299
|
+
// 这一轮**开始**的宿主时间。运行中的轮次没有 durationMs,但客户端仍要显示
|
|
300
|
+
// 真实用时;窗口裁到轮次中间(turn/start 不在本地)时,这是唯一的起点来源。
|
|
301
|
+
const startedAt = Number.isFinite(startEvent?.time) ? startEvent.time : null
|
|
302
|
+
const reason = endEvent?.data?.reason
|
|
303
|
+
const endReason = typeof reason === 'string'
|
|
304
|
+
? reason
|
|
305
|
+
: (typeof reason?.kind === 'string' ? reason.kind : null)
|
|
306
|
+
|
|
307
|
+
const out = []
|
|
308
|
+
const summaries = []
|
|
309
|
+
let group = null
|
|
310
|
+
|
|
311
|
+
const openGroup = (event) => {
|
|
312
|
+
if (group !== null) return
|
|
313
|
+
group = { startSeq: event.seq, endSeq: event.seq, time: event.time, steps: [], calls: new Map() }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const flush = () => {
|
|
317
|
+
const current = group
|
|
318
|
+
group = null
|
|
319
|
+
if (current === null || current.steps.length === 0) return
|
|
320
|
+
const activities = countActivities(current.steps)
|
|
321
|
+
const summary = {
|
|
322
|
+
v: SUMMARY_FOLD_VERSION,
|
|
323
|
+
turn,
|
|
324
|
+
startSeq: current.startSeq,
|
|
325
|
+
endSeq: current.endSeq,
|
|
326
|
+
startedAt,
|
|
327
|
+
durationMs,
|
|
328
|
+
endReason,
|
|
329
|
+
activities,
|
|
330
|
+
groups: [{
|
|
331
|
+
startSeq: current.startSeq,
|
|
332
|
+
endSeq: current.endSeq,
|
|
333
|
+
activities: { ...activities },
|
|
334
|
+
toolCount: current.steps.filter((step) => step.kind === 'tools').length,
|
|
335
|
+
steps: current.steps,
|
|
336
|
+
}],
|
|
337
|
+
}
|
|
338
|
+
summaries.push(summary)
|
|
339
|
+
out.push(record({ type: FOLD_RECORD_TYPE, seq: current.startSeq, time: current.time, data: summary }))
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (const event of input) {
|
|
343
|
+
switch (event.type) {
|
|
344
|
+
case 'turn/start': {
|
|
345
|
+
flush()
|
|
346
|
+
const data = {}
|
|
347
|
+
if (Number.isInteger(event?.data?.turn)) data.turn = event.data.turn
|
|
348
|
+
out.push(record({ type: 'turn/start', seq: event.seq, time: event.time, data }))
|
|
349
|
+
break
|
|
350
|
+
}
|
|
351
|
+
case 'turn/end': {
|
|
352
|
+
flush()
|
|
353
|
+
const data = {}
|
|
354
|
+
if (Number.isInteger(event?.data?.turn)) data.turn = event.data.turn
|
|
355
|
+
if (event?.data?.reason !== undefined) data.reason = event.data.reason
|
|
356
|
+
out.push(record({ type: 'turn/end', seq: event.seq, time: event.time, data }))
|
|
357
|
+
break
|
|
358
|
+
}
|
|
359
|
+
case 'user/message': {
|
|
360
|
+
flush()
|
|
361
|
+
// 只有人打的字才是"讲话"。插件 / 运行时注入的同类型消息(`source.kind`
|
|
362
|
+
// 不是 `user`)手机侧本来也不显示(DSHKit `Timeline.applyEvent` 只看人发的)……
|
|
363
|
+
// 但如果它带着图片,那就是"agent 代发的图片回答"(老格式走这条),必须留。
|
|
364
|
+
const blocks = blocksOf(event)
|
|
365
|
+
if (isHumanMessage(event) || hasAttachmentBlock(blocks)) {
|
|
366
|
+
out.push(userMessageForWire(event, blocks))
|
|
367
|
+
}
|
|
368
|
+
break
|
|
369
|
+
}
|
|
370
|
+
case 'agent/inbox/spliced': {
|
|
371
|
+
// 宿主把 **agent 发出的图片** 走 splice 通道送进会话(DSHKit `Timeline`
|
|
372
|
+
// 的 `joinedImages` 注释:"splice 是 agent 发的图片进入对话的唯一通道")。
|
|
373
|
+
// 这条消息可能就是正式回答本身,所以只要它带 attachment 就留;其余 splice
|
|
374
|
+
// (用户消息的本地回声、插件通知)在 v2 里一律不发——人打的那句话在
|
|
375
|
+
// `user/message` 里已经有了,客户端本来就按 rpcId 去重。
|
|
376
|
+
const inserted = Array.isArray(event?.data?.inserted) ? event.data.inserted : []
|
|
377
|
+
const items = inserted.map((item) => {
|
|
378
|
+
const itemBlocks = Array.isArray(item?.content) ? item.content : []
|
|
379
|
+
return { item, blocks: itemBlocks, content: contentForWire(itemBlocks) }
|
|
380
|
+
}).filter((entry) => entry.content.length > 0
|
|
381
|
+
&& entry.content.some((block) => ATTACHMENT_BLOCK_TYPES.has(block.type)))
|
|
382
|
+
if (items.length === 0) break
|
|
383
|
+
flush()
|
|
384
|
+
out.push(record({
|
|
385
|
+
type: 'agent/inbox/spliced',
|
|
386
|
+
seq: event.seq,
|
|
387
|
+
time: event.time,
|
|
388
|
+
data: {
|
|
389
|
+
...(typeof event?.data?.target === 'string' ? { target: event.data.target } : {}),
|
|
390
|
+
inserted: items.map(({ item, content }) => {
|
|
391
|
+
const kept = { content }
|
|
392
|
+
for (const key of ['source', 'role', 'id']) {
|
|
393
|
+
if (item?.[key] !== undefined) kept[key] = item[key]
|
|
394
|
+
}
|
|
395
|
+
return kept
|
|
396
|
+
}),
|
|
397
|
+
},
|
|
398
|
+
}))
|
|
399
|
+
break
|
|
400
|
+
}
|
|
401
|
+
case 'assistant/message': {
|
|
402
|
+
const blocks = blocksOf(event)
|
|
403
|
+
const reasoning = reasoningTextOf(blocks)
|
|
404
|
+
if (reasoning !== '') {
|
|
405
|
+
openGroup(event)
|
|
406
|
+
group.steps.push({
|
|
407
|
+
seq: event.seq,
|
|
408
|
+
kind: 'reasoning',
|
|
409
|
+
name: 'thinking',
|
|
410
|
+
preview: '',
|
|
411
|
+
status: 'ok',
|
|
412
|
+
bytes: Buffer.byteLength(reasoning, 'utf8'),
|
|
413
|
+
})
|
|
414
|
+
group.endSeq = event.seq
|
|
415
|
+
}
|
|
416
|
+
const content = contentForWire(blocks)
|
|
417
|
+
if (content.length > 0) {
|
|
418
|
+
flush()
|
|
419
|
+
out.push(record(messageWithContent(event, content)))
|
|
420
|
+
}
|
|
421
|
+
break
|
|
422
|
+
}
|
|
423
|
+
case 'tool/call': {
|
|
424
|
+
openGroup(event)
|
|
425
|
+
const step = {
|
|
426
|
+
seq: event.seq,
|
|
427
|
+
kind: 'tools',
|
|
428
|
+
name: typeof event?.data?.name === 'string' && event.data.name !== '' ? event.data.name : 'tool',
|
|
429
|
+
preview: previewOf(event?.data?.arguments),
|
|
430
|
+
status: 'running',
|
|
431
|
+
}
|
|
432
|
+
group.steps.push(step)
|
|
433
|
+
const callId = event?.data?.callId
|
|
434
|
+
if (typeof callId === 'string') group.calls.set(callId, step)
|
|
435
|
+
group.endSeq = event.seq
|
|
436
|
+
break
|
|
437
|
+
}
|
|
438
|
+
case 'tool/result': {
|
|
439
|
+
const callId = event?.data?.message?.toolCallId ?? event?.data?.toolCallId ?? event?.data?.callId
|
|
440
|
+
const step = group?.calls.get(callId)
|
|
441
|
+
// 结果找不到对应调用(日志被裁剪、旧格式)就丢掉:摘要里不凭空造一步。
|
|
442
|
+
if (step === undefined) break
|
|
443
|
+
const text = resultTextOf(event?.data)
|
|
444
|
+
step.status = isErrorResult(event?.data) ? 'error' : 'ok'
|
|
445
|
+
step.resultSeq = event.seq
|
|
446
|
+
if (text !== '') step.bytes = Buffer.byteLength(text, 'utf8')
|
|
447
|
+
group.endSeq = event.seq
|
|
448
|
+
break
|
|
449
|
+
}
|
|
450
|
+
default: {
|
|
451
|
+
if (!KEEP_TYPES.has(event.type)) break
|
|
452
|
+
flush()
|
|
453
|
+
out.push(trimRecordForWire(record(event)))
|
|
454
|
+
break
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
flush()
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
turn,
|
|
462
|
+
startedAt,
|
|
463
|
+
durationMs,
|
|
464
|
+
endReason,
|
|
465
|
+
records: out,
|
|
466
|
+
summaries,
|
|
467
|
+
// 预算单位是**下发字节**,不是原始事件字节:摘要页的价值就在这里。
|
|
468
|
+
bytes: bytesOf(out),
|
|
469
|
+
startSeq: out.length > 0 ? out[0].event.seq : null,
|
|
470
|
+
endSeq: out.length > 0 ? out[out.length - 1].event.seq : null,
|
|
471
|
+
}
|
|
472
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `_link/sessionWindow` 的折叠感知下发(能力 `history-fold-summary`)。
|
|
3
|
+
*
|
|
4
|
+
* 手机对已完成的轮次默认收起过程(spec 033)。但报文仍按原始事件把整段过程
|
|
5
|
+
* 发下来——实测一次上翻里多数字节属于折叠后不可见的行。长轮次会跨好几页
|
|
6
|
+
* (本机样本一轮可达千级 seq),所以这里不按"整轮"折,而按**页内连续过程段**折:
|
|
7
|
+
* 一段连续的助手中间输出 / 工具调用 / 工具结果收成一条 `+link/fold` 摘要记录;
|
|
8
|
+
* 用户消息、轮次开始/结束、压缩与错误通知一律保留。
|
|
9
|
+
*
|
|
10
|
+
* 保留规则是**轮级**的,与客户端收起态契约一致(`TranscriptProcessFold.plan`):
|
|
11
|
+
* 每个 `turn/end` 之前末尾连续的一段回答必须留在页里(回答 = 带正文的
|
|
12
|
+
* `assistant/message`,或 agent 发送的图片消息);页尾再补一次同样的扫描,覆盖
|
|
13
|
+
* "轮次未结束、`turn/end` 在下一页"的跨页截断。回答不在页里,收起后该轮就没有正文;
|
|
14
|
+
* 少折只损失字节。
|
|
15
|
+
*
|
|
16
|
+
* 展开:客户端按摘要的 `startSeq...endSeq` 发一次**不带 fold** 的普通窗口请求
|
|
17
|
+
* (现有 `_link/sessionWindow`),取回原事件即可,不需要新端点。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/// 客户端按这个类型画过程节点;以 `+` 开头,不会与宿主事件类型相撞。
|
|
21
|
+
export const FOLD_RECORD_TYPE = '+link/fold'
|
|
22
|
+
|
|
23
|
+
/// 连接器在 `_link/hello` 里声明:`_link/sessionWindow` 接受 `fold: true`。
|
|
24
|
+
export const FOLD_CAPABILITY = 'history-fold-summary'
|
|
25
|
+
|
|
26
|
+
/// 客户端渲染不读、可以整条丢掉的事件(不产生任何行,也不参与折叠状态)。
|
|
27
|
+
/// `turn/start` / `turn/end` 不在其中:它们维持轮次状态与结束标记。
|
|
28
|
+
const DROP_TYPES = new Set([
|
|
29
|
+
'request/header', 'step/start', 'step/end', 'goal/change', 'title/change',
|
|
30
|
+
'system/message', 'developer/message',
|
|
31
|
+
'session-log-deepseek/delivery-accepted', 'session/end-seed', 'compaction/prune',
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
/// 可以收进摘要的过程事件。助手回答里可能既有思考也有正文;整条一起折。
|
|
35
|
+
const FOLD_TYPES = new Set(['assistant/message', 'tool/call', 'tool/result'])
|
|
36
|
+
|
|
37
|
+
/// 会渲染成可见行的通知 / 压缩事件:保留,绝不折。
|
|
38
|
+
const KEEP_SIDE_EVENTS = new Set([
|
|
39
|
+
'compaction/start', 'compaction/end', 'compaction/summary', 'agent/error', 'error',
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
/// 是不是**人打的字**(`user/message` 里插件 / 运行时注入的同类型消息不算)。
|
|
43
|
+
///
|
|
44
|
+
/// 导出给 `fold-summary.js` 用:v1 用它决定"人类消息切开过程段",v2 用它决定
|
|
45
|
+
/// "哪些 `user/message` 算讲话"。同一口径只留一份,两边各写一遍必然会漂。
|
|
46
|
+
export function isHumanMessage(event) {
|
|
47
|
+
if (event?.type !== 'user/message') return false
|
|
48
|
+
const source = event?.data?.source
|
|
49
|
+
if (source !== undefined && source !== null && typeof source === 'object') {
|
|
50
|
+
if (source.kind !== undefined && source.kind !== 'user') return false
|
|
51
|
+
if (typeof source.rpcId === 'string' && source.rpcId.startsWith('agent-')) return false
|
|
52
|
+
}
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function reasoningBlocks(event) {
|
|
57
|
+
const blocks = event?.data?.content
|
|
58
|
+
if (!Array.isArray(blocks)) return 0
|
|
59
|
+
return blocks.filter((block) => block?.type === 'reasoning' || block?.type === 'thinking').length
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// 一条 `assistant/message` 会不会渲染成正文行。
|
|
63
|
+
///
|
|
64
|
+
/// 判定必须看 content 块,不能只看事件类型:一条消息可以只有 `reasoning` 块,
|
|
65
|
+
/// 客户端不会为它产生 `assistantText` 行(Timeline.swift 按块拆行)。枚举的形态与
|
|
66
|
+
/// `docText` 的消息类枚举对齐:`data.message.content`(宿主生产形态)、`data.content`
|
|
67
|
+
/// (旧 / 测试形态)、以及裸 `data.text`。
|
|
68
|
+
function hasTextBlock(event) {
|
|
69
|
+
if (event?.type !== 'assistant/message') return false
|
|
70
|
+
const data = event?.data ?? {}
|
|
71
|
+
const blocks = [data.message?.content, data.content].find((value) => Array.isArray(value)) ?? []
|
|
72
|
+
const hasBlock = blocks.some((block) => block?.type === 'text'
|
|
73
|
+
&& typeof block.text === 'string' && block.text.trim() !== '')
|
|
74
|
+
return hasBlock || (typeof data.text === 'string' && data.text.trim() !== '')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// agent 代发的消息(图片型回答)也算回答行:客户端 `isAnswerRow` 认它。
|
|
78
|
+
/// 人类用户消息是可见行、会切开过程段,但不是回答候选。
|
|
79
|
+
function isAgentMessage(event) {
|
|
80
|
+
return event?.type === 'user/message' && !isHumanMessage(event)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// 轮级保留:把 `turn/end`(或页尾)之前末尾连续的一段回答加进保护集。
|
|
84
|
+
///
|
|
85
|
+
/// 与客户端 `TranscriptProcessFold.plan` 同构:从 `beforeIndex - 1` 向前找第一条
|
|
86
|
+
/// **回答候选**作为锚点,再向左扩展整段连续回答;`DROP_TYPES` 事件不产生行、跳过
|
|
87
|
+
/// 不算断开;人类用户消息不是候选但也不终止扫描(回答可能在它的后面);其余任何
|
|
88
|
+
/// 事件(tool/*、无正文的 assistant、通知类、`turn/start`)停止扫描——尤其 `turn/end`
|
|
89
|
+
/// / `turn/start` 保证不跨入上一轮。
|
|
90
|
+
function protectAnswerRun(events, beforeIndex, protectedIndexes) {
|
|
91
|
+
const isCandidate = (index) => hasTextBlock(events[index]) || isAgentMessage(events[index])
|
|
92
|
+
let anchor = -1
|
|
93
|
+
for (let index = beforeIndex - 1; index >= 0; index -= 1) {
|
|
94
|
+
const event = events[index]
|
|
95
|
+
if (event.type === 'turn/end' || event.type === 'turn/start') return
|
|
96
|
+
if (isCandidate(index)) {
|
|
97
|
+
anchor = index
|
|
98
|
+
break
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (anchor < 0) return
|
|
102
|
+
for (let index = anchor; index >= 0; index -= 1) {
|
|
103
|
+
const event = events[index]
|
|
104
|
+
if (DROP_TYPES.has(event.type)) continue
|
|
105
|
+
if (!isCandidate(index)) break
|
|
106
|
+
if (hasTextBlock(event)) protectedIndexes.add(index)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function summarise(run) {
|
|
111
|
+
let toolCount = 0
|
|
112
|
+
let reasoningCount = 0
|
|
113
|
+
let messageCount = 0
|
|
114
|
+
let subagentCount = 0
|
|
115
|
+
for (const event of run) {
|
|
116
|
+
if (event.type === 'tool/call') {
|
|
117
|
+
toolCount += 1
|
|
118
|
+
if (event?.data?.name === 'subagent' || event?.data?.name === 'task') subagentCount += 1
|
|
119
|
+
} else if (event.type === 'assistant/message') {
|
|
120
|
+
messageCount += 1
|
|
121
|
+
reasoningCount += reasoningBlocks(event)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const turn = run.find((event) => Number.isInteger(event?.data?.turn))?.data?.turn ?? 0
|
|
125
|
+
return {
|
|
126
|
+
turn,
|
|
127
|
+
startSeq: run[0].seq,
|
|
128
|
+
endSeq: run[run.length - 1].seq,
|
|
129
|
+
finalSeq: null,
|
|
130
|
+
dividerSeq: null,
|
|
131
|
+
toolCount,
|
|
132
|
+
reasoningCount,
|
|
133
|
+
messageCount,
|
|
134
|
+
subagentCount,
|
|
135
|
+
endReason: null,
|
|
136
|
+
durationMs: null,
|
|
137
|
+
foldedCount: run.length,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// 把一页记录折成"过程摘要 + 保留事件"。
|
|
142
|
+
///
|
|
143
|
+
/// `records` 必须是 `[{type:'event', event}]` 且按 seq 升序。返回的 `records`
|
|
144
|
+
/// 保持这个形状;`folds` 是同一份摘要的列表,便于记账与测试。
|
|
145
|
+
export function foldProcessRuns(records) {
|
|
146
|
+
const events = records.map((record) => record.event)
|
|
147
|
+
// 轮级保留:每个已结束轮次回答段各保一次,页尾再补一次覆盖跨页未结束的轮次。
|
|
148
|
+
const protectedIndexes = new Set()
|
|
149
|
+
for (let i = 0; i < events.length; i += 1) {
|
|
150
|
+
if (events[i].type === 'turn/end') protectAnswerRun(events, i, protectedIndexes)
|
|
151
|
+
}
|
|
152
|
+
protectAnswerRun(events, events.length, protectedIndexes)
|
|
153
|
+
|
|
154
|
+
const out = []
|
|
155
|
+
const folds = []
|
|
156
|
+
let run = []
|
|
157
|
+
const flush = () => {
|
|
158
|
+
if (run.length === 0) return
|
|
159
|
+
const summary = summarise(run)
|
|
160
|
+
out.push({ type: FOLD_RECORD_TYPE, seq: summary.startSeq, time: run[0].time, data: summary })
|
|
161
|
+
folds.push(summary)
|
|
162
|
+
run = []
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
166
|
+
const event = events[index]
|
|
167
|
+
if (DROP_TYPES.has(event.type)) continue
|
|
168
|
+
const protectedEvent = protectedIndexes.has(index)
|
|
169
|
+
if (FOLD_TYPES.has(event.type) && !protectedEvent) {
|
|
170
|
+
run.push(event)
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
flush()
|
|
174
|
+
out.push(event)
|
|
175
|
+
// 可见行(用户消息、回答、通知、轮次边界)把过程段切开;保留原顺序。
|
|
176
|
+
if (KEEP_SIDE_EVENTS.has(event.type) || isHumanMessage(event)) continue
|
|
177
|
+
}
|
|
178
|
+
flush()
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
records: out.map((event) => ({ type: 'event', event })),
|
|
182
|
+
folds,
|
|
183
|
+
}
|
|
184
|
+
}
|