dsh-plugin-mobile-link 0.6.5 → 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/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`).
@@ -46,6 +54,21 @@ export const FIND_METHOD = '_link/sessionFind'
46
54
  /// does not read the field keeps the old cursor rule.
47
55
  export const HISTORY_CAPABILITIES = [
48
56
  'history-window', 'session-search', 'history-role-filter', 'history-scan-boundary',
57
+ // search-progress: a scanned page also carries `totalChunks` / `remainingChunks`,
58
+ // so the reader can be told "已扫 k/N 片" from the first page on. Purely additive:
59
+ // `totalChunks` comes from the log's newest seq, which a scan already fetches.
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,
49
72
  ]
50
73
 
51
74
  export function isHistoryMethod(method) {
@@ -59,7 +82,15 @@ export function isHistoryMethod(method) {
59
82
  export const SEARCH_CHUNK = 2000
60
83
  export const MAX_SEARCH_CHUNKS_PER_CALL = 4
61
84
  export const DEFAULT_SEARCH_LIMIT = 50
62
- export const MIN_QUERY_LENGTH = 2
85
+ /// 查询下限:**1 个字符**(owner 2026-10-17 决定)。
86
+ ///
87
+ /// 原来卡在 2 个字,理由是「一个字几乎命中整段历史、翻不完」。这个代价在 2026-10-17
88
+ /// 变成可接受:搜索有了分页与片数进度(`search-progress`,界面显示「已扫 k/N 片」),
89
+ /// 一页仍只回 50 条、一次调用仍最多扫 4 块(≈1.3 秒),所以下限降到 1。
90
+ ///
91
+ /// 仍然拒绝的是**空关键词且没带 roles**:那不是搜索,是一次没有范围的全表扫描
92
+ /// (「浏览我的提问」必须带 roles,见 `browsing`)。
93
+ export const MIN_QUERY_LENGTH = 1
63
94
 
64
95
  function idOfAddress(address) {
65
96
  if (address === null || typeof address !== 'object') return undefined
@@ -86,6 +117,29 @@ function parseRoles(value) {
86
117
  return { roles }
87
118
  }
88
119
 
120
+ /// 一条文档里所有可读正文:宿主给的 `text`、`data.text`、`data.content[].text`
121
+ /// (blocks 形态,可能**不止一段**)、`data.message.content[].text`,以及工具结果的
122
+ /// `output` / `result` / `stdout` / `stderr`。
123
+ ///
124
+ /// 为什么自己做匹配、不把 `{kind:'text'}` 交给宿主:宿主的文本过滤只看它自己认定的那一段,
125
+ /// 2026-10-17 真机实测(搜"我"/"的"拿不到全部用户输入)说明会漏掉 `content` 里靠后的部分。
126
+ /// 改成"取回这一段全部文档、在这里逐字匹配"之后,命中集合只由我们自己的规则决定。
127
+ function docText(doc) {
128
+ const data = doc?.data ?? {}
129
+ const parts = []
130
+ const push = (value) => {
131
+ if (typeof value === 'string' && value.length > 0) parts.push(value)
132
+ }
133
+ push(doc?.text)
134
+ push(data.text)
135
+ for (const blocks of [data.content, data.message?.content]) {
136
+ if (!Array.isArray(blocks)) continue
137
+ for (const block of blocks) push(block?.text)
138
+ }
139
+ for (const key of ['output', 'result', 'stdout', 'stderr']) push(data[key])
140
+ return parts.join('\n')
141
+ }
142
+
89
143
  /// A hit snippet with the highlight offsets the phone's list already renders.
90
144
  ///
91
145
  /// Offsets, not ranges: the value crosses the wire and a character offset stays
@@ -94,12 +148,12 @@ function makeSnippet(text, query, radius = 36) {
94
148
  const haystack = String(text ?? '')
95
149
  const at = haystack.toLowerCase().indexOf(query.toLowerCase())
96
150
  if (at < 0) {
97
- const head = haystack.slice(0, radius * 2).replace(/\s+/g, ' ').trim()
151
+ const head = truncateSafe(haystack, radius * 2).replace(/\s+/g, ' ').trim()
98
152
  return { snippet: head, highlightStart: 0, highlightLength: 0 }
99
153
  }
100
154
  const start = Math.max(0, at - radius)
101
155
  const end = Math.min(haystack.length, at + query.length + radius)
102
- const snippet = haystack.slice(start, end).replace(/\s+/g, ' ').trim()
156
+ const snippet = sliceRangeSafe(haystack, start, end).replace(/\s+/g, ' ').trim()
103
157
  // Re-find the match inside the flattened snippet so the offsets line up with
104
158
  // what is actually displayed.
105
159
  const flatAt = snippet.toLowerCase().indexOf(query.toLowerCase())
@@ -132,7 +186,11 @@ function hostFailure(error) {
132
186
  /**
133
187
  * @param {{ logger?: { warn?: Function, info?: Function } }} deps
134
188
  */
135
- export function createHistoryService({ logger = console } = {}) {
189
+ export function createHistoryService({
190
+ logger = console,
191
+ cacheTtlMs = 15_000,
192
+ cacheLimit = 32,
193
+ } = {}) {
136
194
  let sessionQuery
137
195
  /// 会话日志快照(只给**按角色过滤**的读取用):一个会话读一次整份、本地过滤、短 TTL。
138
196
  /// 它把"每条命中一次宿主调用"那道墙去掉——灌库一个会话从十几次往返变成一次读。
@@ -144,6 +202,42 @@ export function createHistoryService({ logger = console } = {}) {
144
202
  let indexSearchable = true
145
203
  const inFlight = new Set()
146
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
+ }
147
241
 
148
242
  /// The log's newest seq, cached briefly: it is only needed to clamp a request
149
243
  /// that runs past the end (a session can be trimmed between two pages).
@@ -217,7 +311,8 @@ export function createHistoryService({ logger = console } = {}) {
217
311
  // 这时不按文本过滤,只按 seq 段扫,按角色筛。
218
312
  const browsing = query.length === 0 && roles !== undefined
219
313
  if (!browsing && query.length < MIN_QUERY_LENGTH) {
220
- return failure('link/query-too-short', `query must be at least ${MIN_QUERY_LENGTH} characters`)
314
+ return failure('link/query-too-short',
315
+ `query must be at least ${MIN_QUERY_LENGTH} character (or pass roles to browse)`)
221
316
  }
222
317
  const limit = Number.isSafeInteger(Number(args.limit)) && Number(args.limit) > 0
223
318
  ? Math.min(Number(args.limit), 200) : DEFAULT_SEARCH_LIMIT
@@ -261,15 +356,25 @@ export function createHistoryService({ logger = console } = {}) {
261
356
  logger.info?.(`mobile-link: full-text index unavailable, scanning instead: ${messageOf(error)}`)
262
357
  }
263
358
  }
359
+ const needle = query.toLowerCase()
264
360
  const from = parseSearchCursor(args.cursor)
265
- const ceiling = from !== undefined ? from : await newestSeq(sessionId)
361
+ // `newestSeq` also carries the progress denominator below; it is cached for
362
+ // 5s, and a scan would have fetched it anyway on the first page.
363
+ const newest = await newestSeq(sessionId)
364
+ // 游标是上一页留下的 seq,可能**越过**当前最新(日志被裁剪过、或手机拿着旧游标回来)。
365
+ // 越界时不夹住的话,扫描会从一个不存在的 seq 往下空跑满 4 块,`remainingChunks`
366
+ // 还会算出比 `totalChunks` 更大的数(实测 996/2),界面的"已扫 k/N"随即变成假的。
367
+ const cursorSeq = from !== undefined && newest >= 0 ? Math.min(from, newest) : from
368
+ const ceiling = cursorSeq !== undefined ? cursorSeq : newest
369
+ // 片数(进度的分母):由日志最新 seq 算出,与游标无关,所以第一页就是精确的。
370
+ const total = newest >= 0 ? Math.floor(newest / chunk) + 1 : 0
266
371
  const hits = []
267
372
  let to = ceiling
268
373
  let scanned = 0
269
374
  while (to >= 0 && hits.length < limit && scanned < MAX_SEARCH_CHUNKS_PER_CALL) {
270
375
  const chunkFrom = Math.max(0, to - chunk + 1)
376
+ // 只按 seq 段取文档;正文匹配在下面自己做(见 `docText` 的头注)。
271
377
  const filters = [{ kind: 'seq', from: chunkFrom, to }]
272
- if (!browsing) filters.push({ kind: 'text', text: query })
273
378
  const docs = await withTimeout(
274
379
  sessionQuery.filterEvents(sessionId, filters),
275
380
  HOST_CALL_TIMEOUT_MS,
@@ -278,6 +383,12 @@ export function createHistoryService({ logger = console } = {}) {
278
383
  scanned += 1
279
384
  // Newest first inside the chunk: the reader asked about something recent
280
385
  // far more often than about something old.
386
+ //
387
+ // **游标必须落在"这一页读到哪儿"**,不能一律跳到整块之前:一页在块中途装满时,
388
+ // 块尾还没读过的文档会被永久跳过——2026-10-17 owner 报的"全局搜索不是用户提问的超集"
389
+ // 就是这条(用户提问正好落在片尾时被吞掉;索引路径不受影响)。
390
+ // `resumeFrom` 默认 = 整块之前(读完这块才成立),中途停下时改成"下一条还没读的 seq"。
391
+ let resumeFrom = chunkFrom - 1
281
392
  for (let index = docs.length - 1; index >= 0; index -= 1) {
282
393
  const doc = docs[index]
283
394
  // **已知局限**:这里只能按文档判角色,而宿主的 `filterEvents` 文档不带 `data.source`,
@@ -291,13 +402,20 @@ export function createHistoryService({ logger = console } = {}) {
291
402
  if (roles !== undefined && (role === undefined || !roles.has(role))) continue
292
403
  const display = displayRoleOf(doc)
293
404
  if (display === undefined) continue
405
+ const text = docText(doc)
406
+ if (!browsing && !text.toLowerCase().includes(needle)) continue
294
407
  hits.push({ seq: doc.seq, role: display, ...(query.length > 0
295
- ? makeSnippet(doc.text, query)
296
- : { snippet: String(doc.text ?? '').slice(0, 72).replace(/\s+/g, ' ').trim(),
408
+ ? makeSnippet(text, query)
409
+ : { snippet: truncateSafe(String(text), 72).replace(/\s+/g, ' ').trim(),
297
410
  highlightStart: 0, highlightLength: 0 }) })
298
- if (hits.length >= limit) break
411
+ if (hits.length >= limit) {
412
+ // 装满了:这一条已经收进本页,下一页从它的**前一条**继续——不能从整块之前
413
+ // (会丢块尾),也不能从这一条本身(会重复)。
414
+ resumeFrom = doc.seq - 1
415
+ break
416
+ }
299
417
  }
300
- to = chunkFrom - 1
418
+ to = resumeFrom
301
419
  }
302
420
  const hasMore = to >= 0 && hits.length >= limit ? true : to >= 0
303
421
  return {
@@ -307,6 +425,12 @@ export function createHistoryService({ logger = console } = {}) {
307
425
  cursor: hasMore ? String(to) : null,
308
426
  hasMore,
309
427
  scannedChunks: scanned,
428
+ // 进度:片数是**扫描单位**(一段 `chunk` 个 seq,从新往旧),不是命中页数。
429
+ // `totalChunks` 由日志最新 seq 算出,所以从第一页起就是精确的;命中的**条数**
430
+ // 仍然要扫到最早那一片才定稿(不开宿主全文索引的必然结果)。
431
+ totalChunks: total,
432
+ // 剩余片数不可能多于总片数:越界游标、被裁剪的日志都不该让进度超过 100%。
433
+ remainingChunks: to >= 0 ? Math.min(Math.floor(to / chunk) + 1, total) : 0,
310
434
  source: 'scan',
311
435
  },
312
436
  }
@@ -325,11 +449,60 @@ export function createHistoryService({ logger = console } = {}) {
325
449
  return Number.isSafeInteger(value) && value >= 0 ? value : undefined
326
450
  }
327
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
+
328
498
  async function window(args) {
329
499
  const sessionId = idOfAddress(args.address)
330
500
  const before = Number(args.before)
331
501
  if (sessionId === undefined) return failure('gateway/bad-request', 'address must name a session')
332
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
+ // 自己要知道的"首屏几轮"传即可。
333
506
  const limit = Number.isSafeInteger(Number(args.limit)) && Number(args.limit) > 0 ? Number(args.limit) : DEFAULT_LIMIT
334
507
  const maxBytes = Number.isSafeInteger(Number(args.maxBytes)) && Number(args.maxBytes) > 0
335
508
  ? Number(args.maxBytes) : DEFAULT_MAX_BYTES
@@ -343,10 +516,36 @@ export function createHistoryService({ logger = console } = {}) {
343
516
  const { roles, error: rolesError } = parseRoles(args.roles)
344
517
  if (rolesError) return rolesError
345
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
+
346
539
  const key = `window:${sessionId}`
347
540
  if (inFlight.has(key)) return failure('link/busy', 'another window read is in flight for this session')
348
541
  inFlight.add(key)
349
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
+ }
350
549
  // 按角色过滤时走"先定位命中 seq、再定向取完整记录"(spec 016 的"来回次数"一条):
351
550
  // 同样一段日志,过滤路径实测从 2.5–4.1 s 降到数百毫秒;未过滤的浏览翻页保持原样
352
551
  // (它要的是精确的一段原始事件,`readEvent` 每块 51 条正合适)。
@@ -354,9 +553,21 @@ export function createHistoryService({ logger = console } = {}) {
354
553
  // 三级:① 角色过滤 + 宿主能整份读 → 从快照出(一次读、之后每页零往返);
355
554
  // ② 角色过滤但快照不可用(没有 readSession / 太大 / 读失败)→ A1 的分块定位;
356
555
  // ③ 不带角色 → 原来的分块扫描(浏览翻页要的是精确的一段原始事件)。
357
- const snapshot = roles !== undefined && typeof sessionQuery?.readSession === 'function'
358
- ? await snapshots.get(sessionId)
359
- : 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
+ }
360
571
  const scan = snapshot !== undefined
361
572
  ? scanSnapshot(snapshot, { before, through, limit, maxBytes, roles, roleOf })
362
573
  : useFilterScan
@@ -368,7 +579,23 @@ export function createHistoryService({ logger = console } = {}) {
368
579
  const page = pageFrom(scan, { limit, maxBytes, roles })
369
580
  // 016:下发前统一清掉只用于诊断的宿主模型输出日志(口径只有一份,
370
581
  // 见 specs/016-history-load-perf/contracts/history-record-trim.md)。
371
- 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) }
372
599
  } catch (error) {
373
600
  logger.warn?.(`mobile-link: sessionWindow failed: ${messageOf(error)}`)
374
601
  return hostFailure(error)
package/lib/index.js CHANGED
@@ -56,6 +56,10 @@ const DEFAULTS = {
56
56
  // 是否接收手机推上来的阶段记录(`_link/diagnostics`)。false = 回 diagnostics/disabled,
57
57
  // 手机静默忽略——这是"记录出问题时的回退闸",关掉不影响连接。
58
58
  diagnostics: true,
59
+ // 是否接收手机推上来的运行日志(`_link/logs`),落盘目录见 logs.js。
60
+ // 关掉后手机收到的同样是明确拒绝,不会影响连接。
61
+ logs: true,
62
+ logsFile: '',
59
63
  heartbeatMs: 20000,
60
64
  pongTimeoutMs: 60000,
61
65
  maxBackoffMs: 30000,
@@ -112,6 +116,10 @@ function buildAgent(config, logger) {
112
116
  ? DEFAULTS.slowRequestMs
113
117
  : Number(config.slowRequestMs) || 0,
114
118
  diagnostics: config.diagnostics !== false,
119
+ // 设备日志落盘(见 lib/logs.js):`logsFile` 换根目录,`logs: false` 关掉接收。
120
+ // 保留期固定 7 天,不做配置项——清理在写入路径上,配错就是悄悄留不住或悄悄写满。
121
+ logs: config.logs !== false,
122
+ logsRoot: typeof config.logsFile === 'string' && config.logsFile ? config.logsFile : undefined,
115
123
  heartbeatMs: Number(config.heartbeatMs) || DEFAULTS.heartbeatMs,
116
124
  pongTimeoutMs: Number(config.pongTimeoutMs) || DEFAULTS.pongTimeoutMs,
117
125
  maxBackoffMs: Number(config.maxBackoffMs) || DEFAULTS.maxBackoffMs,
@@ -190,6 +198,26 @@ function wireSessionQuery(ctx, history, logger) {
190
198
  }
191
199
  }
192
200
 
201
+ /**
202
+ * 把宿主的任务与活会话服务交给活动名单(**可选注入**,spec 018)。
203
+ *
204
+ * 两个服务都到位才回答 `_link/jobRoster`;缺一个就保持"不支持",手机整块不显示
205
+ * (缺一个却回空名单,等于把降级说成"没有任务在跑")。
206
+ * 注入可能晚于第一台设备连接——roster 在绑定后会补一次订阅,见 `jobs-roster.js`。
207
+ */
208
+ function wireJobsServices(ctx, agent, logger) {
209
+ try {
210
+ ctx.inject(['jobs', 'sessions'], (scope) => {
211
+ agent.setJobsServices?.({ jobs: scope.jobs, sessions: scope.sessions })
212
+ // 服务卸载(宿主换代/插件禁用)时同步摘掉宿主任务事件订阅。
213
+ scope.effect?.(() => () => agent.setJobsServices?.({}), 'mobile-link: jobs roster')
214
+ logger.info?.('mobile-link: activity roster bound to ctx.jobs / ctx.sessions')
215
+ })
216
+ } catch (error) {
217
+ logger.warn?.(`mobile-link: jobs service unavailable (activity roster falls back): ${error}`)
218
+ }
219
+ }
220
+
193
221
  /**
194
222
  * @param {import('@deepseek-ai/cordis').Context} ctx
195
223
  * @param {Record<string, unknown>} rawConfig
@@ -205,6 +233,7 @@ export function apply(ctx, rawConfig) {
205
233
  registerRoutes(ctx, agent, logger, config, history)
206
234
  wireEndpointResolver(ctx, agent, logger)
207
235
  wireSessionQuery(ctx, history, logger)
236
+ wireJobsServices(ctx, agent, logger)
208
237
  logger.info?.(`mobile-link: ready (relay ${config.relayUrl})`)
209
238
  }
210
239