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.
- package/README.md +8 -1
- package/cordis.patch.yml +12 -0
- package/lib/client.js +101 -41
- package/lib/diagnostics.js +172 -0
- package/lib/dlp.js +10 -1
- package/lib/hello.js +35 -3
- package/lib/history/filter-scan.js +145 -0
- package/lib/history/records.js +28 -0
- package/lib/history/session-snapshot.js +119 -0
- package/lib/history.js +135 -24
- package/lib/index.js +16 -0
- package/lib/journal.js +279 -0
- package/lib/link.js +113 -8
- package/lib/pairing.js +60 -9
- package/lib/router.js +215 -2
- package/lib/routes.js +34 -34
- package/lib/session-digest.js +117 -0
- package/lib/status.js +32 -3
- package/package.json +1 -1
- package/lib/history/probe.js +0 -195
|
@@ -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
|
+
}
|
package/lib/history/records.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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(
|
|
265
|
-
: { snippet: String(
|
|
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)
|
|
352
|
+
if (hits.length >= limit) {
|
|
353
|
+
// 装满了:这一条已经收进本页,下一页从它的**前一条**继续——不能从整块之前
|
|
354
|
+
// (会丢块尾),也不能从这一条本身(会重复)。
|
|
355
|
+
resumeFrom = doc.seq - 1
|
|
356
|
+
break
|
|
357
|
+
}
|
|
268
358
|
}
|
|
269
|
-
to =
|
|
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
|
-
|
|
320
|
-
|
|
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,
|