dsh-plugin-mobile-link 0.6.0 → 0.6.6

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.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `_link/sessionWindow` 的**角色过滤**扫描(spec 016 的"来回次数"一条)。
3
+ *
4
+ * 为什么单独一条路径:未过滤的浏览翻页要的是"按 seq 精确的一段原始事件",`readEvent`
5
+ * 一次 51 条正合适;但**按角色过滤**("只看我的提问"灌库、只看工具/助手)时,命中往往很稀疏,
6
+ * 用 `readEvent` 就是"扫 12 块、每块 51 条、回 1 条"——实测一页 2.5–4.1 秒,而且随会话变长而变慢。
7
+ *
8
+ * 宿主另有一个按 seq 段过滤的接口(`sessionQuery.filterEvents`),**一次能覆盖 2000 个 seq**
9
+ * (实测 420–500 ms,约 0.22 ms/seq,而 `readEvent` 是 4.4 ms/seq),返回的是**消息类文档**
10
+ * (`user/message`、`assistant/message`、`tool/call`、`tool/result` 等),带 `seq` 与 `type`、
11
+ * `data.source`——足够判断角色。所以:
12
+ *
13
+ * 1. 用 `filterEvents` 按 2000 seq 的块从新到旧定位**命中的 seq**;
14
+ * 2. 把命中 seq 合并成 ≤51 宽的簇,**只对命中的那几簇** `readEvent` 取完整记录。
15
+ *
16
+ * 两点必须守住:
17
+ * - **对外语义不变**:本模块返回的形状与 `scanOlder` 完全一致(`events` / `truncatedBy` /
18
+ * `clamped` / `exhausted` / `scannedFloor`),裁剪与分页仍由 `pageFrom` 一处决定。
19
+ * - **`scanBoundary` 的承诺不破**:没取回来的命中必须能被下一页重新扫到,所以提前收工时
20
+ * `scannedFloor` 落在"真正取到的那一段"的最老 seq 上(见下)。
21
+ */
22
+
23
+ import { MAX_CHUNKS_PER_CALL } from './records.js'
24
+
25
+ /// 一个 `filterEvents` 块覆盖多少 seq。实测 2000 seq ≈ 420–500 ms(含宿主侧过滤)。
26
+ export const FILTER_CHUNK = 2000
27
+
28
+ /// 一次调用最多扫几个 `filterEvents` 块:6 × 2000 = 12000 seq。
29
+ /// 取这个数是因为一块 ~0.5 s,六块 ~3 s,仍在宿主调用 8 s 预算内,且远小于一次超时。
30
+ export const MAX_FILTER_CHUNKS_PER_CALL = 6
31
+
32
+ /// 一次调用最多做几次"定向取完整记录"。命中稀疏时簇会多,这里封顶免得一次调用打太多来回。
33
+ export const MAX_FILTER_READS_PER_CALL = 8
34
+
35
+ /// `readEvent` 的 `before` 上限(宿主硬约束),一个簇因此最多覆盖 51 个 seq。
36
+ const READ_SPAN = MAX_CHUNKS_PER_CALL === 12 ? 50 : 50
37
+
38
+ /**
39
+ * 把命中 seq 合并成"一次 `readEvent` 能覆盖"的簇。
40
+ *
41
+ * 升序返回 `[{from, to}]`,每个簇宽度 ≤ 51(`to - from ≤ 50`),
42
+ * 这样 `readEvent({seq: to, before: 50})` 一定涵盖 `[from, to]`。
43
+ */
44
+ export function clusterSeqs(seqs) {
45
+ const sorted = [...new Set(seqs)].sort((a, b) => a - b)
46
+ const clusters = []
47
+ for (const seq of sorted) {
48
+ const last = clusters[clusters.length - 1]
49
+ if (last !== undefined && seq - last.from <= READ_SPAN) last.to = seq
50
+ else clusters.push({ from: seq, to: seq })
51
+ }
52
+ return clusters
53
+ }
54
+
55
+ /**
56
+ * 角色过滤扫描。与 `scanOlder` 同形返回。
57
+ *
58
+ * @param {object} input
59
+ * @param {(sessionId: string, from: number, to: number) => Promise<{docs: Array, clamped?: boolean}>} input.readFilterChunk
60
+ * @param {(sessionId: string, seq: number) => Promise<{window: {events: Array}, clamped?: boolean}>} input.readRecords
61
+ * @param {string} input.sessionId
62
+ * @param {number} input.before 最新边界(不含)
63
+ * @param {number|undefined} input.through 下界(含)
64
+ * @param {number} input.limit 目标记录条数
65
+ * @param {Set<string>} input.roles 角色白名单
66
+ * @param {(doc: unknown) => string|undefined} input.roleOf 角色判定(与原始事件同一份口径)
67
+ *
68
+ * 字节预算不在这里裁:命中记录要取回来才知道大小,统一交给 `pageFrom`(它同时负责
69
+ * `truncatedBy='bytes'` 与 `scanBoundary` 的收口,与未过滤路径共用同一处规则)。
70
+ */
71
+ export async function filteredScan({
72
+ readFilterChunk, readRecords, newestSeq, sessionId, before, through, limit, roles, roleOf,
73
+ }) {
74
+ let cursor = Math.min(before - 1, through ?? Number.MAX_SAFE_INTEGER)
75
+ const matched = []
76
+ let truncatedBy = null
77
+ let clamped = false
78
+ let exhausted = false
79
+ let scannedFloor = null
80
+
81
+ // **必须自己 clamp**:`filterEvents` 对越界的 seq 段**不报错、只回空数组**(`readEvent` 会抛
82
+ // "has no event at seq")。不 clamp 的后果实测过:某个日志只到 1208 的会话,App 传来的游标是
83
+ // 投影的 asOfSeq(101473),新路径于是扫了六段空区间、返回空页并把 `scanBoundary` 推到 89474,
84
+ // 调用方从这里往回读会**整段跳过**真实历史。`newestSeq` 与旧路径共用(会话内 5 秒缓存)。
85
+ if (typeof newestSeq === 'function') {
86
+ const newest = await newestSeq(sessionId)
87
+ if (Number.isInteger(newest) && newest >= 0 && cursor > newest) {
88
+ cursor = newest
89
+ clamped = true
90
+ }
91
+ }
92
+
93
+ if (cursor < 0) return { events: [], truncatedBy: null, clamped, exhausted: true, scannedFloor: 0 }
94
+
95
+ for (let chunk = 0; chunk < MAX_FILTER_CHUNKS_PER_CALL && cursor >= 0; chunk += 1) {
96
+ const from = Math.max(0, cursor - FILTER_CHUNK + 1)
97
+ const { docs = [], clamped: thisClamped } = await readFilterChunk(sessionId, from, cursor)
98
+ clamped = clamped || thisClamped === true
99
+ scannedFloor = scannedFloor === null ? from : Math.min(scannedFloor, from)
100
+ // 文档升序;从后往前插到队首,保持升序(与 scanOlder 同一手法)。
101
+ for (let index = docs.length - 1; index >= 0; index -= 1) {
102
+ const doc = docs[index]
103
+ if (!Number.isInteger(doc?.seq) || doc.seq < from || doc.seq > cursor) continue
104
+ if (!roles.has(roleOf(doc))) continue
105
+ matched.unshift(doc)
106
+ }
107
+ if (matched.length >= limit) { truncatedBy = 'count'; break }
108
+ const next = from - 1
109
+ if (next < 0) { exhausted = true; break }
110
+ cursor = next
111
+ }
112
+
113
+ // 命中 seq → 簇 → 只取命中那几簇的完整记录。簇太多时**只取最新的一段**,
114
+ // 并把 `scannedFloor` 收到这一段的最老 seq:下一页会从这里往回扫,丢掉的命中还能被重新找到
115
+ // (`pageFrom` 对过滤页用的正是 `scan.scannedFloor`)。
116
+ const all = clusterSeqs(matched.map((doc) => doc.seq))
117
+ const kept = all.slice(Math.max(0, all.length - MAX_FILTER_READS_PER_CALL))
118
+ const wanted = new Set(matched.map((doc) => doc.seq))
119
+ const events = []
120
+ for (const cluster of kept) {
121
+ const { window: result, clamped: thisClamped } = await readRecords(sessionId, cluster.to)
122
+ clamped = clamped || thisClamped === true
123
+ for (const event of Array.isArray(result?.events) ? result.events : []) {
124
+ // **必须同时卡簇边界**:相邻簇的 `readEvent` 窗口会重叠(`before: 50` 往前多取 50 个 seq),
125
+ // 只按 `wanted` 过滤会把上一个簇里的命中重复收进来(实测 48 号记录进了两页,两页并集少一条)。
126
+ if (event?.seq < cluster.from || event?.seq > cluster.to || !wanted.has(event.seq)) continue
127
+ // **角色必须用原始事件复判一次**,不能信文档的判定:宿主的 `filterEvents` 文档不带
128
+ // `data.source`(实测:`source.kind = "agent-instructions"` 的注入消息在 doc 路径下被判成
129
+ // `me`),而 `roleOf` 正是靠 `data.source` 才能把注入消息排除掉——spec 009 修过的那个
130
+ // "把 plugin 注入消息错算成提问"的缺陷(实测占比 24%)会由 doc 判定重新引入。
131
+ // 文档在这里只是"定位用的索引",命中与否以原始事件为准。
132
+ if (roles !== undefined && !roles.has(roleOf(event))) continue
133
+ events.push(event)
134
+ }
135
+ }
136
+ events.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
137
+
138
+ if (all.length > kept.length) {
139
+ truncatedBy = truncatedBy ?? 'count'
140
+ exhausted = false
141
+ scannedFloor = kept.length > 0 ? kept[0].from : scannedFloor
142
+ }
143
+
144
+ return { events, truncatedBy, clamped, exhausted, scannedFloor }
145
+ }
@@ -47,6 +47,34 @@ export function toRecord(event) {
47
47
  return { type: 'event', event }
48
48
  }
49
49
 
50
+ /// 去掉一条记录里**只用于诊断、渲染不读**的宿主模型输出日志。
51
+ ///
52
+ /// 口径只有一份,写在这里由三条回包路径共用(精确窗口 / 搜索 / 旧分页),
53
+ /// 见 `specs/016-history-load-perf/contracts/history-record-trim.md`。
54
+ ///
55
+ /// 为什么删:实测该字段占样本会话报文约 60%、占 assistant 记录 75.7%、
56
+ /// 占手机侧解码耗时约 94%(`specs/016-history-load-perf/research.md` §0),
57
+ /// 而产品路径没有任何消费者(`DSHKit` 里只有一句"不能拿它当流式判据"的注释)。
58
+ ///
59
+ /// 两条纪律:
60
+ /// - **不就地改写**:宿主回包可能仍被别处引用(测试与探针都会再看一眼),
61
+ /// 所以带该键时产生新的 `data` 对象,不带时按原对象返回(不复制、不白花时间)。
62
+ /// - **只删这一个键**:`message` / `usage` / `meta` / `arguments` 等一律原样,
63
+ /// 删它们就是丢屏幕上要显示的内容。
64
+ export function trimRecordForWire(record) {
65
+ const event = record?.event
66
+ const data = event?.data
67
+ if (data === null || typeof data !== 'object' || !('stream' in data)) return record
68
+ const { stream: _dropped, ...rest } = data
69
+ return { ...record, event: { ...event, data: rest } }
70
+ }
71
+
72
+ /// 一整页记录的同一条清洗(顺序与长度不变)。
73
+ export function trimRecordsForWire(records) {
74
+ if (!Array.isArray(records)) return records
75
+ return records.map(trimRecordForWire)
76
+ }
77
+
50
78
  export function bytesOfRecords(records) {
51
79
  return bytesOf(records)
52
80
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * 会话日志快照(spec 016 "来回次数"一条的第二步)。
3
+ *
4
+ * A1 把"扫描"从 `readEvent`(51 事件/块)换成 `filterEvents`(2000 seq/块)之后,单会话成本
5
+ * 仍停在"每条命中一次 `readEvent`"这道墙上:实测 163 条命中 ≈ 163 个簇 × ~225 ms ≈ 37 秒。
6
+ * 根因是宿主的**每次调用地板价**,不是扫描算法。
7
+ *
8
+ * 宿主另有一条 `readSession(sessionId)`:一次返回整份日志(2026-10-08 spike 实测 13k 会话
9
+ * 116–315 ms,冷会话可用、不激活 Agent)。于是"要全量用户输入"这件事的正确做法是:
10
+ *
11
+ * 1. 一个会话**读一次**整份日志;
12
+ * 2. 在连接器里按角色过滤(同一份 `roleOf` 口径);
13
+ * 3. 之后这个会话的每一页都从这份快照出,**不再打宿主**。
14
+ *
15
+ * 三条护栏(都是防"为了省几次调用把进程拖垮"):
16
+ * - **事件数上限**:超过 `MAX_EVENTS` 的会话不做快照,退回 A1 的分块路径(那些会话本来就少)。
17
+ * - **条目上限 + TTL**:同时只留 `MAX_ENTRIES` 份、`TTL_MS` 到期即弃。灌库是串行的、搜索面板是
18
+ * 单会话的,所以留一两份就够;过期即弃避免刚发出去的消息被旧快照盖住。
19
+ * - **失败即弃**:宿主没有这个接口、抛错、或返回形状不对,一律返回 undefined(调用方退回 A1)。
20
+ */
21
+
22
+ import { bytesOfRecords, toRecord } from './records.js'
23
+
24
+ /// 一份快照最多装多少条事件。超过就不快照(内存护栏)。
25
+ export const SNAPSHOT_MAX_EVENTS = 20_000
26
+ /// 同时留几份。灌库串行、面板单会话,两份足够覆盖"两台设备交替看"。
27
+ export const SNAPSHOT_MAX_ENTRIES = 2
28
+ /// 快照寿命(毫秒)。短一点,避免刚发出的提问被旧快照挡住。
29
+ export const SNAPSHOT_TTL_MS = 90_000
30
+
31
+ /**
32
+ * 建一个按会话的快照缓存。
33
+ *
34
+ * @param {object} input
35
+ * @param {(sessionId: string) => Promise<{events?: Array}>} input.readSession 宿主的整份读接口
36
+ * @param {() => number} [input.now]
37
+ */
38
+ export function createSnapshotCache({
39
+ readSession, maxEvents = SNAPSHOT_MAX_EVENTS, maxEntries = SNAPSHOT_MAX_ENTRIES,
40
+ ttlMs = SNAPSHOT_TTL_MS, now = Date.now,
41
+ } = {}) {
42
+ const entries = new Map()
43
+
44
+ return {
45
+ /** 命中且新鲜就返回;否则读一次;读不到/超限/形状不对一律 undefined。 */
46
+ 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
71
+ },
72
+ invalidate(sessionId) { entries.delete(sessionId) },
73
+ get size() { return entries.size },
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 从快照扫一页。**纯函数**,返回形状与 `scanOlder` / `filteredScan` 完全一致,
79
+ * 于是裁剪与分页仍然由 `pageFrom` 一处决定。
80
+ *
81
+ * 与分块路径的差别只在"看了多少":快照里我们已经拥有整段,所以 `scannedFloor` 是**这段区间的
82
+ * 下界**(而不是"扫到第几块就停"),空页因此能一次推进到底。
83
+ */
84
+ export function scanSnapshot(snapshot, { before, through, limit, maxBytes = 256 * 1024, roles, roleOf }) {
85
+ // `through` 与 `scanOlder` 同义:它只**封顶新边**(`cursor = min(before-1, through)`),
86
+ // 不是下界——扫描一律往下走到 seq 0。
87
+ const requested = Math.min(before - 1, through ?? Number.MAX_SAFE_INTEGER)
88
+ const newest = Number.isInteger(snapshot?.newestSeq) ? snapshot.newestSeq : -1
89
+ const clamped = requested > newest
90
+ const upper = clamped ? newest : requested
91
+ if (upper < 0) {
92
+ return { events: [], truncatedBy: null, clamped, exhausted: true, scannedFloor: 0 }
93
+ }
94
+ const matches = []
95
+ for (const event of snapshot.events ?? []) {
96
+ const seq = event?.seq
97
+ if (!Number.isInteger(seq) || seq > upper) continue
98
+ if (roles !== undefined && !roles.has(roleOf(event))) continue
99
+ matches.push(event)
100
+ }
101
+ matches.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
102
+
103
+ // **裁剪只由 `pageFrom` 一处做**(模块契约):这里把区间内全部命中交出去,另给三个信号。
104
+ //
105
+ // `exhausted` 的含义与 `scanOlder` 对齐:它表示"这道区间里所有命中都交出去了、下面没有了",
106
+ // 而不是"我看过整段"。不这么定就会出错:快照一次就能看到全部命中,若直接设 true,
107
+ // `pageFrom` 按条数裁掉一部分后会给出 `hasMore=false`,调用方停手,被裁掉的命中永远拿不到。
108
+ // 而 `scannedFloor` 保持 0(= 真下界)也只在"没被裁"时才被 `pageFrom` 当作 `scanBoundary`;
109
+ // 一旦裁剪发生,`pageFrom` 会改用页内最老记录,调用方据此续读,不重不漏。
110
+ const moreThanOnePage = matches.length > limit
111
+ || bytesOfRecords(matches.map(toRecord)) >= maxBytes
112
+ return {
113
+ events: matches,
114
+ truncatedBy: moreThanOnePage ? (matches.length > limit ? 'count' : 'bytes') : null,
115
+ clamped,
116
+ exhausted: !moreThanOnePage,
117
+ scannedFloor: 0,
118
+ }
119
+ }
package/lib/history.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { HOST_CALL_TIMEOUT_MS, withTimeout, messageOf } from './history/shared.js'
2
- import { createProbeRunner } from './history/probe.js'
3
2
  import {
4
3
  DEFAULT_LIMIT, DEFAULT_MAX_BYTES, WINDOW_CHUNK,
5
- bytesOfRecords, displayRoleOf, roleOf, toRecord,
4
+ bytesOfRecords, displayRoleOf, roleOf, toRecord, trimRecordForWire, trimRecordsForWire,
6
5
  } from './history/records.js'
7
6
  import { pageFrom, scanOlder } from './history/window.js'
7
+ import { filteredScan } from './history/filter-scan.js'
8
+ import { createSnapshotCache, scanSnapshot } from './history/session-snapshot.js'
8
9
 
9
10
  // Re-exported for the caller/tests that read them off this module (single source
10
11
  // is `history/records.js`; the call ceiling lives in `history/shared.js`).
@@ -30,10 +31,6 @@ export { HOST_CALL_TIMEOUT_MS } from './history/shared.js'
30
31
  * Feature detection is the switch, not version comparison: a connector that
31
32
  * lacks the injected service answers `link/unsupported`, and the phone keeps
32
33
  * using `session/page`. Nothing about the existing methods changes.
33
- *
34
- * SPIKE NOTE (spec 007, task T008): `probe()` below is a temporary read-only
35
- * measurement used to pick the exact Host API mapping. It is wired to
36
- * `GET /mobile-link/history-probe` and is removed once the mapping is locked.
37
34
  */
38
35
 
39
36
  export const WINDOW_METHOD = '_link/sessionWindow'
@@ -49,6 +46,10 @@ export const FIND_METHOD = '_link/sessionFind'
49
46
  /// does not read the field keeps the old cursor rule.
50
47
  export const HISTORY_CAPABILITIES = [
51
48
  'history-window', 'session-search', 'history-role-filter', 'history-scan-boundary',
49
+ // search-progress: a scanned page also carries `totalChunks` / `remainingChunks`,
50
+ // so the reader can be told "已扫 k/N 片" from the first page on. Purely additive:
51
+ // `totalChunks` comes from the log's newest seq, which a scan already fetches.
52
+ 'search-progress',
52
53
  ]
53
54
 
54
55
  export function isHistoryMethod(method) {
@@ -62,7 +63,15 @@ export function isHistoryMethod(method) {
62
63
  export const SEARCH_CHUNK = 2000
63
64
  export const MAX_SEARCH_CHUNKS_PER_CALL = 4
64
65
  export const DEFAULT_SEARCH_LIMIT = 50
65
- export const MIN_QUERY_LENGTH = 2
66
+ /// 查询下限:**1 个字符**(owner 2026-10-17 决定)。
67
+ ///
68
+ /// 原来卡在 2 个字,理由是「一个字几乎命中整段历史、翻不完」。这个代价在 2026-10-17
69
+ /// 变成可接受:搜索有了分页与片数进度(`search-progress`,界面显示「已扫 k/N 片」),
70
+ /// 一页仍只回 50 条、一次调用仍最多扫 4 块(≈1.3 秒),所以下限降到 1。
71
+ ///
72
+ /// 仍然拒绝的是**空关键词且没带 roles**:那不是搜索,是一次没有范围的全表扫描
73
+ /// (「浏览我的提问」必须带 roles,见 `browsing`)。
74
+ export const MIN_QUERY_LENGTH = 1
66
75
 
67
76
  function idOfAddress(address) {
68
77
  if (address === null || typeof address !== 'object') return undefined
@@ -89,6 +98,29 @@ function parseRoles(value) {
89
98
  return { roles }
90
99
  }
91
100
 
101
+ /// 一条文档里所有可读正文:宿主给的 `text`、`data.text`、`data.content[].text`
102
+ /// (blocks 形态,可能**不止一段**)、`data.message.content[].text`,以及工具结果的
103
+ /// `output` / `result` / `stdout` / `stderr`。
104
+ ///
105
+ /// 为什么自己做匹配、不把 `{kind:'text'}` 交给宿主:宿主的文本过滤只看它自己认定的那一段,
106
+ /// 2026-10-17 真机实测(搜"我"/"的"拿不到全部用户输入)说明会漏掉 `content` 里靠后的部分。
107
+ /// 改成"取回这一段全部文档、在这里逐字匹配"之后,命中集合只由我们自己的规则决定。
108
+ function docText(doc) {
109
+ const data = doc?.data ?? {}
110
+ const parts = []
111
+ const push = (value) => {
112
+ if (typeof value === 'string' && value.length > 0) parts.push(value)
113
+ }
114
+ push(doc?.text)
115
+ push(data.text)
116
+ for (const blocks of [data.content, data.message?.content]) {
117
+ if (!Array.isArray(blocks)) continue
118
+ for (const block of blocks) push(block?.text)
119
+ }
120
+ for (const key of ['output', 'result', 'stdout', 'stderr']) push(data[key])
121
+ return parts.join('\n')
122
+ }
123
+
92
124
  /// A hit snippet with the highlight offsets the phone's list already renders.
93
125
  ///
94
126
  /// Offsets, not ranges: the value crosses the wire and a character offset stays
@@ -117,6 +149,13 @@ function failure(code, message, details = {}) {
117
149
  return { ok: false, error: { code, message, details } }
118
150
  }
119
151
 
152
+ /// 搜索命中项按契约只带 `seq`/`role`/`snippet`(不带记录本身),所以今天**没有**
153
+ /// 宿主日志可删。这里仍然逐条过一遍清洗:万一将来命中项带上记录(例如为了跳转
154
+ /// 预取),它就不会成为那条绕过统一口径的旁路。
155
+ function trimHitsForWire(hits) {
156
+ return hits.map((hit) => (hit?.record === undefined ? hit : trimRecordForWire(hit)))
157
+ }
158
+
120
159
  /// Map a thrown Host/gateway error onto the contract's codes.
121
160
  function hostFailure(error) {
122
161
  const message = messageOf(error)
@@ -130,7 +169,11 @@ function hostFailure(error) {
130
169
  */
131
170
  export function createHistoryService({ logger = console } = {}) {
132
171
  let sessionQuery
133
- const probeRunner = createProbeRunner({ getQuery: () => sessionQuery, logger })
172
+ /// 会话日志快照(只给**按角色过滤**的读取用):一个会话读一次整份、本地过滤、短 TTL。
173
+ /// 它把"每条命中一次宿主调用"那道墙去掉——灌库一个会话从十几次往返变成一次读。
174
+ const snapshots = createSnapshotCache({
175
+ readSession: (sessionId) => withTimeout(sessionQuery.readSession(sessionId), HOST_CALL_TIMEOUT_MS, 'readSession'),
176
+ })
134
177
  /// Set to false after the Host has told us its full-text index is closed
135
178
  /// (`openAt: "never"`), so later searches go straight to the scan.
136
179
  let indexSearchable = true
@@ -176,6 +219,22 @@ export function createHistoryService({ logger = console } = {}) {
176
219
  }
177
220
  }
178
221
 
222
+ /**
223
+ * 一个 seq 段的**消息类文档**(`filterEvents`):比 `readEvent` 每块 51 条快约 20 倍/seq,
224
+ * 但只回消息类事件(user/assistant/tool 那一族)——角色过滤要的正是这些。
225
+ *
226
+ * 宿主没挂这个接口时(老宿主)回 undefined,`window()` 会退回原来的分块扫描。
227
+ */
228
+ async function readFilterChunk(sessionId, from, to) {
229
+ if (typeof sessionQuery?.filterEvents !== 'function') return undefined
230
+ const docs = await withTimeout(
231
+ sessionQuery.filterEvents(sessionId, [{ kind: 'seq', from, to }]),
232
+ HOST_CALL_TIMEOUT_MS,
233
+ 'filterEvents(window)',
234
+ )
235
+ return { docs: Array.isArray(docs) ? docs : [] }
236
+ }
237
+
179
238
  /// One page of in-session literal search (spec 007 FR-010…FR-012).
180
239
  ///
181
240
  /// Preferred path is the Host's indexed `searchEvents`; this deployment ships it
@@ -193,7 +252,8 @@ export function createHistoryService({ logger = console } = {}) {
193
252
  // 这时不按文本过滤,只按 seq 段扫,按角色筛。
194
253
  const browsing = query.length === 0 && roles !== undefined
195
254
  if (!browsing && query.length < MIN_QUERY_LENGTH) {
196
- return failure('link/query-too-short', `query must be at least ${MIN_QUERY_LENGTH} characters`)
255
+ return failure('link/query-too-short',
256
+ `query must be at least ${MIN_QUERY_LENGTH} character (or pass roles to browse)`)
197
257
  }
198
258
  const limit = Number.isSafeInteger(Number(args.limit)) && Number(args.limit) > 0
199
259
  ? Math.min(Number(args.limit), 200) : DEFAULT_SEARCH_LIMIT
@@ -226,7 +286,7 @@ export function createHistoryService({ logger = console } = {}) {
226
286
  return {
227
287
  ok: true,
228
288
  value: {
229
- hits: indexed,
289
+ hits: trimHitsForWire(indexed),
230
290
  cursor: page.hasMore === true && page.cursor !== undefined ? String(page.cursor) : null,
231
291
  hasMore: page.hasMore === true,
232
292
  source: 'index',
@@ -237,15 +297,25 @@ export function createHistoryService({ logger = console } = {}) {
237
297
  logger.info?.(`mobile-link: full-text index unavailable, scanning instead: ${messageOf(error)}`)
238
298
  }
239
299
  }
300
+ const needle = query.toLowerCase()
240
301
  const from = parseSearchCursor(args.cursor)
241
- const ceiling = from !== undefined ? from : await newestSeq(sessionId)
302
+ // `newestSeq` also carries the progress denominator below; it is cached for
303
+ // 5s, and a scan would have fetched it anyway on the first page.
304
+ const newest = await newestSeq(sessionId)
305
+ // 游标是上一页留下的 seq,可能**越过**当前最新(日志被裁剪过、或手机拿着旧游标回来)。
306
+ // 越界时不夹住的话,扫描会从一个不存在的 seq 往下空跑满 4 块,`remainingChunks`
307
+ // 还会算出比 `totalChunks` 更大的数(实测 996/2),界面的"已扫 k/N"随即变成假的。
308
+ const cursorSeq = from !== undefined && newest >= 0 ? Math.min(from, newest) : from
309
+ const ceiling = cursorSeq !== undefined ? cursorSeq : newest
310
+ // 片数(进度的分母):由日志最新 seq 算出,与游标无关,所以第一页就是精确的。
311
+ const total = newest >= 0 ? Math.floor(newest / chunk) + 1 : 0
242
312
  const hits = []
243
313
  let to = ceiling
244
314
  let scanned = 0
245
315
  while (to >= 0 && hits.length < limit && scanned < MAX_SEARCH_CHUNKS_PER_CALL) {
246
316
  const chunkFrom = Math.max(0, to - chunk + 1)
317
+ // 只按 seq 段取文档;正文匹配在下面自己做(见 `docText` 的头注)。
247
318
  const filters = [{ kind: 'seq', from: chunkFrom, to }]
248
- if (!browsing) filters.push({ kind: 'text', text: query })
249
319
  const docs = await withTimeout(
250
320
  sessionQuery.filterEvents(sessionId, filters),
251
321
  HOST_CALL_TIMEOUT_MS,
@@ -254,28 +324,54 @@ export function createHistoryService({ logger = console } = {}) {
254
324
  scanned += 1
255
325
  // Newest first inside the chunk: the reader asked about something recent
256
326
  // far more often than about something old.
327
+ //
328
+ // **游标必须落在"这一页读到哪儿"**,不能一律跳到整块之前:一页在块中途装满时,
329
+ // 块尾还没读过的文档会被永久跳过——2026-10-17 owner 报的"全局搜索不是用户提问的超集"
330
+ // 就是这条(用户提问正好落在片尾时被吞掉;索引路径不受影响)。
331
+ // `resumeFrom` 默认 = 整块之前(读完这块才成立),中途停下时改成"下一条还没读的 seq"。
332
+ let resumeFrom = chunkFrom - 1
257
333
  for (let index = docs.length - 1; index >= 0; index -= 1) {
258
334
  const doc = docs[index]
335
+ // **已知局限**:这里只能按文档判角色,而宿主的 `filterEvents` 文档不带 `data.source`,
336
+ // 所以 `roles:['me']` 会把 `source.kind = "agent-instructions"` 这类**注入消息**也算进来
337
+ // (真机实测:AGENTS.md 注入那条被判成 me)。窗口路径(`_link/sessionWindow`)已改成
338
+ // 取回原始事件后复判,这里没有原始事件、逐条回读的代价又太大(50 条命中 ≈ 11 秒),
339
+ // 因此保留现状并在 `session-search` 的说明里标注:**角色过滤对注入消息不可靠**。
340
+ // App 侧「只看我的提问」自 T040 起走本地库,不走这条路;要恢复这条能力需先让宿主
341
+ // 在文档里带上 `data.source`。
259
342
  const role = roleOf(doc)
260
343
  if (roles !== undefined && (role === undefined || !roles.has(role))) continue
261
344
  const display = displayRoleOf(doc)
262
345
  if (display === undefined) continue
346
+ const text = docText(doc)
347
+ if (!browsing && !text.toLowerCase().includes(needle)) continue
263
348
  hits.push({ seq: doc.seq, role: display, ...(query.length > 0
264
- ? makeSnippet(doc.text, query)
265
- : { snippet: String(doc.text ?? '').slice(0, 72).replace(/\s+/g, ' ').trim(),
349
+ ? makeSnippet(text, query)
350
+ : { snippet: String(text).slice(0, 72).replace(/\s+/g, ' ').trim(),
266
351
  highlightStart: 0, highlightLength: 0 }) })
267
- if (hits.length >= limit) break
352
+ if (hits.length >= limit) {
353
+ // 装满了:这一条已经收进本页,下一页从它的**前一条**继续——不能从整块之前
354
+ // (会丢块尾),也不能从这一条本身(会重复)。
355
+ resumeFrom = doc.seq - 1
356
+ break
357
+ }
268
358
  }
269
- to = chunkFrom - 1
359
+ to = resumeFrom
270
360
  }
271
361
  const hasMore = to >= 0 && hits.length >= limit ? true : to >= 0
272
362
  return {
273
363
  ok: true,
274
364
  value: {
275
- hits,
365
+ hits: trimHitsForWire(hits),
276
366
  cursor: hasMore ? String(to) : null,
277
367
  hasMore,
278
368
  scannedChunks: scanned,
369
+ // 进度:片数是**扫描单位**(一段 `chunk` 个 seq,从新往旧),不是命中页数。
370
+ // `totalChunks` 由日志最新 seq 算出,所以从第一页起就是精确的;命中的**条数**
371
+ // 仍然要扫到最早那一片才定稿(不开宿主全文索引的必然结果)。
372
+ totalChunks: total,
373
+ // 剩余片数不可能多于总片数:越界游标、被裁剪的日志都不该让进度超过 100%。
374
+ remainingChunks: to >= 0 ? Math.min(Math.floor(to / chunk) + 1, total) : 0,
279
375
  source: 'scan',
280
376
  },
281
377
  }
@@ -316,8 +412,28 @@ export function createHistoryService({ logger = console } = {}) {
316
412
  if (inFlight.has(key)) return failure('link/busy', 'another window read is in flight for this session')
317
413
  inFlight.add(key)
318
414
  try {
319
- const scan = await scanOlder({ readChunk, sessionId, before, through, limit, maxBytes, roles })
320
- return { ok: true, value: pageFrom(scan, { limit, maxBytes, roles }) }
415
+ // 按角色过滤时走"先定位命中 seq、再定向取完整记录"(spec 016 的"来回次数"一条):
416
+ // 同样一段日志,过滤路径实测从 2.5–4.1 s 降到数百毫秒;未过滤的浏览翻页保持原样
417
+ // (它要的是精确的一段原始事件,`readEvent` 每块 51 条正合适)。
418
+ const useFilterScan = roles !== undefined && typeof sessionQuery?.filterEvents === 'function'
419
+ // 三级:① 角色过滤 + 宿主能整份读 → 从快照出(一次读、之后每页零往返);
420
+ // ② 角色过滤但快照不可用(没有 readSession / 太大 / 读失败)→ A1 的分块定位;
421
+ // ③ 不带角色 → 原来的分块扫描(浏览翻页要的是精确的一段原始事件)。
422
+ const snapshot = roles !== undefined && typeof sessionQuery?.readSession === 'function'
423
+ ? await snapshots.get(sessionId)
424
+ : undefined
425
+ const scan = snapshot !== undefined
426
+ ? scanSnapshot(snapshot, { before, through, limit, maxBytes, roles, roleOf })
427
+ : useFilterScan
428
+ ? await filteredScan({
429
+ readFilterChunk, readRecords: readChunk, newestSeq, sessionId, before, through, limit,
430
+ roles, roleOf,
431
+ })
432
+ : await scanOlder({ readChunk, sessionId, before, through, limit, maxBytes, roles })
433
+ const page = pageFrom(scan, { limit, maxBytes, roles })
434
+ // 016:下发前统一清掉只用于诊断的宿主模型输出日志(口径只有一份,
435
+ // 见 specs/016-history-load-perf/contracts/history-record-trim.md)。
436
+ return { ok: true, value: { ...page, records: trimRecordsForWire(page.records) } }
321
437
  } catch (error) {
322
438
  logger.warn?.(`mobile-link: sessionWindow failed: ${messageOf(error)}`)
323
439
  return hostFailure(error)
@@ -346,10 +462,5 @@ export function createHistoryService({ logger = console } = {}) {
346
462
  return failure('link/unknown-method', String(method))
347
463
  },
348
464
 
349
- /// Exposed for the unit tests and the (gated) measurement route.
350
- /// 只读测量(spec 007 T008/T047):实现见 `history/probe.js`。
351
- async probe(args = {}) {
352
- return probeRunner(args)
353
- },
354
465
  }
355
466
  }
package/lib/index.js CHANGED
@@ -48,6 +48,14 @@ const DEFAULTS = {
48
48
  stateFile: '',
49
49
  dshUrl: '',
50
50
  inviteCode: '',
51
+ // 生命周期流水的落盘位置:留空=默认写在身份文件旁边(`$DSH_HOME/mobile-link/
52
+ // link-events.jsonl`),给字符串=写在那里,`false`=只在内存里留一份。
53
+ journalFile: '',
54
+ // 慢请求阈值(毫秒);0 = 不记单条慢请求(每次接入的 `device.summary` 仍然写)。
55
+ slowRequestMs: 2000,
56
+ // 是否接收手机推上来的阶段记录(`_link/diagnostics`)。false = 回 diagnostics/disabled,
57
+ // 手机静默忽略——这是"记录出问题时的回退闸",关掉不影响连接。
58
+ diagnostics: true,
51
59
  heartbeatMs: 20000,
52
60
  pongTimeoutMs: 60000,
53
61
  maxBackoffMs: 30000,
@@ -96,6 +104,14 @@ function buildAgent(config, logger) {
96
104
  // otherwise the agent reads DSH_MOBILE_LINK_INVITE (see enroll.js).
97
105
  inviteCode: typeof config.inviteCode === 'string' && config.inviteCode ? config.inviteCode : undefined,
98
106
  dshUrl: typeof config.dshUrl === 'string' && config.dshUrl ? config.dshUrl : undefined,
107
+ journalPath: config.journalFile === false
108
+ ? false
109
+ : (typeof config.journalFile === 'string' && config.journalFile ? config.journalFile : undefined),
110
+ // 注意 0 是**有效值**(关闭逐条慢请求),所以不能用 `||` 兜底。
111
+ slowRequestMs: config.slowRequestMs === undefined
112
+ ? DEFAULTS.slowRequestMs
113
+ : Number(config.slowRequestMs) || 0,
114
+ diagnostics: config.diagnostics !== false,
99
115
  heartbeatMs: Number(config.heartbeatMs) || DEFAULTS.heartbeatMs,
100
116
  pongTimeoutMs: Number(config.pongTimeoutMs) || DEFAULTS.pongTimeoutMs,
101
117
  maxBackoffMs: Number(config.maxBackoffMs) || DEFAULTS.maxBackoffMs,