dsh-plugin-mobile-link 0.4.1 → 0.6.5
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 +10 -2
- package/cordis.patch.yml +35 -0
- package/lib/cli.js +6 -6
- package/lib/client.js +493 -0
- package/lib/diagnostics.js +172 -0
- package/lib/hello.js +19 -2
- package/lib/history/filter-scan.js +145 -0
- package/lib/history/records.js +125 -0
- package/lib/history/session-snapshot.js +119 -0
- package/lib/history/shared.js +25 -0
- package/lib/history/window.js +90 -0
- package/lib/history.js +401 -0
- package/lib/index.js +45 -6
- package/lib/journal.js +279 -0
- package/lib/link.js +179 -4
- package/lib/qr.js +34 -5
- package/lib/router.js +237 -2
- package/lib/routes.js +26 -55
- package/lib/session-digest.js +117 -0
- package/lib/status.js +28 -2
- package/package.json +5 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一次接入的计时与有界耗时汇总(spec 020)。
|
|
3
|
+
*
|
|
4
|
+
* 职责(只做这三件,别的都不做):
|
|
5
|
+
* 1. **阶段计时**:一次设备接入走完「接入 → `_link/hello` → 首个数据请求 → 首个响应」各用了多少毫秒;
|
|
6
|
+
* 没发生的阶段必须是 `null` 并在 `missing` 里列出——**不许记 0**(0 与"没发生"在判读时是两回事)。
|
|
7
|
+
* 2. **慢请求**:单次请求超过阈值时产出一条 `request.slow`(方法名、耗时、字节数)。
|
|
8
|
+
* 3. **有界汇总**:一次接入的 `requests / slow / maxMs / p50Ms / p95Ms / top3`,
|
|
9
|
+
* 用**定长水库**估分位,数组长度不随请求数增长(否则"记日志"本身会变成新的性能问题)。
|
|
10
|
+
*
|
|
11
|
+
* 本模块是纯逻辑:它不认识 socket、不认识 journal,事件由上层通过 `onEvent` 注入
|
|
12
|
+
* (宪法原则 VII.1:能力注入,不在内部读全局状态)。
|
|
13
|
+
*
|
|
14
|
+
* 契约:`docs/diagnostics-events.md`
|
|
15
|
+
* 数据模型:`specs/020-link-diagnostics/data-model.md` §3–§5
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** 水库样本容量:估 p50/p95 够用,且不随请求数增长。 */
|
|
19
|
+
export const RESERVOIR_SIZE = 256
|
|
20
|
+
/** 每累计这么多请求写一条汇总(加上断开时那条,构成"条数常数级"的上界)。 */
|
|
21
|
+
export const SUMMARY_EVERY = 200
|
|
22
|
+
/** `top3` 保留几条最慢的请求。 */
|
|
23
|
+
export const TOP_SLOWEST = 3
|
|
24
|
+
|
|
25
|
+
/** 阶段名(对外,出现在 `missing` 里)。 */
|
|
26
|
+
export const PHASES = Object.freeze({
|
|
27
|
+
helloMs: 'hello',
|
|
28
|
+
firstRequestMs: 'first-request',
|
|
29
|
+
firstResponseMs: 'first-response',
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/** 握手方法名(与 `hello.js` 同口径;这里只用来区分"握手"与"数据请求")。 */
|
|
33
|
+
const HELLO_METHOD = '_link/hello'
|
|
34
|
+
|
|
35
|
+
/** 最近秩分位数:样本少的时候比插值更保守,也更容易在单测里核对。 */
|
|
36
|
+
function percentile(sorted, fraction) {
|
|
37
|
+
if (sorted.length === 0) return 0
|
|
38
|
+
const rank = Math.ceil(fraction * sorted.length)
|
|
39
|
+
return sorted[Math.max(0, Math.min(sorted.length - 1, rank - 1))]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class DeviceTimeline {
|
|
43
|
+
/**
|
|
44
|
+
* @param {object} [options]
|
|
45
|
+
* @param {string} [options.deviceId]
|
|
46
|
+
* @param {number} [options.slowRequestMs] 超过它才记 `request.slow`;0 表示关闭该逐条记录
|
|
47
|
+
* @param {() => number} [options.now]
|
|
48
|
+
* @param {(kind: string, detail: Record<string, unknown>) => void} [options.onEvent]
|
|
49
|
+
*/
|
|
50
|
+
constructor({ deviceId, slowRequestMs = 2000, now = Date.now, onEvent = () => {} } = {}) {
|
|
51
|
+
this.deviceId = deviceId
|
|
52
|
+
this.slowRequestMs = Number(slowRequestMs) || 0
|
|
53
|
+
this.now = now
|
|
54
|
+
this.onEvent = onEvent
|
|
55
|
+
this.attachedAt = now()
|
|
56
|
+
this.helloAt = undefined
|
|
57
|
+
this.firstRequestAt = undefined
|
|
58
|
+
this.firstResponseAt = undefined
|
|
59
|
+
this.requests = 0
|
|
60
|
+
this.slow = 0
|
|
61
|
+
this.maxMs = 0
|
|
62
|
+
/** 定长水库:满了以后按步长覆盖(确定性的系统抽样,避免引入随机数让单测发抖)。 */
|
|
63
|
+
this._samples = []
|
|
64
|
+
this._sampleCursor = 0
|
|
65
|
+
/** 最慢的若干条,长度恒 ≤ TOP_SLOWEST。 */
|
|
66
|
+
this._top = []
|
|
67
|
+
this._phaseEmitted = false
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 收到 `_link/hello`(只在第一次生效)。 */
|
|
71
|
+
noteHello(at = this.now()) {
|
|
72
|
+
if (this.helloAt === undefined) this.helloAt = at
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 一次请求收尾。
|
|
77
|
+
*
|
|
78
|
+
* @param {{method: string, startedAt: number, ms: number, bytes: number}} request
|
|
79
|
+
*/
|
|
80
|
+
noteRequest({ method, startedAt, ms, bytes }) {
|
|
81
|
+
const isHello = method === HELLO_METHOD
|
|
82
|
+
if (isHello) this.noteHello(startedAt)
|
|
83
|
+
if (!isHello && this.firstRequestAt === undefined) this.firstRequestAt = startedAt
|
|
84
|
+
// 只认**数据请求**的响应:握手响应不算。否则 `firstResponseMs` 会小于 `firstRequestMs`
|
|
85
|
+
// (握手先回、数据请求后发),三段的阶梯就读不出来了。
|
|
86
|
+
if (!isHello && this.firstResponseAt === undefined) this.firstResponseAt = startedAt + ms
|
|
87
|
+
|
|
88
|
+
this.requests += 1
|
|
89
|
+
this.maxMs = Math.max(this.maxMs, ms)
|
|
90
|
+
this.#sample(ms)
|
|
91
|
+
this.#keepTop({ method, ms, bytes })
|
|
92
|
+
if (this.slowRequestMs > 0 && ms > this.slowRequestMs) {
|
|
93
|
+
this.slow += 1
|
|
94
|
+
// 字段叫 elapsedMs 而不是 ms:`ms` 是信封上的**时刻**,同名会把时刻冲掉(见 journal.js)。
|
|
95
|
+
this.onEvent('request.slow', { deviceId: this.deviceId, method, elapsedMs: ms, bytes })
|
|
96
|
+
}
|
|
97
|
+
if (this.requests % SUMMARY_EVERY === 0) this.emitSummary()
|
|
98
|
+
this.#maybeEmitPhase()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 阶段明细(`missing` 里的阶段名,其 `phases.*Ms` 必为 null)。 */
|
|
102
|
+
phaseDetail() {
|
|
103
|
+
const relative = (at) => (at === undefined ? null : at - this.attachedAt)
|
|
104
|
+
const phases = {
|
|
105
|
+
helloMs: relative(this.helloAt),
|
|
106
|
+
firstRequestMs: relative(this.firstRequestAt),
|
|
107
|
+
firstResponseMs: relative(this.firstResponseAt),
|
|
108
|
+
}
|
|
109
|
+
const missing = Object.entries(phases)
|
|
110
|
+
.filter(([, value]) => value === null)
|
|
111
|
+
.map(([key]) => PHASES[key])
|
|
112
|
+
return { deviceId: this.deviceId, phases, missing }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 汇总明细(分位来自定长水库)。 */
|
|
116
|
+
summaryDetail() {
|
|
117
|
+
const sorted = [...this._samples].sort((a, b) => a - b)
|
|
118
|
+
return {
|
|
119
|
+
deviceId: this.deviceId,
|
|
120
|
+
requests: this.requests,
|
|
121
|
+
slow: this.slow,
|
|
122
|
+
maxMs: this.maxMs,
|
|
123
|
+
p50Ms: percentile(sorted, 0.5),
|
|
124
|
+
p95Ms: percentile(sorted, 0.95),
|
|
125
|
+
top3: this._top.map((entry) => ({ ...entry })),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 断开时调用:补齐阶段事件(若还没写过)并在有请求时才写汇总。 */
|
|
130
|
+
finish() {
|
|
131
|
+
if (!this._phaseEmitted) {
|
|
132
|
+
this._phaseEmitted = true
|
|
133
|
+
this.onEvent('device.phase', this.phaseDetail())
|
|
134
|
+
}
|
|
135
|
+
if (this.requests > 0) this.emitSummary()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 写一条汇总(供断开与每 `SUMMARY_EVERY` 个请求两个时机调用)。 */
|
|
139
|
+
emitSummary() {
|
|
140
|
+
this.onEvent('device.summary', this.summaryDetail())
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#sample(ms) {
|
|
144
|
+
if (this._samples.length < RESERVOIR_SIZE) {
|
|
145
|
+
this._samples.push(ms)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const index = this._sampleCursor % RESERVOIR_SIZE
|
|
149
|
+
this._samples[index] = ms
|
|
150
|
+
this._sampleCursor += 1
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#keepTop(entry) {
|
|
154
|
+
this._top.push(entry)
|
|
155
|
+
this._top.sort((a, b) => b.ms - a.ms)
|
|
156
|
+
if (this._top.length > TOP_SLOWEST) this._top.length = TOP_SLOWEST
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#maybeEmitPhase() {
|
|
160
|
+
if (this._phaseEmitted) return
|
|
161
|
+
// 三段**全齐**才写;缺哪一段就等 `finish()` 时按 missing 写(读者要知道"没发生",而不是看到 0)。
|
|
162
|
+
//
|
|
163
|
+
// 为什么要求全齐而不是"有数据请求就写":App 的真实顺序是**先探针(session/list)再握手**
|
|
164
|
+
// (`ConnectionStore.connect()` 先 `sessions()`,再在 `install()` 里发 `_link/hello`)。
|
|
165
|
+
// 按旧条件,阶段事件会在探针那一刻写出来、`missing: ['hello']`,于是最有用的
|
|
166
|
+
// 「attach→hello 用了多久」在事件里永远是空的——2026-10-13 09:28 的中转运行实测到了这一条。
|
|
167
|
+
if (this.helloAt === undefined) return
|
|
168
|
+
if (this.firstRequestAt === undefined || this.firstResponseAt === undefined) return
|
|
169
|
+
this._phaseEmitted = true
|
|
170
|
+
this.onEvent('device.phase', this.phaseDetail())
|
|
171
|
+
}
|
|
172
|
+
}
|
package/lib/hello.js
CHANGED
|
@@ -41,8 +41,9 @@ function readManifestVersion() {
|
|
|
41
41
|
* - `events` the per-device `$events` stream is served
|
|
42
42
|
* - `session-streams` logical streams (`session/follow`, …) are served
|
|
43
43
|
* - `pair-code` this connector can mint a pairing code on request
|
|
44
|
-
* - `qr-pairing` this connector
|
|
45
|
-
*
|
|
44
|
+
* - `qr-pairing` this connector renders the pairing QR itself (the `svg`
|
|
45
|
+
* field of `POST /mobile-link/pair-code`), so the DSH client
|
|
46
|
+
* needs no QR library of its own
|
|
46
47
|
* - `self-enroll` this connector can register itself with an invite code
|
|
47
48
|
* - `git` git status / diff / log / show can be read from the work
|
|
48
49
|
* tree on this computer (see git.js)
|
|
@@ -57,6 +58,22 @@ function readManifestVersion() {
|
|
|
57
58
|
export const SERVER_CAPABILITIES = [
|
|
58
59
|
'file-transfer', 'events', 'session-streams', 'pair-code', 'qr-pairing', 'self-enroll', 'git',
|
|
59
60
|
'background-transfer',
|
|
61
|
+
// history-window: the connector answers `_link/sessionWindow` (exact seq window);
|
|
62
|
+
// session-search: it answers `_link/sessionFind` (in-session literal search). The
|
|
63
|
+
// phone switches channels on these and falls back to `session/page` without them.
|
|
64
|
+
'history-window', 'session-search',
|
|
65
|
+
// session-digest: 连接器答 `_link/sessionDigest`(`session/list` 的精简同形副本,
|
|
66
|
+
// 去掉 App 侧零读取点的字段、可按要求 gzip)。手机侧按这条能力切换,缺了就回退到
|
|
67
|
+
// `session/list`——spec 021 只做连接器侧,切换在后续规格。
|
|
68
|
+
'session-digest',
|
|
69
|
+
// history-role-filter: `_link/sessionWindow` accepts `roles` (e.g. just the
|
|
70
|
+
// "me" role) and returns only matching records; seq cursor semantics are
|
|
71
|
+
// unchanged (spec 009 T031).
|
|
72
|
+
'history-role-filter',
|
|
73
|
+
// history-scan-boundary: such a page also carries `scanBoundary` — the seq the
|
|
74
|
+
// scan actually reached — so a caller whose page was filtered empty can still
|
|
75
|
+
// advance instead of asking for the same stretch forever (spec 009 T039).
|
|
76
|
+
'history-scan-boundary',
|
|
60
77
|
]
|
|
61
78
|
|
|
62
79
|
export function isHelloMethod(method) {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `_link/sessionWindow` / `_link/sessionFind` 共用的事件→记录件。
|
|
3
|
+
*
|
|
4
|
+
* 抽出来的理由与 `shared.js` 同一条(spec 007 T065):窗口扫描、检索、探针三块
|
|
5
|
+
* 都要按角色归类事件、都要按字节预算裁剪,放在 `history.js` 里会把那个文件撑到
|
|
6
|
+
* 读不动,也确实漏过一次导入(`probe.js` 里 `bytesOf`/`summarizeEvents` 一直是
|
|
7
|
+
* 未定义的名字)。
|
|
8
|
+
*
|
|
9
|
+
* 这里只有纯函数:没有 IO、没有服务状态。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/// Host hard cap: `readEvent` rejects `before > 50` (`before must be an integer
|
|
13
|
+
/// between 0 and 50`). One chunk therefore yields at most 51 raw events.
|
|
14
|
+
export const WINDOW_CHUNK = 50
|
|
15
|
+
|
|
16
|
+
/// Defaults for one page. The phone may override both; the byte budget wins when
|
|
17
|
+
/// they disagree (measured: 51 raw events can already be 253KB on a long session).
|
|
18
|
+
export const DEFAULT_LIMIT = 60
|
|
19
|
+
export const DEFAULT_MAX_BYTES = 256 * 1024
|
|
20
|
+
|
|
21
|
+
/// Ceiling on how much one call reads, so a page request can never pin the
|
|
22
|
+
/// connector's serial inbox (measured: ~30ms per chunk on a 2.3k session).
|
|
23
|
+
export const MAX_CHUNKS_PER_CALL = 12
|
|
24
|
+
|
|
25
|
+
export function bytesOf(value) {
|
|
26
|
+
try {
|
|
27
|
+
return Buffer.byteLength(JSON.stringify(value), 'utf8')
|
|
28
|
+
} catch {
|
|
29
|
+
return -1
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function summarizeEvents(events) {
|
|
34
|
+
if (!Array.isArray(events) || events.length === 0) {
|
|
35
|
+
return { count: 0, firstSeq: null, lastSeq: null, types: [] }
|
|
36
|
+
}
|
|
37
|
+
const types = [...new Set(events.map((event) => event?.type).filter(Boolean))].slice(0, 8)
|
|
38
|
+
return {
|
|
39
|
+
count: events.length,
|
|
40
|
+
firstSeq: events[0]?.seq ?? null,
|
|
41
|
+
lastSeq: events[events.length - 1]?.seq ?? null,
|
|
42
|
+
types,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function toRecord(event) {
|
|
47
|
+
return { type: 'event', event }
|
|
48
|
+
}
|
|
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
|
+
|
|
78
|
+
export function bytesOfRecords(records) {
|
|
79
|
+
return bytesOf(records)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// Drop the **oldest** end until the page fits. The newest edge must stay exact
|
|
83
|
+
/// (it is the seq the phone's window abuts), so trimming only ever happens here.
|
|
84
|
+
export function trimToBytes(records, maxBytes) {
|
|
85
|
+
let truncated = false
|
|
86
|
+
while (records.length > 1 && bytesOfRecords(records) > maxBytes) {
|
|
87
|
+
records.shift()
|
|
88
|
+
truncated = true
|
|
89
|
+
}
|
|
90
|
+
return truncated
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// Which of the three transcript roles one event belongs to (the phone's own
|
|
94
|
+
/// `TranscriptSearchHit.Role`).
|
|
95
|
+
///
|
|
96
|
+
/// `user/message` only counts as **me** when the event really came from the
|
|
97
|
+
/// user: measured on a real session, ~24% of `user/message` events are
|
|
98
|
+
/// plugin/system injections (`data.source.kind` other than `"user"`), and
|
|
99
|
+
/// agent-relayed ones carry a `data.source.rpcId` starting with `agent-`.
|
|
100
|
+
/// Events with no `data.source` at all predate the marker and stay `me`
|
|
101
|
+
/// (old-data compatibility). Non-`me` user messages return `undefined` —
|
|
102
|
+
/// they remain visible to full-text search, they just never enter the
|
|
103
|
+
/// "my questions" set (spec 009 T031).
|
|
104
|
+
export function roleOf(event) {
|
|
105
|
+
const type = event?.type
|
|
106
|
+
if (type === 'user/message') {
|
|
107
|
+
const source = event?.data?.source
|
|
108
|
+
if (source !== undefined && source !== null && typeof source === 'object') {
|
|
109
|
+
if (source.kind !== undefined && source.kind !== 'user') return undefined
|
|
110
|
+
if (typeof source.rpcId === 'string' && source.rpcId.startsWith('agent-')) return undefined
|
|
111
|
+
}
|
|
112
|
+
return 'me'
|
|
113
|
+
}
|
|
114
|
+
if (type === 'assistant/message') return 'agent'
|
|
115
|
+
if (type === 'tool/call' || type === 'tool/result') return 'tool'
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// What the search hit is *displayed* as. Injected/relayed user messages are
|
|
120
|
+
/// not "me" for the curated set, but they are still user speech in full-text
|
|
121
|
+
/// search (spec 009: excluded from "我的提问", not from the corpus), so an
|
|
122
|
+
/// unfiltered search shows them under the user bucket.
|
|
123
|
+
export function displayRoleOf(event) {
|
|
124
|
+
return roleOf(event) ?? (event?.type === 'user/message' ? 'me' : undefined)
|
|
125
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `_link/sessionWindow` / `_link/sessionFind` 共用的小工具:调用上限、超时与错误文案。
|
|
3
|
+
*
|
|
4
|
+
* 抽出来的理由:这两个助手原先在 `history.js` 里,而窗口、检索、探针三块都要用;
|
|
5
|
+
* 放在这里可以让三块各自成文件(spec 007 T065)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/// Host-side call ceiling. The connector's inbox is serial, so one slow query
|
|
9
|
+
/// would otherwise hold up every other frame on the tunnel.
|
|
10
|
+
export const HOST_CALL_TIMEOUT_MS = 8000
|
|
11
|
+
|
|
12
|
+
/// 包一层超时:连接器的入站链是**串行**的,一次不返回的宿主调用会卡住整条链。
|
|
13
|
+
export function withTimeout(promise, ms, label) {
|
|
14
|
+
let timer
|
|
15
|
+
const timeout = new Promise((_, reject) => {
|
|
16
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
|
|
17
|
+
})
|
|
18
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function messageOf(error) {
|
|
22
|
+
if (error === undefined || error === null) return 'unknown error'
|
|
23
|
+
if (typeof error === 'string') return error
|
|
24
|
+
return error.message ?? String(error)
|
|
25
|
+
}
|