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.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `_link/sessionWindow` 的「往下扫」与「把扫描结果裁成一页」两块(spec 007 T065
3
+ * 的同一条理由:让 `history.js` 留得下校验、在飞锁与检索)。
4
+ *
5
+ * 唯一的 IO 是调用方给的 `readChunk`,所以这里没有服务状态、可以直接单测。
6
+ */
7
+
8
+ import { MAX_CHUNKS_PER_CALL, bytesOfRecords, roleOf, toRecord, trimToBytes } from './records.js'
9
+
10
+ /// One backward scan from `before - 1` (spec 009 T039).
11
+ ///
12
+ /// Returns the matching events **and** `scannedFloor`: the oldest seq this call
13
+ /// actually examined (inclusive). It is about *where the scan got to*, not about
14
+ /// which events matched — a page whose whole stretch is filtered out still moves
15
+ /// it, which is what lets the phone's role-filtered backfill advance. (The old
16
+ /// cursor rule — the oldest returned record — stands still on an empty page.)
17
+ ///
18
+ /// `exhausted` means the scan ran into the start of the log (or an empty chunk):
19
+ /// a filtered page needs it to answer "is there more?".
20
+ export async function scanOlder({ readChunk, sessionId, before, through, limit, maxBytes, roles }) {
21
+ let cursor = Math.min(before - 1, through ?? Number.MAX_SAFE_INTEGER)
22
+ const events = []
23
+ let truncatedBy = null
24
+ let clamped = false
25
+ let exhausted = false
26
+ let scannedFloor = null
27
+ for (let chunk = 0; chunk < MAX_CHUNKS_PER_CALL && cursor >= 0; chunk += 1) {
28
+ const { window: result, clamped: thisClamped } = await readChunk(sessionId, cursor)
29
+ clamped = clamped || thisClamped
30
+ const batch = Array.isArray(result.events) ? result.events : []
31
+ if (batch.length === 0) { exhausted = true; scannedFloor = 0; break }
32
+ const lowest = result.startSeq ?? batch[0].seq
33
+ scannedFloor = scannedFloor === null ? lowest : Math.min(scannedFloor, lowest)
34
+ for (let index = batch.length - 1; index >= 0; index -= 1) {
35
+ const event = batch[index]
36
+ if (roles !== undefined && !roles.has(roleOf(event))) continue
37
+ events.unshift(event)
38
+ }
39
+ if (events.length >= limit || bytesOfRecords(events.map(toRecord)) >= maxBytes) {
40
+ truncatedBy = events.length >= limit ? 'count' : 'bytes'
41
+ break
42
+ }
43
+ const next = lowest - 1
44
+ if (next >= cursor) { exhausted = true; break }
45
+ cursor = next
46
+ if (cursor < 0) { exhausted = true; break }
47
+ }
48
+ return { events, truncatedBy, clamped, exhausted, scannedFloor }
49
+ }
50
+
51
+ /// Shape one scan into the page the phone gets.
52
+ ///
53
+ /// Trimming only ever drops the **oldest** end: the newest edge is the seq the
54
+ /// phone's window abuts, so it must stay exact (one chunk is 51 events, so the
55
+ /// page would otherwise overshoot `limit` by up to a chunk).
56
+ ///
57
+ /// `scanBoundary` is what the caller passes as the next `before`. Normally that
58
+ /// is `scannedFloor` — the stretch below it was never looked at. When trimming
59
+ /// dropped records, it falls back to the oldest **returned** record instead:
60
+ /// dropped records were not handed over, so the next page has to scan them
61
+ /// again (re-scanning is free, losing one question is not). Without `roles` the
62
+ /// field mirrors `startSeq`: old clients read the old fields and see no change.
63
+ export function pageFrom(scan, { limit, maxBytes, roles }) {
64
+ const records = scan.events.map(toRecord)
65
+ let truncatedBy = scan.truncatedBy
66
+ let droppedOldest = false
67
+ if (records.length > limit) {
68
+ records.splice(0, records.length - limit)
69
+ truncatedBy = 'count'
70
+ droppedOldest = true
71
+ }
72
+ if (trimToBytes(records, maxBytes)) { truncatedBy = 'bytes'; droppedOldest = true }
73
+ if (records.length === 0) {
74
+ return {
75
+ records: [], startSeq: null, endSeq: null,
76
+ hasMore: roles !== undefined && !scan.exhausted,
77
+ truncatedBy: null, clamped: scan.clamped,
78
+ scanBoundary: roles === undefined ? null : scan.scannedFloor,
79
+ }
80
+ }
81
+ const startSeq = records[0].event.seq
82
+ const endSeq = records[records.length - 1].event.seq
83
+ // Filtered pages: "more" means older seqs remain unscanned **and** the page
84
+ // did not already reach the log's first event.
85
+ const hasMore = roles === undefined ? startSeq > 0 : (startSeq > 0 && !scan.exhausted)
86
+ return {
87
+ records, startSeq, endSeq, hasMore, truncatedBy, clamped: scan.clamped,
88
+ scanBoundary: roles === undefined ? startSeq : (droppedOldest ? startSeq : scan.scannedFloor),
89
+ }
90
+ }
package/lib/history.js ADDED
@@ -0,0 +1,401 @@
1
+ import { HOST_CALL_TIMEOUT_MS, withTimeout, messageOf } from './history/shared.js'
2
+ import {
3
+ DEFAULT_LIMIT, DEFAULT_MAX_BYTES, WINDOW_CHUNK,
4
+ bytesOfRecords, displayRoleOf, roleOf, toRecord, trimRecordForWire, trimRecordsForWire,
5
+ } from './history/records.js'
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'
9
+
10
+ // Re-exported for the caller/tests that read them off this module (single source
11
+ // is `history/records.js`; the call ceiling lives in `history/shared.js`).
12
+ export { DEFAULT_MAX_BYTES, DEFAULT_LIMIT, WINDOW_CHUNK } from './history/records.js'
13
+ export { HOST_CALL_TIMEOUT_MS } from './history/shared.js'
14
+
15
+ /**
16
+ * History window reads and in-session search, answered **locally** by the connector.
17
+ *
18
+ * Why local: the Host already owns a correct, seq-exact reader
19
+ * (`ctx.sessionQuery`, see @deepseek-ai/dsh-session-query) but does **not** expose
20
+ * it over the remote API the phone talks to. The only history method on that
21
+ * surface is `session/page`, whose `beforeSeq` lands in log-offset space, so a
22
+ * session containing offset-only events silently skips a stretch of history —
23
+ * measured at 235 seqs on a real session (see DSHKit's TranscriptPageBoundary).
24
+ *
25
+ * So the connector reads the Host's own state through the injected service and
26
+ * answers two extra methods:
27
+ *
28
+ * _link/sessionWindow exact window ending before a given seq
29
+ * _link/sessionFind literal text search within one session
30
+ *
31
+ * Feature detection is the switch, not version comparison: a connector that
32
+ * lacks the injected service answers `link/unsupported`, and the phone keeps
33
+ * using `session/page`. Nothing about the existing methods changes.
34
+ */
35
+
36
+ export const WINDOW_METHOD = '_link/sessionWindow'
37
+ export const FIND_METHOD = '_link/sessionFind'
38
+
39
+ /// Declared in `_link/hello`; the phone switches channels on these.
40
+ /// `history-role-filter`: `_link/sessionWindow` accepts an optional `roles`
41
+ /// array (e.g. `["me"]`) and returns only matching records; the seq cursor
42
+ /// semantics are unchanged (spec 009 T031).
43
+ /// `history-scan-boundary`: a `roles`-filtered page also carries `scanBoundary`,
44
+ /// the seq the scan really reached, so a caller can advance its cursor when a
45
+ /// whole page is filtered empty (spec 009 T039). Purely additive: a client that
46
+ /// does not read the field keeps the old cursor rule.
47
+ export const HISTORY_CAPABILITIES = [
48
+ 'history-window', 'session-search', 'history-role-filter', 'history-scan-boundary',
49
+ ]
50
+
51
+ export function isHistoryMethod(method) {
52
+ return method === WINDOW_METHOD || method === FIND_METHOD
53
+ }
54
+
55
+ /// One search chunk, and how many of them one call may scan. Measured on a
56
+ /// 12968-event session: ~320ms per 2000-event chunk, so four chunks keep a call
57
+ /// near 1.3s — well inside the 8s host-call budget and short enough not to hold
58
+ /// the connector's single inbound chain for long (spec 007 FR-019).
59
+ export const SEARCH_CHUNK = 2000
60
+ export const MAX_SEARCH_CHUNKS_PER_CALL = 4
61
+ export const DEFAULT_SEARCH_LIMIT = 50
62
+ export const MIN_QUERY_LENGTH = 2
63
+
64
+ function idOfAddress(address) {
65
+ if (address === null || typeof address !== 'object') return undefined
66
+ if (address.kind === 'session') return typeof address.sessionId === 'string' ? address.sessionId : undefined
67
+ if (address.kind === 'subagent') {
68
+ return typeof address.childSessionId === 'string' ? address.childSessionId : undefined
69
+ }
70
+ return undefined
71
+ }
72
+
73
+ /// Validate the optional `roles` argument: unknown values would silently match
74
+ /// nothing, so the caller gets a bad-request instead of an empty page.
75
+ const KNOWN_ROLES = new Set(['me', 'agent', 'tool'])
76
+
77
+ function parseRoles(value) {
78
+ if (value === undefined) return { roles: undefined }
79
+ if (!Array.isArray(value) || value.length === 0) return { roles: undefined }
80
+ const roles = new Set(value)
81
+ for (const role of roles) {
82
+ if (!KNOWN_ROLES.has(role)) {
83
+ return { error: failure('gateway/bad-request', `unknown role "${String(role)}"`) }
84
+ }
85
+ }
86
+ return { roles }
87
+ }
88
+
89
+ /// A hit snippet with the highlight offsets the phone's list already renders.
90
+ ///
91
+ /// Offsets, not ranges: the value crosses the wire and a character offset stays
92
+ /// meaningful on the other side.
93
+ function makeSnippet(text, query, radius = 36) {
94
+ const haystack = String(text ?? '')
95
+ const at = haystack.toLowerCase().indexOf(query.toLowerCase())
96
+ if (at < 0) {
97
+ const head = haystack.slice(0, radius * 2).replace(/\s+/g, ' ').trim()
98
+ return { snippet: head, highlightStart: 0, highlightLength: 0 }
99
+ }
100
+ const start = Math.max(0, at - radius)
101
+ const end = Math.min(haystack.length, at + query.length + radius)
102
+ const snippet = haystack.slice(start, end).replace(/\s+/g, ' ').trim()
103
+ // Re-find the match inside the flattened snippet so the offsets line up with
104
+ // what is actually displayed.
105
+ const flatAt = snippet.toLowerCase().indexOf(query.toLowerCase())
106
+ return {
107
+ snippet,
108
+ highlightStart: flatAt < 0 ? 0 : flatAt,
109
+ highlightLength: flatAt < 0 ? 0 : query.length,
110
+ }
111
+ }
112
+
113
+ function failure(code, message, details = {}) {
114
+ return { ok: false, error: { code, message, details } }
115
+ }
116
+
117
+ /// 搜索命中项按契约只带 `seq`/`role`/`snippet`(不带记录本身),所以今天**没有**
118
+ /// 宿主日志可删。这里仍然逐条过一遍清洗:万一将来命中项带上记录(例如为了跳转
119
+ /// 预取),它就不会成为那条绕过统一口径的旁路。
120
+ function trimHitsForWire(hits) {
121
+ return hits.map((hit) => (hit?.record === undefined ? hit : trimRecordForWire(hit)))
122
+ }
123
+
124
+ /// Map a thrown Host/gateway error onto the contract's codes.
125
+ function hostFailure(error) {
126
+ const message = messageOf(error)
127
+ if (/timed out after/.test(message)) return failure('link/timeout', message)
128
+ if (/not found|no such session/i.test(message)) return failure('session/unknown', message)
129
+ return failure('gateway/failed', message)
130
+ }
131
+
132
+ /**
133
+ * @param {{ logger?: { warn?: Function, info?: Function } }} deps
134
+ */
135
+ export function createHistoryService({ logger = console } = {}) {
136
+ let sessionQuery
137
+ /// 会话日志快照(只给**按角色过滤**的读取用):一个会话读一次整份、本地过滤、短 TTL。
138
+ /// 它把"每条命中一次宿主调用"那道墙去掉——灌库一个会话从十几次往返变成一次读。
139
+ const snapshots = createSnapshotCache({
140
+ readSession: (sessionId) => withTimeout(sessionQuery.readSession(sessionId), HOST_CALL_TIMEOUT_MS, 'readSession'),
141
+ })
142
+ /// Set to false after the Host has told us its full-text index is closed
143
+ /// (`openAt: "never"`), so later searches go straight to the scan.
144
+ let indexSearchable = true
145
+ const inFlight = new Set()
146
+ const lastSeqCache = new Map()
147
+
148
+ /// The log's newest seq, cached briefly: it is only needed to clamp a request
149
+ /// that runs past the end (a session can be trimmed between two pages).
150
+ async function newestSeq(sessionId) {
151
+ const cached = lastSeqCache.get(sessionId)
152
+ if (cached !== undefined && Date.now() - cached.at < 5000) return cached.seq
153
+ const records = await withTimeout(sessionQuery.listEvents(sessionId), HOST_CALL_TIMEOUT_MS, 'listEvents')
154
+ const seq = records.length > 0 ? records[records.length - 1].seq : -1
155
+ lastSeqCache.set(sessionId, { at: Date.now(), seq })
156
+ return seq
157
+ }
158
+
159
+ /// One exact backward window ending at `seq` (inclusive).
160
+ ///
161
+ /// `clamped` is reported upward: when the session was trimmed, the page cannot
162
+ /// abut the phone's window, and the phone needs to know that the gap is the
163
+ /// log's doing rather than a hole it should treat as a bug (FR-002/FR-041).
164
+ async function readChunk(sessionId, seq) {
165
+ try {
166
+ const window = await withTimeout(
167
+ sessionQuery.readEvent({ sessionId, seq, before: WINDOW_CHUNK, after: 0 }),
168
+ HOST_CALL_TIMEOUT_MS,
169
+ 'readEvent',
170
+ )
171
+ return { window, clamped: false }
172
+ } catch (error) {
173
+ // Past the end of a trimmed log: clamp once and retry, never guess.
174
+ if (!/has no event at seq/.test(messageOf(error))) throw error
175
+ const newest = await newestSeq(sessionId)
176
+ if (newest < 0 || newest >= seq) throw error
177
+ lastSeqCache.delete(sessionId)
178
+ const window = await withTimeout(
179
+ sessionQuery.readEvent({ sessionId, seq: newest, before: WINDOW_CHUNK, after: 0 }),
180
+ HOST_CALL_TIMEOUT_MS,
181
+ 'readEvent(clamped)',
182
+ )
183
+ return { window, clamped: true }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * 一个 seq 段的**消息类文档**(`filterEvents`):比 `readEvent` 每块 51 条快约 20 倍/seq,
189
+ * 但只回消息类事件(user/assistant/tool 那一族)——角色过滤要的正是这些。
190
+ *
191
+ * 宿主没挂这个接口时(老宿主)回 undefined,`window()` 会退回原来的分块扫描。
192
+ */
193
+ async function readFilterChunk(sessionId, from, to) {
194
+ if (typeof sessionQuery?.filterEvents !== 'function') return undefined
195
+ const docs = await withTimeout(
196
+ sessionQuery.filterEvents(sessionId, [{ kind: 'seq', from, to }]),
197
+ HOST_CALL_TIMEOUT_MS,
198
+ 'filterEvents(window)',
199
+ )
200
+ return { docs: Array.isArray(docs) ? docs : [] }
201
+ }
202
+
203
+ /// One page of in-session literal search (spec 007 FR-010…FR-012).
204
+ ///
205
+ /// Preferred path is the Host's indexed `searchEvents`; this deployment ships it
206
+ /// disabled (`openAt: "never"`), so the working path is a bounded scan: chunks of
207
+ /// `SEARCH_CHUNK` seqs, filtered by seq range **and** the literal text, newest
208
+ /// first. Each call scans at most `MAX_SEARCH_CHUNKS_PER_CALL` chunks and hands
209
+ /// back a cursor, so "keep looking older" is the reader's next request rather
210
+ /// than one call that holds the tunnel.
211
+ async function find(args) {
212
+ const sessionId = idOfAddress(args.address)
213
+ if (sessionId === undefined) return failure('gateway/bad-request', 'address must name a session')
214
+ const query = typeof args.query === 'string' ? args.query.trim() : ''
215
+ const roles = Array.isArray(args.roles) && args.roles.length > 0 ? new Set(args.roles) : undefined
216
+ // 浏览模式:**没有关键词但带了 roles**(读者要看"我问过的全部提问")。
217
+ // 这时不按文本过滤,只按 seq 段扫,按角色筛。
218
+ const browsing = query.length === 0 && roles !== undefined
219
+ if (!browsing && query.length < MIN_QUERY_LENGTH) {
220
+ return failure('link/query-too-short', `query must be at least ${MIN_QUERY_LENGTH} characters`)
221
+ }
222
+ const limit = Number.isSafeInteger(Number(args.limit)) && Number(args.limit) > 0
223
+ ? Math.min(Number(args.limit), 200) : DEFAULT_SEARCH_LIMIT
224
+ // Advanced knob (also used by the measurement route and the tests): a smaller
225
+ // chunk makes the cursor path reachable on short sessions. Clamped, never 0.
226
+ const chunk = Number.isSafeInteger(Number(args.chunk)) && Number(args.chunk) > 0
227
+ ? Math.min(Number(args.chunk), SEARCH_CHUNK) : SEARCH_CHUNK
228
+ const key = `find:${sessionId}`
229
+ if (inFlight.has(key)) return failure('link/busy', 'another search is in flight for this session')
230
+ inFlight.add(key)
231
+ try {
232
+ // Fast path when the deployment has the index open. One attempt, and a
233
+ // refusal is remembered: the scan below is the path that always works.
234
+ if (indexSearchable && args.cursor === undefined && typeof sessionQuery.searchEvents === 'function') {
235
+ try {
236
+ const page = await withTimeout(
237
+ sessionQuery.searchEvents({ sessionId, query, limit }),
238
+ HOST_CALL_TIMEOUT_MS,
239
+ 'searchEvents',
240
+ )
241
+ const indexed = (page.items ?? [])
242
+ .map((item) => {
243
+ const role = roleOf(item)
244
+ if (roles !== undefined && (role === undefined || !roles.has(role))) return undefined
245
+ const display = displayRoleOf(item)
246
+ if (display === undefined) return undefined
247
+ return { seq: item.seq, role: display, ...makeSnippet(item.snippet ?? item.text ?? '', query) }
248
+ })
249
+ .filter(Boolean)
250
+ return {
251
+ ok: true,
252
+ value: {
253
+ hits: trimHitsForWire(indexed),
254
+ cursor: page.hasMore === true && page.cursor !== undefined ? String(page.cursor) : null,
255
+ hasMore: page.hasMore === true,
256
+ source: 'index',
257
+ },
258
+ }
259
+ } catch (error) {
260
+ indexSearchable = false
261
+ logger.info?.(`mobile-link: full-text index unavailable, scanning instead: ${messageOf(error)}`)
262
+ }
263
+ }
264
+ const from = parseSearchCursor(args.cursor)
265
+ const ceiling = from !== undefined ? from : await newestSeq(sessionId)
266
+ const hits = []
267
+ let to = ceiling
268
+ let scanned = 0
269
+ while (to >= 0 && hits.length < limit && scanned < MAX_SEARCH_CHUNKS_PER_CALL) {
270
+ const chunkFrom = Math.max(0, to - chunk + 1)
271
+ const filters = [{ kind: 'seq', from: chunkFrom, to }]
272
+ if (!browsing) filters.push({ kind: 'text', text: query })
273
+ const docs = await withTimeout(
274
+ sessionQuery.filterEvents(sessionId, filters),
275
+ HOST_CALL_TIMEOUT_MS,
276
+ 'filterEvents(search)',
277
+ )
278
+ scanned += 1
279
+ // Newest first inside the chunk: the reader asked about something recent
280
+ // far more often than about something old.
281
+ for (let index = docs.length - 1; index >= 0; index -= 1) {
282
+ const doc = docs[index]
283
+ // **已知局限**:这里只能按文档判角色,而宿主的 `filterEvents` 文档不带 `data.source`,
284
+ // 所以 `roles:['me']` 会把 `source.kind = "agent-instructions"` 这类**注入消息**也算进来
285
+ // (真机实测:AGENTS.md 注入那条被判成 me)。窗口路径(`_link/sessionWindow`)已改成
286
+ // 取回原始事件后复判,这里没有原始事件、逐条回读的代价又太大(50 条命中 ≈ 11 秒),
287
+ // 因此保留现状并在 `session-search` 的说明里标注:**角色过滤对注入消息不可靠**。
288
+ // App 侧「只看我的提问」自 T040 起走本地库,不走这条路;要恢复这条能力需先让宿主
289
+ // 在文档里带上 `data.source`。
290
+ const role = roleOf(doc)
291
+ if (roles !== undefined && (role === undefined || !roles.has(role))) continue
292
+ const display = displayRoleOf(doc)
293
+ if (display === undefined) continue
294
+ 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(),
297
+ highlightStart: 0, highlightLength: 0 }) })
298
+ if (hits.length >= limit) break
299
+ }
300
+ to = chunkFrom - 1
301
+ }
302
+ const hasMore = to >= 0 && hits.length >= limit ? true : to >= 0
303
+ return {
304
+ ok: true,
305
+ value: {
306
+ hits: trimHitsForWire(hits),
307
+ cursor: hasMore ? String(to) : null,
308
+ hasMore,
309
+ scannedChunks: scanned,
310
+ source: 'scan',
311
+ },
312
+ }
313
+ } catch (error) {
314
+ logger.warn?.(`mobile-link: sessionFind failed: ${messageOf(error)}`)
315
+ return hostFailure(error)
316
+ } finally {
317
+ inFlight.delete(key)
318
+ }
319
+ }
320
+
321
+ /// The cursor is the next `to` seq — opaque to the phone, validated here.
322
+ function parseSearchCursor(cursor) {
323
+ if (typeof cursor !== 'string' || cursor.length === 0) return undefined
324
+ const value = Number(cursor)
325
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined
326
+ }
327
+
328
+ async function window(args) {
329
+ const sessionId = idOfAddress(args.address)
330
+ const before = Number(args.before)
331
+ if (sessionId === undefined) return failure('gateway/bad-request', 'address must name a session')
332
+ if (!Number.isSafeInteger(before) || before < 0) return failure('gateway/bad-request', 'before must be a non-negative integer')
333
+ const limit = Number.isSafeInteger(Number(args.limit)) && Number(args.limit) > 0 ? Number(args.limit) : DEFAULT_LIMIT
334
+ const maxBytes = Number.isSafeInteger(Number(args.maxBytes)) && Number(args.maxBytes) > 0
335
+ ? Number(args.maxBytes) : DEFAULT_MAX_BYTES
336
+ const through = Number.isSafeInteger(Number(args.through)) ? Number(args.through) : undefined
337
+ // Optional role filter (spec 009 T031): e.g. `roles: ["me"]` returns only
338
+ // the caller's own questions. Filtering affects **which records come back,
339
+ // never the cursor**: the window is still read as contiguous seqs and
340
+ // `before` still bounds the newest edge exactly. Since T039 a filtered page
341
+ // also carries `scanBoundary` (see history/window.js) so a caller can keep
342
+ // moving when a whole page is filtered empty.
343
+ const { roles, error: rolesError } = parseRoles(args.roles)
344
+ if (rolesError) return rolesError
345
+
346
+ const key = `window:${sessionId}`
347
+ if (inFlight.has(key)) return failure('link/busy', 'another window read is in flight for this session')
348
+ inFlight.add(key)
349
+ try {
350
+ // 按角色过滤时走"先定位命中 seq、再定向取完整记录"(spec 016 的"来回次数"一条):
351
+ // 同样一段日志,过滤路径实测从 2.5–4.1 s 降到数百毫秒;未过滤的浏览翻页保持原样
352
+ // (它要的是精确的一段原始事件,`readEvent` 每块 51 条正合适)。
353
+ const useFilterScan = roles !== undefined && typeof sessionQuery?.filterEvents === 'function'
354
+ // 三级:① 角色过滤 + 宿主能整份读 → 从快照出(一次读、之后每页零往返);
355
+ // ② 角色过滤但快照不可用(没有 readSession / 太大 / 读失败)→ A1 的分块定位;
356
+ // ③ 不带角色 → 原来的分块扫描(浏览翻页要的是精确的一段原始事件)。
357
+ const snapshot = roles !== undefined && typeof sessionQuery?.readSession === 'function'
358
+ ? await snapshots.get(sessionId)
359
+ : undefined
360
+ const scan = snapshot !== undefined
361
+ ? scanSnapshot(snapshot, { before, through, limit, maxBytes, roles, roleOf })
362
+ : useFilterScan
363
+ ? await filteredScan({
364
+ readFilterChunk, readRecords: readChunk, newestSeq, sessionId, before, through, limit,
365
+ roles, roleOf,
366
+ })
367
+ : await scanOlder({ readChunk, sessionId, before, through, limit, maxBytes, roles })
368
+ const page = pageFrom(scan, { limit, maxBytes, roles })
369
+ // 016:下发前统一清掉只用于诊断的宿主模型输出日志(口径只有一份,
370
+ // 见 specs/016-history-load-perf/contracts/history-record-trim.md)。
371
+ return { ok: true, value: { ...page, records: trimRecordsForWire(page.records) } }
372
+ } catch (error) {
373
+ logger.warn?.(`mobile-link: sessionWindow failed: ${messageOf(error)}`)
374
+ return hostFailure(error)
375
+ } finally {
376
+ inFlight.delete(key)
377
+ }
378
+ }
379
+
380
+ return {
381
+ /// Called from an optional injection; absent on Hosts that predate the service.
382
+ bindQuery(service) {
383
+ sessionQuery = service
384
+ logger.info?.('mobile-link: history service bound to ctx.sessionQuery')
385
+ },
386
+ get available() {
387
+ return sessionQuery !== undefined
388
+ },
389
+
390
+ /// Frame handler for the two methods.
391
+ async handle(method, args = {}) {
392
+ if (sessionQuery === undefined) {
393
+ return failure('link/unsupported', 'this Host does not expose the session query service')
394
+ }
395
+ if (method === WINDOW_METHOD) return window(args)
396
+ if (method === FIND_METHOD) return find(args)
397
+ return failure('link/unknown-method', String(method))
398
+ },
399
+
400
+ }
401
+ }
package/lib/index.js CHANGED
@@ -7,12 +7,15 @@
7
7
  * never been registered, redeem the invite code from the environment (or
8
8
  * the config) first, so a fresh install becomes usable without the relay
9
9
  * operator provisioning anything by hand.
10
- * 2. Three routes under `/mobile-link` behind DSH's own connection fence:
10
+ * 2. Two routes under `/mobile-link` behind DSH's own connection fence:
11
11
  * GET /status — is the link up, which devices are attached, and is
12
12
  * this computer registered yet
13
- * POST /pair-code — mint a pairing code + QR payload through the relay
14
- * GET /qr — the same code rendered as a scannable QR SVG
13
+ * POST /pair-code — mint a pairing code through the relay, with the QR
14
+ * payload and the symbol rendered alongside it
15
15
  *
16
+ * The pairing screen itself lives in the client half (`lib/client.js`, spec 010):
17
+ * a standing button in the sidebar plus a panel that shows the code, its QR and
18
+ * the paired phones. There is no browser page for pairing any more.
16
19
  * Deliberate design choices:
17
20
  * * The plugin declares **no required services** (`inject` is empty). The DLP
18
21
  * agent must run even if a future DSH renames or drops `webServer` /
@@ -28,6 +31,7 @@
28
31
 
29
32
  import { MobileLinkAgent } from './link.js'
30
33
  import { registerMobileLinkRoutes } from './routes.js'
34
+ import { createHistoryService } from './history.js'
31
35
 
32
36
  /** Cordis function-plugin name (the loader row id is separate). */
33
37
  export const name = 'mobile-link'
@@ -44,6 +48,14 @@ const DEFAULTS = {
44
48
  stateFile: '',
45
49
  dshUrl: '',
46
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,
47
59
  heartbeatMs: 20000,
48
60
  pongTimeoutMs: 60000,
49
61
  maxBackoffMs: 30000,
@@ -92,6 +104,14 @@ function buildAgent(config, logger) {
92
104
  // otherwise the agent reads DSH_MOBILE_LINK_INVITE (see enroll.js).
93
105
  inviteCode: typeof config.inviteCode === 'string' && config.inviteCode ? config.inviteCode : undefined,
94
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,
95
115
  heartbeatMs: Number(config.heartbeatMs) || DEFAULTS.heartbeatMs,
96
116
  pongTimeoutMs: Number(config.pongTimeoutMs) || DEFAULTS.pongTimeoutMs,
97
117
  maxBackoffMs: Number(config.maxBackoffMs) || DEFAULTS.maxBackoffMs,
@@ -116,10 +136,10 @@ function startAgent(ctx, agent, logger) {
116
136
  }
117
137
 
118
138
  /** 三条本机 HTTP 路由;只依赖 `webServer`,缺了也不影响链路。 */
119
- function registerRoutes(ctx, agent, logger, config) {
139
+ function registerRoutes(ctx, agent, logger, config, history) {
120
140
  try {
121
141
  ctx.inject(['webServer'], (scope) => {
122
- const disposers = registerMobileLinkRoutes(scope, { agent, logger, config })
142
+ const disposers = registerMobileLinkRoutes(scope, { agent, logger, config, history })
123
143
  scope.effect?.(() => () => {
124
144
  for (const dispose of disposers) {
125
145
  try {
@@ -154,6 +174,22 @@ function wireEndpointResolver(ctx, agent, logger) {
154
174
  }
155
175
  }
156
176
 
177
+ /**
178
+ * 把宿主的会话查询服务交给历史能力(**可选注入**)。
179
+ *
180
+ * 拿不到就保持"不支持":宿主里没有这个服务(旧版本)时,历史能力回 `link/unsupported`,
181
+ * 手机端继续走 `session/page`。这条注入刻意独立于链路本身——注入失败不影响连接器运行。
182
+ */
183
+ function wireSessionQuery(ctx, history, logger) {
184
+ try {
185
+ ctx.inject(['sessionQuery'], (scope) => {
186
+ history.bindQuery(scope.sessionQuery)
187
+ })
188
+ } catch (error) {
189
+ logger.warn?.(`mobile-link: session query unavailable (history falls back): ${error}`)
190
+ }
191
+ }
192
+
157
193
  /**
158
194
  * @param {import('@deepseek-ai/cordis').Context} ctx
159
195
  * @param {Record<string, unknown>} rawConfig
@@ -162,10 +198,13 @@ export function apply(ctx, rawConfig) {
162
198
  const config = { ...DEFAULTS, ...asRecord(rawConfig) }
163
199
  const logger = ctx.logger ?? console
164
200
  const agent = buildAgent(config, logger)
201
+ const history = createHistoryService({ logger })
202
+ agent.setHistoryService?.(history)
165
203
 
166
204
  startAgent(ctx, agent, logger)
167
- registerRoutes(ctx, agent, logger, config)
205
+ registerRoutes(ctx, agent, logger, config, history)
168
206
  wireEndpointResolver(ctx, agent, logger)
207
+ wireSessionQuery(ctx, history, logger)
169
208
  logger.info?.(`mobile-link: ready (relay ${config.relayUrl})`)
170
209
  }
171
210