dsh-plugin-mobile-link 0.6.6 → 0.8.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.
@@ -41,39 +41,90 @@ export function createSnapshotCache({
41
41
  } = {}) {
42
42
  const entries = new Map()
43
43
 
44
+ /// 同一会话的并发读只打一次宿主(预热与首屏可能同时到)。
45
+ const loading = new Map()
46
+
47
+ function fresh(sessionId) {
48
+ const cached = entries.get(sessionId)
49
+ if (cached === undefined || now() - cached.at >= ttlMs) return undefined
50
+ // LRU:命中后挪到队尾。
51
+ entries.delete(sessionId)
52
+ entries.set(sessionId, cached)
53
+ return cached
54
+ }
55
+
56
+ async function load(sessionId) {
57
+ if (typeof readSession !== 'function') return undefined
58
+ let snapshot
59
+ try {
60
+ snapshot = await readSession(sessionId)
61
+ } catch {
62
+ return undefined
63
+ }
64
+ const events = snapshot?.events
65
+ if (!Array.isArray(events) || events.length > maxEvents) return undefined
66
+ const entry = {
67
+ events,
68
+ newestSeq: events.length > 0 ? events[events.length - 1].seq : -1,
69
+ at: now(),
70
+ }
71
+ entries.set(sessionId, entry)
72
+ while (entries.size > maxEntries) entries.delete(entries.keys().next().value)
73
+ return entry
74
+ }
75
+
44
76
  return {
45
77
  /** 命中且新鲜就返回;否则读一次;读不到/超限/形状不对一律 undefined。 */
46
78
  async get(sessionId) {
47
- const cached = entries.get(sessionId)
48
- if (cached !== undefined && now() - cached.at < ttlMs) {
49
- // LRU:命中后挪到队尾。
50
- entries.delete(sessionId)
51
- entries.set(sessionId, cached)
52
- return cached
53
- }
54
- if (typeof readSession !== 'function') return undefined
55
- let snapshot
56
- try {
57
- snapshot = await readSession(sessionId)
58
- } catch {
59
- return undefined
60
- }
61
- const events = snapshot?.events
62
- if (!Array.isArray(events) || events.length > maxEvents) return undefined
63
- const entry = {
64
- events,
65
- newestSeq: events.length > 0 ? events[events.length - 1].seq : -1,
66
- at: now(),
67
- }
68
- entries.set(sessionId, entry)
69
- while (entries.size > maxEntries) entries.delete(entries.keys().next().value)
70
- return entry
79
+ const cached = fresh(sessionId)
80
+ if (cached !== undefined) return cached
81
+ const pending = loading.get(sessionId)
82
+ if (pending !== undefined) return pending
83
+ const task = load(sessionId).finally(() => loading.delete(sessionId))
84
+ loading.set(sessionId, task)
85
+ return task
86
+ },
87
+ /** 只看缓存,不读宿主(浏览翻页第一次请求用它,避免首屏多付一次整份读)。 */
88
+ peek(sessionId) { return fresh(sessionId) },
89
+ /** 后台预热:不阻塞当前请求,下一次翻页就能命中。 */
90
+ warm(sessionId) {
91
+ if (fresh(sessionId) !== undefined) return
92
+ if (loading.has(sessionId)) return
93
+ void this.get(sessionId)
71
94
  },
72
95
  invalidate(sessionId) { entries.delete(sessionId) },
73
96
  get size() { return entries.size },
74
97
  }
75
98
  }
76
99
 
100
+ /**
101
+ * 快照上的「再往旧读一批」读取器(概要 v2 用):`(cursor) => {events, floorSeq, exhausted, clamped}`。
102
+ *
103
+ * 与 `scanSnapshot` 的区别只在职责:`scanSnapshot` 是"过滤 + 交给 `pageFrom` 裁一页",
104
+ * 这里只回答"seq ≤ cursor 的原始事件有哪些",因为概要 v2 的分页单位是**整轮**,
105
+ * 裁剪由 `summary-window.js` 按轮做。事件已经在内存里,所以这一层是同步的。
106
+ */
107
+ export function snapshotOlderReader(snapshot) {
108
+ const events = Array.isArray(snapshot?.events) ? snapshot.events : []
109
+ const newest = Number.isInteger(snapshot?.newestSeq) ? snapshot.newestSeq : -1
110
+ return (cursor) => {
111
+ const batch = []
112
+ for (const event of events) {
113
+ if (!Number.isInteger(event?.seq)) continue
114
+ if (event.seq > cursor) break
115
+ batch.push(event)
116
+ }
117
+ const floorSeq = batch.length > 0 ? batch[0].seq : 0
118
+ return {
119
+ events: batch,
120
+ floorSeq,
121
+ // 日志被裁剪过时第一条的 seq 可能不是 0;下一次读到空批就自然收敛(见调用方)。
122
+ exhausted: floorSeq === 0,
123
+ clamped: cursor > newest,
124
+ }
125
+ }
126
+ }
127
+
77
128
  /**
78
129
  * 从快照扫一页。**纯函数**,返回形状与 `scanOlder` / `filteredScan` 完全一致,
79
130
  * 于是裁剪与分页仍然由 `pageFrom` 一处决定。
@@ -0,0 +1,168 @@
1
+ /**
2
+ * 概要 v2 的**整轮切页**(能力 `history-fold-summary-v2`)。
3
+ *
4
+ * 规格(`maintainers/设计/20261025-过程折叠-对齐桌面.md` §3.4 第 5 条):
5
+ * 窗口**从轮次边界开始、到轮次边界结束**;一页装不下整轮就少给轮次,不能给半轮。
6
+ * 这样手机侧不再出现"记录不完整"与"连续孤立摘要"两种 UI(设计 §5 G9)。
7
+ *
8
+ * 唯一的 IO 是调用方给的 `readOlder(cursor)`,返回 `seq ≤ cursor` 的原始事件
9
+ * (升序)+ `floorSeq` / `exhausted` / `clamped`。两个实现(会话快照 / 分块回读)
10
+ * 形状相同,所以这里可以纯逻辑地单测。
11
+ *
12
+ * 三条不变量,任何改动都不许破坏:
13
+ * 1. **绝不下发半轮**:一轮的事件没读全(到了调用上限或时间预算)就整轮丢掉;
14
+ * 连最新一轮都读不全时回可重试错误,而不是给一段残缺的过程。
15
+ * 2. **至少给一轮**:`limit` 比一轮投影后的记录数还小也照给这一轮——否则翻页会卡在
16
+ * 同一处永远前进不了(客户端按 `startSeq` 当游标)。
17
+ * 3. **游标落在轮次边界**:`startSeq` 永远是某个 `turn/start`(或日志真正的起点),
18
+ * 下一次请求 `before = startSeq` 正好落在上一轮的 `turn/end` 之后。
19
+ */
20
+
21
+ import { projectTurnUnit } from './fold-summary.js'
22
+
23
+ /// 一次调用最多回读多少批原始事件。分块路径一批 51 条(宿主 `readEvent` 的上限),
24
+ /// 64 批 ≈ 3264 条 ≈ 1.5–2 s;快照路径一批就是整份日志,用不到这个数。
25
+ export const SUMMARY_MAX_BATCHES = 64
26
+ /// 一次调用最多花多久在"往回读"上(毫秒)。到点即按"没读全"处理,绝不下发半轮。
27
+ export const SUMMARY_DEADLINE_MS = 6000
28
+ /// 一页最多塞多少轮。防"预算设得极大 + 会话极长"时把一页做成整份日志。
29
+ export const SUMMARY_MAX_UNITS = 200
30
+
31
+ const EMPTY_PAGE = {
32
+ records: [], summaries: [], startSeq: null, endSeq: null,
33
+ hasMore: false, truncatedBy: null, clamped: false,
34
+ }
35
+
36
+ /**
37
+ * 从 `upper` 往回按整轮取一页。
38
+ *
39
+ * @param {object} input
40
+ * @param {(cursor: number) => ({events?: Array<object>, floorSeq?: number, exhausted?: boolean, clamped?: boolean})
41
+ * | Promise<object>} input.readOlder 往回读一批(`seq ≤ cursor`)
42
+ * @param {number} input.upper 本页最新一条的 seq(含)
43
+ * @param {number} input.limit 本页最多几**轮**(本模式的页单位是轮;普通窗口里它是记录条数)
44
+ * @param {number} input.maxBytes 字节预算(按**投影后**的下发记录算)
45
+ * @param {boolean} [input.clamped] 调用方已经把上界夹到日志最新(越界请求)
46
+ * @param {number} [input.maxBatches] 回读批数上限
47
+ * @param {number} [input.deadlineMs] 回读时间上限
48
+ * @param {() => number} [input.now] 时钟(测试用)
49
+ * @returns {Promise<object>} 页;`{error:{code,message}}` 表示这一页取不出来
50
+ */
51
+ export async function readSummaryPage({
52
+ readOlder,
53
+ upper,
54
+ limit,
55
+ maxBytes,
56
+ clamped: upperClamped = false,
57
+ maxBatches = SUMMARY_MAX_BATCHES,
58
+ deadlineMs = SUMMARY_DEADLINE_MS,
59
+ now = Date.now,
60
+ }) {
61
+ if (!Number.isInteger(upper) || upper < 0) return { ...EMPTY_PAGE }
62
+ const startedAt = now()
63
+ const stack = []
64
+ let nextRead = upper
65
+ let exhausted = false
66
+ let capped = false
67
+ let clamped = upperClamped === true
68
+ let batches = 0
69
+
70
+ const budgetSpent = () => batches >= maxBatches || now() - startedAt >= deadlineMs
71
+
72
+ async function fill() {
73
+ if (stack.length > 0 || exhausted) return
74
+ if (budgetSpent()) { capped = true; return }
75
+ const batch = await readOlder(nextRead)
76
+ batches += 1
77
+ if (batch?.clamped === true) clamped = true
78
+ const events = Array.isArray(batch?.events) ? batch.events : []
79
+ for (const event of events) {
80
+ if (!Number.isInteger(event?.seq) || event.seq > nextRead) continue
81
+ stack.push(event)
82
+ }
83
+ const floorSeq = Number.isInteger(batch?.floorSeq)
84
+ ? batch.floorSeq
85
+ : (events[0]?.seq ?? 0)
86
+ if (batch?.exhausted === true || floorSeq <= 0 || events.length === 0) exhausted = true
87
+ nextRead = Math.min(nextRead, floorSeq) - 1
88
+ }
89
+
90
+ /// 读一轮:往回读到 `turn/start`(含)为止。`complete` 为假表示没读全(上限/没数据),
91
+ /// 调用方必须整轮丢掉。
92
+ async function readUnit() {
93
+ const events = []
94
+ while (true) {
95
+ await fill()
96
+ if (stack.length === 0) return { events: events.reverse(), complete: exhausted }
97
+ const event = stack.pop()
98
+ events.push(event)
99
+ if (event.type === 'turn/start') return { events: events.reverse(), complete: true }
100
+ }
101
+ }
102
+
103
+ const kept = []
104
+ let byteCount = 0
105
+ let unitsSeen = 0
106
+ let droppedHalf = false
107
+ let stop = null
108
+
109
+ for (let index = 0; index < SUMMARY_MAX_UNITS; index += 1) {
110
+ const { events, complete } = await readUnit()
111
+ if (events.length === 0) { stop ??= 'done'; break }
112
+ unitsSeen += 1
113
+ if (!complete) {
114
+ // 半轮:整轮丢掉(最新一轮也不破例,宁可报错让客户端重试)。
115
+ droppedHalf = true
116
+ stop = 'incomplete'
117
+ break
118
+ }
119
+ const projected = projectTurnUnit(events)
120
+ if (projected.records.length > 0) {
121
+ // 字节预算只约束"较旧的那些轮":最新的这一轮无论如何都要给全(宁可超一点,
122
+ // 也不给半轮、也不让客户端永远看不到当前轮)。
123
+ if (kept.length > 0 && byteCount + projected.bytes > maxBytes) { stop = 'bytes'; break }
124
+ // 页边界取**整轮原始事件**的首尾,不是首尾下发记录:一轮起点前的
125
+ // `request/header` 之类虽然在 v2 里不下发,但它们确实属于这一页的 seq 段,
126
+ // 游标必须越过它们,否则下一次请求会把同一段重扫一遍。
127
+ kept.push({
128
+ ...projected,
129
+ unitStartSeq: events[0].seq,
130
+ unitEndSeq: events[events.length - 1].seq,
131
+ })
132
+ byteCount += projected.bytes
133
+ }
134
+ if (kept.length >= limit) { stop = 'count'; break }
135
+ if (byteCount >= maxBytes) { stop = 'bytes'; break }
136
+ if (exhausted && stack.length === 0) { stop = 'done'; break }
137
+ }
138
+ if (stop === null) stop = 'units'
139
+
140
+ if (kept.length === 0) {
141
+ if (droppedHalf && unitsSeen > 0) {
142
+ return {
143
+ error: {
144
+ code: 'link/too-large',
145
+ message: 'one turn cannot be read within a single page; retry',
146
+ },
147
+ }
148
+ }
149
+ return { ...EMPTY_PAGE, clamped }
150
+ }
151
+
152
+ const ordered = [...kept].reverse()
153
+ const records = ordered.flatMap((unit) => unit.records)
154
+ const summaries = ordered.flatMap((unit) => unit.summaries)
155
+ return {
156
+ records,
157
+ summaries,
158
+ startSeq: ordered[0].unitStartSeq,
159
+ endSeq: ordered[ordered.length - 1].unitEndSeq,
160
+ // "还有更早的"只由"往回读到底没有"回答:预算停下、或者被回读上限截住,
161
+ // 都还有更早的轮次。游标永远是 `startSeq`(一轮的起点),不靠猜。
162
+ hasMore: !(exhausted && stack.length === 0),
163
+ truncatedBy: stop === 'count' ? 'count' : stop === 'bytes' ? 'bytes' : null,
164
+ clamped,
165
+ batches,
166
+ units: ordered.length,
167
+ }
168
+ }
package/lib/history.js CHANGED
@@ -1,11 +1,19 @@
1
1
  import { HOST_CALL_TIMEOUT_MS, withTimeout, messageOf } from './history/shared.js'
2
+ import { sliceRangeSafe, truncateSafe } from './unicode.js'
2
3
  import {
3
4
  DEFAULT_LIMIT, DEFAULT_MAX_BYTES, WINDOW_CHUNK,
4
5
  bytesOfRecords, displayRoleOf, roleOf, toRecord, trimRecordForWire, trimRecordsForWire,
5
6
  } from './history/records.js'
6
7
  import { pageFrom, scanOlder } from './history/window.js'
7
8
  import { filteredScan } from './history/filter-scan.js'
8
- import { createSnapshotCache, scanSnapshot } from './history/session-snapshot.js'
9
+ import { createSnapshotCache, scanSnapshot, snapshotOlderReader } from './history/session-snapshot.js'
10
+ import {
11
+ HISTORY_COMPRESSION_CAPABILITY, maybeCompressHistoryValue,
12
+ } from './history/compress.js'
13
+ import { FOLD_CAPABILITY, foldProcessRuns } from './history/fold.js'
14
+ import { SUMMARY_FOLD_CAPABILITY, SUMMARY_FOLD_MODE } from './history/fold-summary.js'
15
+ import { readSummaryPage } from './history/summary-window.js'
16
+ import { emptySpans } from './history/coverage.js'
9
17
 
10
18
  // Re-exported for the caller/tests that read them off this module (single source
11
19
  // is `history/records.js`; the call ceiling lives in `history/shared.js`).
@@ -50,6 +58,17 @@ export const HISTORY_CAPABILITIES = [
50
58
  // so the reader can be told "已扫 k/N 片" from the first page on. Purely additive:
51
59
  // `totalChunks` comes from the log's newest seq, which a scan already fetches.
52
60
  'search-progress',
61
+ // history-compression: `_link/sessionWindow` 接受 `encoding: "deflate-raw"`,
62
+ // 回包变成 `{encoding, value(base64), rawBytes}`。手机只在看到这条能力时才请求;
63
+ // 老客户端不传 encoding,拿到的仍是纯 JSON。
64
+ HISTORY_COMPRESSION_CAPABILITY,
65
+ // history-fold-summary: `_link/sessionWindow` 接受 `fold: true`,把本页内
66
+ // 已完成的轮次折成一条 `+link/fold` 摘要记录(展开时用不带 fold 的普通窗口取回)。
67
+ FOLD_CAPABILITY,
68
+ // history-fold-summary-v2: `fold: "summary"` —— 概要先行取数模式(设计
69
+ // 20261025 §3.4/M6)。只回讲话 + `+link/fold` v2 概要 + 正式回答,按整轮切页。
70
+ // 独立能力位:老连接器不认识这个值,老客户端也不会传它,两边都没有行为变化。
71
+ SUMMARY_FOLD_CAPABILITY,
53
72
  ]
54
73
 
55
74
  export function isHistoryMethod(method) {
@@ -129,12 +148,12 @@ function makeSnippet(text, query, radius = 36) {
129
148
  const haystack = String(text ?? '')
130
149
  const at = haystack.toLowerCase().indexOf(query.toLowerCase())
131
150
  if (at < 0) {
132
- const head = haystack.slice(0, radius * 2).replace(/\s+/g, ' ').trim()
151
+ const head = truncateSafe(haystack, radius * 2).replace(/\s+/g, ' ').trim()
133
152
  return { snippet: head, highlightStart: 0, highlightLength: 0 }
134
153
  }
135
154
  const start = Math.max(0, at - radius)
136
155
  const end = Math.min(haystack.length, at + query.length + radius)
137
- const snippet = haystack.slice(start, end).replace(/\s+/g, ' ').trim()
156
+ const snippet = sliceRangeSafe(haystack, start, end).replace(/\s+/g, ' ').trim()
138
157
  // Re-find the match inside the flattened snippet so the offsets line up with
139
158
  // what is actually displayed.
140
159
  const flatAt = snippet.toLowerCase().indexOf(query.toLowerCase())
@@ -167,7 +186,11 @@ function hostFailure(error) {
167
186
  /**
168
187
  * @param {{ logger?: { warn?: Function, info?: Function } }} deps
169
188
  */
170
- export function createHistoryService({ logger = console } = {}) {
189
+ export function createHistoryService({
190
+ logger = console,
191
+ cacheTtlMs = 15_000,
192
+ cacheLimit = 32,
193
+ } = {}) {
171
194
  let sessionQuery
172
195
  /// 会话日志快照(只给**按角色过滤**的读取用):一个会话读一次整份、本地过滤、短 TTL。
173
196
  /// 它把"每条命中一次宿主调用"那道墙去掉——灌库一个会话从十几次往返变成一次读。
@@ -179,6 +202,42 @@ export function createHistoryService({ logger = console } = {}) {
179
202
  let indexSearchable = true
180
203
  const inFlight = new Set()
181
204
  const lastSeqCache = new Map()
205
+ /// 最近读过的页(spec 016 后续):回看、回收后再水合、重复请求同一段时不再走网络。
206
+ /// 只缓存"已经结束在 before-1"的页:日志只追加,旧 seq 段的内容不会变;
207
+ /// 会话被裁剪是唯一例外,用短 TTL 兜底(默认 15s)。键里带全部影响结果的参数,
208
+ /// 少一个就可能把过滤页当成浏览页返回。
209
+ const pageCache = new Map()
210
+
211
+ /// 缓存键里带**折叠模式**(`none` / `v1` / `summary`)而不是布尔值:三种模式的回包
212
+ /// 形状不同,混用一个键会把概要页当普通页发出去。
213
+ function cacheKey({ sessionId, before, through, limit, maxBytes, roles, foldMode, coverage }) {
214
+ return [sessionId, before, through ?? '', limit, maxBytes,
215
+ roles === undefined ? '' : [...roles].sort().join(','), foldMode,
216
+ coverage === true ? '1' : '0'].join('|')
217
+ }
218
+
219
+ function cacheGet(key, now = Date.now()) {
220
+ const hit = pageCache.get(key)
221
+ if (hit === undefined) return undefined
222
+ if (now - hit.at > cacheTtlMs) {
223
+ pageCache.delete(key)
224
+ return undefined
225
+ }
226
+ // 重新插入:Map 的插入序当作 LRU 序,命中也要刷新位置。
227
+ pageCache.delete(key)
228
+ pageCache.set(key, hit)
229
+ return hit.value
230
+ }
231
+
232
+ function cachePut(key, value, now = Date.now()) {
233
+ if (cacheLimit <= 0) return
234
+ pageCache.delete(key)
235
+ pageCache.set(key, { at: now, value })
236
+ while (pageCache.size > cacheLimit) {
237
+ const oldest = pageCache.keys().next().value
238
+ pageCache.delete(oldest)
239
+ }
240
+ }
182
241
 
183
242
  /// The log's newest seq, cached briefly: it is only needed to clamp a request
184
243
  /// that runs past the end (a session can be trimmed between two pages).
@@ -347,7 +406,7 @@ export function createHistoryService({ logger = console } = {}) {
347
406
  if (!browsing && !text.toLowerCase().includes(needle)) continue
348
407
  hits.push({ seq: doc.seq, role: display, ...(query.length > 0
349
408
  ? makeSnippet(text, query)
350
- : { snippet: String(text).slice(0, 72).replace(/\s+/g, ' ').trim(),
409
+ : { snippet: truncateSafe(String(text), 72).replace(/\s+/g, ' ').trim(),
351
410
  highlightStart: 0, highlightLength: 0 }) })
352
411
  if (hits.length >= limit) {
353
412
  // 装满了:这一条已经收进本页,下一页从它的**前一条**继续——不能从整块之前
@@ -390,11 +449,60 @@ export function createHistoryService({ logger = console } = {}) {
390
449
  return Number.isSafeInteger(value) && value >= 0 ? value : undefined
391
450
  }
392
451
 
452
+ /// 概要 v2 的一页(能力 `history-fold-summary-v2`,`fold: "summary"`)。
453
+ ///
454
+ /// 为什么走**整份快照**:这一页的单位是"整轮",而一轮可以长达两千级 seq。
455
+ /// 分块回读一次只有 51 条(宿主 `readEvent` 的硬上限),一页要几十次往返;
456
+ /// 快照一次读完整份(本机 13k 事件实测 116–315 ms),之后每页零往返。
457
+ /// 快照不可用(老宿主没 `readSession`、会话超过内存护栏、读失败)时退回分块回读:
458
+ /// 语义不变(仍按整轮、仍不切半轮),只是慢一些,并且在单轮超过回读预算时
459
+ /// 如实回 `link/too-large` 让客户端重试,而不是下发半轮。
460
+ async function summaryWindowValue({ sessionId, before, through, limit, maxBytes }) {
461
+ let snapshot = await snapshots.get(sessionId)
462
+ // 与浏览翻页同一条护栏:快照必须是"这段的上界之内"的。请求上界比快照还新
463
+ // (刚发出去一条消息,或快照刚好过期一瞬)时不能拿旧快照夹上界——那会把
464
+ // 最新的一轮整段漏掉,还谎报 `clamped`("日志变短了")。改走实时读。
465
+ if (snapshot !== undefined && before - 1 > snapshot.newestSeq) snapshot = undefined
466
+ let newest = snapshot?.newestSeq
467
+ if (!Number.isInteger(newest)) newest = await newestSeq(sessionId)
468
+ let upper = Math.min(before - 1, through ?? Number.MAX_SAFE_INTEGER)
469
+ let clamped = false
470
+ if (Number.isInteger(newest) && upper > newest) {
471
+ upper = newest
472
+ clamped = true
473
+ }
474
+ const readOlder = snapshot !== undefined
475
+ ? snapshotOlderReader(snapshot)
476
+ : async (cursor) => {
477
+ const { window: result, clamped: chunkClamped } = await readChunk(sessionId, cursor)
478
+ const events = Array.isArray(result.events) ? result.events : []
479
+ const floorSeq = Number.isInteger(result.startSeq) ? result.startSeq : (events[0]?.seq ?? 0)
480
+ return { events, floorSeq, exhausted: floorSeq === 0, clamped: chunkClamped }
481
+ }
482
+ const page = await readSummaryPage({ readOlder, upper, limit, maxBytes, clamped })
483
+ if (page.error !== undefined) return page
484
+ // 字段与普通窗口同形(`startSeq`/`endSeq`/`hasMore`/`truncatedBy`/`clamped`/
485
+ // `scanBoundary`),客户端沿用同一套游标规则即可。**不带 `folds` 副本**:
486
+ // 摘要已经在记录里,再抄一份就是白白多一倍概要字节(设计 §十一 的预算是按一份算的)。
487
+ return {
488
+ records: page.records,
489
+ startSeq: page.startSeq,
490
+ endSeq: page.endSeq,
491
+ hasMore: page.hasMore,
492
+ truncatedBy: page.truncatedBy,
493
+ clamped: page.clamped === true,
494
+ scanBoundary: page.startSeq,
495
+ }
496
+ }
497
+
393
498
  async function window(args) {
394
499
  const sessionId = idOfAddress(args.address)
395
500
  const before = Number(args.before)
396
501
  if (sessionId === undefined) return failure('gateway/bad-request', 'address must name a session')
397
502
  if (!Number.isSafeInteger(before) || before < 0) return failure('gateway/bad-request', 'before must be a non-negative integer')
503
+ // `limit` 的单位随模式变:普通窗口是**记录条数**;概要 v2(`fold: "summary"`)是
504
+ // **轮数**,因为这一模式的页单位就是整轮(字节预算由 `maxBytes` 兜)。客户端按
505
+ // 自己要知道的"首屏几轮"传即可。
398
506
  const limit = Number.isSafeInteger(Number(args.limit)) && Number(args.limit) > 0 ? Number(args.limit) : DEFAULT_LIMIT
399
507
  const maxBytes = Number.isSafeInteger(Number(args.maxBytes)) && Number(args.maxBytes) > 0
400
508
  ? Number(args.maxBytes) : DEFAULT_MAX_BYTES
@@ -408,10 +516,36 @@ export function createHistoryService({ logger = console } = {}) {
408
516
  const { roles, error: rolesError } = parseRoles(args.roles)
409
517
  if (rolesError) return rolesError
410
518
 
519
+ // 折叠模式三态:`none`(不传)/ `v1`(`fold: true`)/ `summary`(`fold: "summary"`)。
520
+ // 只有精确的 `"summary"` 切到概要 v2;`fold: true` 与所有其它取值一律按老规矩走,
521
+ // 所以老客户端的回包与改动前逐字节相同(`test/helpers/fold-v1-golden.json`)。
522
+ const summaryFold = args.fold === SUMMARY_FOLD_MODE
523
+ const fold = !summaryFold && args.fold === true
524
+ const foldMode = summaryFold ? 'summary' : fold ? 'v1' : 'none'
525
+ const coverage = args.coverage === true
526
+ // 概要 v2 的语义是"整轮 + 只讲话与概要":角色过滤会切碎轮次,覆盖区间也按原始
527
+ // 记录算,两者与它拼不到一起。明确拒绝,不静默退回另一种语义(否则回包看起来
528
+ // 正常、内容却是错的)。
529
+ if (summaryFold && (roles !== undefined || coverage)) {
530
+ return failure('gateway/bad-request',
531
+ 'fold "summary" cannot be combined with roles or coverage')
532
+ }
533
+ const requestKey = cacheKey({ sessionId, before, through, limit, maxBytes, roles, foldMode, coverage })
534
+ const cachedPage = cacheGet(requestKey)
535
+ if (cachedPage !== undefined) {
536
+ return { ok: true, value: maybeCompressHistoryValue(cachedPage, args.encoding) }
537
+ }
538
+
411
539
  const key = `window:${sessionId}`
412
540
  if (inFlight.has(key)) return failure('link/busy', 'another window read is in flight for this session')
413
541
  inFlight.add(key)
414
542
  try {
543
+ if (summaryFold) {
544
+ const summary = await summaryWindowValue({ sessionId, before, through, limit, maxBytes })
545
+ if (summary.error !== undefined) return failure(summary.error.code, summary.error.message)
546
+ cachePut(requestKey, summary)
547
+ return { ok: true, value: maybeCompressHistoryValue(summary, args.encoding) }
548
+ }
415
549
  // 按角色过滤时走"先定位命中 seq、再定向取完整记录"(spec 016 的"来回次数"一条):
416
550
  // 同样一段日志,过滤路径实测从 2.5–4.1 s 降到数百毫秒;未过滤的浏览翻页保持原样
417
551
  // (它要的是精确的一段原始事件,`readEvent` 每块 51 条正合适)。
@@ -419,9 +553,21 @@ export function createHistoryService({ logger = console } = {}) {
419
553
  // 三级:① 角色过滤 + 宿主能整份读 → 从快照出(一次读、之后每页零往返);
420
554
  // ② 角色过滤但快照不可用(没有 readSession / 太大 / 读失败)→ A1 的分块定位;
421
555
  // ③ 不带角色 → 原来的分块扫描(浏览翻页要的是精确的一段原始事件)。
422
- const snapshot = roles !== undefined && typeof sessionQuery?.readSession === 'function'
423
- ? await snapshots.get(sessionId)
424
- : undefined
556
+ // 未过滤的浏览翻页也走整份快照:本机 13k 会话一次 `readSession` 约 0.3s,
557
+ // 而逐页 `readEvent` 每页都要付一次宿主地板价(实测 300–500ms/页)。
558
+ // 第一次请求仍走实时读(首屏不能等快照),同时在后台预热;之后的更早页命中快照。
559
+ // 只有"请求上界落在快照最新 seq 之内"才用快照——更新的那段必须实时读,
560
+ // 否则刚发出去的消息会被旧快照挡住。
561
+ let snapshot
562
+ if (roles !== undefined) {
563
+ snapshot = typeof sessionQuery?.readSession === 'function'
564
+ ? await snapshots.get(sessionId)
565
+ : undefined
566
+ } else {
567
+ snapshot = snapshots.peek(sessionId)
568
+ if (snapshot === undefined) snapshots.warm(sessionId)
569
+ if (snapshot !== undefined && before - 1 > snapshot.newestSeq) snapshot = undefined
570
+ }
425
571
  const scan = snapshot !== undefined
426
572
  ? scanSnapshot(snapshot, { before, through, limit, maxBytes, roles, roleOf })
427
573
  : useFilterScan
@@ -433,7 +579,23 @@ export function createHistoryService({ logger = console } = {}) {
433
579
  const page = pageFrom(scan, { limit, maxBytes, roles })
434
580
  // 016:下发前统一清掉只用于诊断的宿主模型输出日志(口径只有一份,
435
581
  // 见 specs/016-history-load-perf/contracts/history-record-trim.md)。
436
- return { ok: true, value: { ...page, records: trimRecordsForWire(page.records) } }
582
+ const trimmed = trimRecordsForWire(page.records)
583
+ const folded = fold ? foldProcessRuns(trimmed) : { records: trimmed, folds: [] }
584
+ const value = { ...page, records: folded.records }
585
+ // 已知为空:只覆盖"最老返回记录..窗口上界"。版本取会话最新号;
586
+ // 客户端版本不一致时不采纳(宿主重排号段后旧结论失效)。
587
+ if (coverage) {
588
+ const upper = before - 1
589
+ const lower = Math.max(1, before - limit)
590
+ value.knownEmpty = emptySpans(trimmed, lower, upper)
591
+ value.revision = String(await newestSeq(sessionId))
592
+ }
593
+ // `folds` 只在折叠时出现:老客户端不认识这个字段也不会读它;新客户端没有它
594
+ // 就按普通记录折(老连接器)。
595
+ if (folded.folds.length > 0) value.folds = folded.folds
596
+ cachePut(requestKey, value)
597
+ // 压缩只改回包形态,不改分页语义:解压后逐字段等于不压缩的那份。
598
+ return { ok: true, value: maybeCompressHistoryValue(value, args.encoding) }
437
599
  } catch (error) {
438
600
  logger.warn?.(`mobile-link: sessionWindow failed: ${messageOf(error)}`)
439
601
  return hostFailure(error)
package/lib/index.js CHANGED
@@ -4,8 +4,8 @@
4
4
  * Scope (deliberately tiny so a DSH upgrade can never make it fatal):
5
5
  * 1. Run the DLP agent: dial the relay over WSS, serve the iOS client's
6
6
  * session/event traffic from the local DSH instance. On a computer that has
7
- * never been registered, redeem the invite code from the environment (or
8
- * the config) first, so a fresh install becomes usable without the relay
7
+ * never been registered, enroll with the relay address first (open
8
+ * enrollment), so a fresh install becomes usable without the relay
9
9
  * operator provisioning anything by hand.
10
10
  * 2. Two routes under `/mobile-link` behind DSH's own connection fence:
11
11
  * GET /status — is the link up, which devices are attached, and is
@@ -43,6 +43,10 @@ const DEFAULTS = {
43
43
  relayUrl: process.env.DSH_RELAY_URL
44
44
  || process.env.DSH_MOBILE_LINK_RELAY
45
45
  || 'wss://relay.example.com/dsh-link',
46
+ // 面板的默认中转:只用于预填,不参与拨号优先级。它和 `relayUrl` 分开,是因为
47
+ // 把地址钉进 `relayUrl` 会覆盖身份文件里真正登记过的地址;这里只给表单一个
48
+ // 可修改的初值。
49
+ defaultRelayUrl: process.env.DSH_DEFAULT_RELAY_URL || '',
46
50
  agentId: '',
47
51
  agentSecret: '',
48
52
  stateFile: '',
@@ -56,6 +60,10 @@ const DEFAULTS = {
56
60
  // 是否接收手机推上来的阶段记录(`_link/diagnostics`)。false = 回 diagnostics/disabled,
57
61
  // 手机静默忽略——这是"记录出问题时的回退闸",关掉不影响连接。
58
62
  diagnostics: true,
63
+ // 是否接收手机推上来的运行日志(`_link/logs`),落盘目录见 logs.js。
64
+ // 关掉后手机收到的同样是明确拒绝,不会影响连接。
65
+ logs: true,
66
+ logsFile: '',
59
67
  heartbeatMs: 20000,
60
68
  pongTimeoutMs: 60000,
61
69
  maxBackoffMs: 30000,
@@ -97,6 +105,9 @@ function buildAgent(config, logger) {
97
105
  return new MobileLinkAgent({
98
106
  enabled: config.enabled !== false,
99
107
  relayUrl: typeof config.relayUrl === 'string' ? config.relayUrl : DEFAULTS.relayUrl,
108
+ defaultRelayUrl: typeof config.defaultRelayUrl === 'string'
109
+ ? config.defaultRelayUrl
110
+ : DEFAULTS.defaultRelayUrl,
100
111
  agentId: typeof config.agentId === 'string' ? config.agentId : '',
101
112
  agentSecret: typeof config.agentSecret === 'string' ? config.agentSecret : '',
102
113
  stateFile: typeof config.stateFile === 'string' && config.stateFile ? config.stateFile : undefined,
@@ -112,6 +123,10 @@ function buildAgent(config, logger) {
112
123
  ? DEFAULTS.slowRequestMs
113
124
  : Number(config.slowRequestMs) || 0,
114
125
  diagnostics: config.diagnostics !== false,
126
+ // 设备日志落盘(见 lib/logs.js):`logsFile` 换根目录,`logs: false` 关掉接收。
127
+ // 保留期固定 7 天,不做配置项——清理在写入路径上,配错就是悄悄留不住或悄悄写满。
128
+ logs: config.logs !== false,
129
+ logsRoot: typeof config.logsFile === 'string' && config.logsFile ? config.logsFile : undefined,
115
130
  heartbeatMs: Number(config.heartbeatMs) || DEFAULTS.heartbeatMs,
116
131
  pongTimeoutMs: Number(config.pongTimeoutMs) || DEFAULTS.pongTimeoutMs,
117
132
  maxBackoffMs: Number(config.maxBackoffMs) || DEFAULTS.maxBackoffMs,
@@ -190,6 +205,26 @@ function wireSessionQuery(ctx, history, logger) {
190
205
  }
191
206
  }
192
207
 
208
+ /**
209
+ * 把宿主的任务与活会话服务交给活动名单(**可选注入**,spec 018)。
210
+ *
211
+ * 两个服务都到位才回答 `_link/jobRoster`;缺一个就保持"不支持",手机整块不显示
212
+ * (缺一个却回空名单,等于把降级说成"没有任务在跑")。
213
+ * 注入可能晚于第一台设备连接——roster 在绑定后会补一次订阅,见 `jobs-roster.js`。
214
+ */
215
+ function wireJobsServices(ctx, agent, logger) {
216
+ try {
217
+ ctx.inject(['jobs', 'sessions'], (scope) => {
218
+ agent.setJobsServices?.({ jobs: scope.jobs, sessions: scope.sessions })
219
+ // 服务卸载(宿主换代/插件禁用)时同步摘掉宿主任务事件订阅。
220
+ scope.effect?.(() => () => agent.setJobsServices?.({}), 'mobile-link: jobs roster')
221
+ logger.info?.('mobile-link: activity roster bound to ctx.jobs / ctx.sessions')
222
+ })
223
+ } catch (error) {
224
+ logger.warn?.(`mobile-link: jobs service unavailable (activity roster falls back): ${error}`)
225
+ }
226
+ }
227
+
193
228
  /**
194
229
  * @param {import('@deepseek-ai/cordis').Context} ctx
195
230
  * @param {Record<string, unknown>} rawConfig
@@ -205,6 +240,7 @@ export function apply(ctx, rawConfig) {
205
240
  registerRoutes(ctx, agent, logger, config, history)
206
241
  wireEndpointResolver(ctx, agent, logger)
207
242
  wireSessionQuery(ctx, history, logger)
243
+ wireJobsServices(ctx, agent, logger)
208
244
  logger.info?.(`mobile-link: ready (relay ${config.relayUrl})`)
209
245
  }
210
246