dsh-plugin-mobile-link 0.6.5 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/cordis.patch.yml +6 -0
- package/lib/client.js +101 -41
- package/lib/dir-listing.js +167 -0
- package/lib/dlp.js +28 -2
- package/lib/enroll.js +2 -2
- package/lib/files.js +118 -12
- package/lib/git.js +20 -5
- package/lib/hello.js +53 -3
- package/lib/history/compress.js +40 -0
- package/lib/history/coverage.js +21 -0
- package/lib/history/fold-summary.js +472 -0
- package/lib/history/fold.js +184 -0
- package/lib/history/session-snapshot.js +75 -24
- package/lib/history/summary-window.js +168 -0
- package/lib/history.js +243 -16
- package/lib/index.js +29 -0
- package/lib/jobs-roster.js +377 -0
- package/lib/link.js +68 -7
- package/lib/logs.js +242 -0
- package/lib/pairing.js +60 -9
- package/lib/router.js +170 -5
- package/lib/routes.js +34 -2
- package/lib/session-digest.js +59 -4
- package/lib/state.js +3 -3
- package/lib/status.js +4 -1
- package/lib/unicode.js +116 -0
- package/package.json +1 -1
package/lib/history.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { HOST_CALL_TIMEOUT_MS, withTimeout, messageOf } from './history/shared.js'
|
|
2
|
+
import { sliceRangeSafe, truncateSafe } from './unicode.js'
|
|
2
3
|
import {
|
|
3
4
|
DEFAULT_LIMIT, DEFAULT_MAX_BYTES, WINDOW_CHUNK,
|
|
4
5
|
bytesOfRecords, displayRoleOf, roleOf, toRecord, trimRecordForWire, trimRecordsForWire,
|
|
5
6
|
} from './history/records.js'
|
|
6
7
|
import { pageFrom, scanOlder } from './history/window.js'
|
|
7
8
|
import { filteredScan } from './history/filter-scan.js'
|
|
8
|
-
import { createSnapshotCache, scanSnapshot } from './history/session-snapshot.js'
|
|
9
|
+
import { createSnapshotCache, scanSnapshot, snapshotOlderReader } from './history/session-snapshot.js'
|
|
10
|
+
import {
|
|
11
|
+
HISTORY_COMPRESSION_CAPABILITY, maybeCompressHistoryValue,
|
|
12
|
+
} from './history/compress.js'
|
|
13
|
+
import { FOLD_CAPABILITY, foldProcessRuns } from './history/fold.js'
|
|
14
|
+
import { SUMMARY_FOLD_CAPABILITY, SUMMARY_FOLD_MODE } from './history/fold-summary.js'
|
|
15
|
+
import { readSummaryPage } from './history/summary-window.js'
|
|
16
|
+
import { emptySpans } from './history/coverage.js'
|
|
9
17
|
|
|
10
18
|
// Re-exported for the caller/tests that read them off this module (single source
|
|
11
19
|
// is `history/records.js`; the call ceiling lives in `history/shared.js`).
|
|
@@ -46,6 +54,21 @@ export const FIND_METHOD = '_link/sessionFind'
|
|
|
46
54
|
/// does not read the field keeps the old cursor rule.
|
|
47
55
|
export const HISTORY_CAPABILITIES = [
|
|
48
56
|
'history-window', 'session-search', 'history-role-filter', 'history-scan-boundary',
|
|
57
|
+
// search-progress: a scanned page also carries `totalChunks` / `remainingChunks`,
|
|
58
|
+
// so the reader can be told "已扫 k/N 片" from the first page on. Purely additive:
|
|
59
|
+
// `totalChunks` comes from the log's newest seq, which a scan already fetches.
|
|
60
|
+
'search-progress',
|
|
61
|
+
// history-compression: `_link/sessionWindow` 接受 `encoding: "deflate-raw"`,
|
|
62
|
+
// 回包变成 `{encoding, value(base64), rawBytes}`。手机只在看到这条能力时才请求;
|
|
63
|
+
// 老客户端不传 encoding,拿到的仍是纯 JSON。
|
|
64
|
+
HISTORY_COMPRESSION_CAPABILITY,
|
|
65
|
+
// history-fold-summary: `_link/sessionWindow` 接受 `fold: true`,把本页内
|
|
66
|
+
// 已完成的轮次折成一条 `+link/fold` 摘要记录(展开时用不带 fold 的普通窗口取回)。
|
|
67
|
+
FOLD_CAPABILITY,
|
|
68
|
+
// history-fold-summary-v2: `fold: "summary"` —— 概要先行取数模式(设计
|
|
69
|
+
// 20261025 §3.4/M6)。只回讲话 + `+link/fold` v2 概要 + 正式回答,按整轮切页。
|
|
70
|
+
// 独立能力位:老连接器不认识这个值,老客户端也不会传它,两边都没有行为变化。
|
|
71
|
+
SUMMARY_FOLD_CAPABILITY,
|
|
49
72
|
]
|
|
50
73
|
|
|
51
74
|
export function isHistoryMethod(method) {
|
|
@@ -59,7 +82,15 @@ export function isHistoryMethod(method) {
|
|
|
59
82
|
export const SEARCH_CHUNK = 2000
|
|
60
83
|
export const MAX_SEARCH_CHUNKS_PER_CALL = 4
|
|
61
84
|
export const DEFAULT_SEARCH_LIMIT = 50
|
|
62
|
-
|
|
85
|
+
/// 查询下限:**1 个字符**(owner 2026-10-17 决定)。
|
|
86
|
+
///
|
|
87
|
+
/// 原来卡在 2 个字,理由是「一个字几乎命中整段历史、翻不完」。这个代价在 2026-10-17
|
|
88
|
+
/// 变成可接受:搜索有了分页与片数进度(`search-progress`,界面显示「已扫 k/N 片」),
|
|
89
|
+
/// 一页仍只回 50 条、一次调用仍最多扫 4 块(≈1.3 秒),所以下限降到 1。
|
|
90
|
+
///
|
|
91
|
+
/// 仍然拒绝的是**空关键词且没带 roles**:那不是搜索,是一次没有范围的全表扫描
|
|
92
|
+
/// (「浏览我的提问」必须带 roles,见 `browsing`)。
|
|
93
|
+
export const MIN_QUERY_LENGTH = 1
|
|
63
94
|
|
|
64
95
|
function idOfAddress(address) {
|
|
65
96
|
if (address === null || typeof address !== 'object') return undefined
|
|
@@ -86,6 +117,29 @@ function parseRoles(value) {
|
|
|
86
117
|
return { roles }
|
|
87
118
|
}
|
|
88
119
|
|
|
120
|
+
/// 一条文档里所有可读正文:宿主给的 `text`、`data.text`、`data.content[].text`
|
|
121
|
+
/// (blocks 形态,可能**不止一段**)、`data.message.content[].text`,以及工具结果的
|
|
122
|
+
/// `output` / `result` / `stdout` / `stderr`。
|
|
123
|
+
///
|
|
124
|
+
/// 为什么自己做匹配、不把 `{kind:'text'}` 交给宿主:宿主的文本过滤只看它自己认定的那一段,
|
|
125
|
+
/// 2026-10-17 真机实测(搜"我"/"的"拿不到全部用户输入)说明会漏掉 `content` 里靠后的部分。
|
|
126
|
+
/// 改成"取回这一段全部文档、在这里逐字匹配"之后,命中集合只由我们自己的规则决定。
|
|
127
|
+
function docText(doc) {
|
|
128
|
+
const data = doc?.data ?? {}
|
|
129
|
+
const parts = []
|
|
130
|
+
const push = (value) => {
|
|
131
|
+
if (typeof value === 'string' && value.length > 0) parts.push(value)
|
|
132
|
+
}
|
|
133
|
+
push(doc?.text)
|
|
134
|
+
push(data.text)
|
|
135
|
+
for (const blocks of [data.content, data.message?.content]) {
|
|
136
|
+
if (!Array.isArray(blocks)) continue
|
|
137
|
+
for (const block of blocks) push(block?.text)
|
|
138
|
+
}
|
|
139
|
+
for (const key of ['output', 'result', 'stdout', 'stderr']) push(data[key])
|
|
140
|
+
return parts.join('\n')
|
|
141
|
+
}
|
|
142
|
+
|
|
89
143
|
/// A hit snippet with the highlight offsets the phone's list already renders.
|
|
90
144
|
///
|
|
91
145
|
/// Offsets, not ranges: the value crosses the wire and a character offset stays
|
|
@@ -94,12 +148,12 @@ function makeSnippet(text, query, radius = 36) {
|
|
|
94
148
|
const haystack = String(text ?? '')
|
|
95
149
|
const at = haystack.toLowerCase().indexOf(query.toLowerCase())
|
|
96
150
|
if (at < 0) {
|
|
97
|
-
const head = haystack
|
|
151
|
+
const head = truncateSafe(haystack, radius * 2).replace(/\s+/g, ' ').trim()
|
|
98
152
|
return { snippet: head, highlightStart: 0, highlightLength: 0 }
|
|
99
153
|
}
|
|
100
154
|
const start = Math.max(0, at - radius)
|
|
101
155
|
const end = Math.min(haystack.length, at + query.length + radius)
|
|
102
|
-
const snippet = haystack
|
|
156
|
+
const snippet = sliceRangeSafe(haystack, start, end).replace(/\s+/g, ' ').trim()
|
|
103
157
|
// Re-find the match inside the flattened snippet so the offsets line up with
|
|
104
158
|
// what is actually displayed.
|
|
105
159
|
const flatAt = snippet.toLowerCase().indexOf(query.toLowerCase())
|
|
@@ -132,7 +186,11 @@ function hostFailure(error) {
|
|
|
132
186
|
/**
|
|
133
187
|
* @param {{ logger?: { warn?: Function, info?: Function } }} deps
|
|
134
188
|
*/
|
|
135
|
-
export function createHistoryService({
|
|
189
|
+
export function createHistoryService({
|
|
190
|
+
logger = console,
|
|
191
|
+
cacheTtlMs = 15_000,
|
|
192
|
+
cacheLimit = 32,
|
|
193
|
+
} = {}) {
|
|
136
194
|
let sessionQuery
|
|
137
195
|
/// 会话日志快照(只给**按角色过滤**的读取用):一个会话读一次整份、本地过滤、短 TTL。
|
|
138
196
|
/// 它把"每条命中一次宿主调用"那道墙去掉——灌库一个会话从十几次往返变成一次读。
|
|
@@ -144,6 +202,42 @@ export function createHistoryService({ logger = console } = {}) {
|
|
|
144
202
|
let indexSearchable = true
|
|
145
203
|
const inFlight = new Set()
|
|
146
204
|
const lastSeqCache = new Map()
|
|
205
|
+
/// 最近读过的页(spec 016 后续):回看、回收后再水合、重复请求同一段时不再走网络。
|
|
206
|
+
/// 只缓存"已经结束在 before-1"的页:日志只追加,旧 seq 段的内容不会变;
|
|
207
|
+
/// 会话被裁剪是唯一例外,用短 TTL 兜底(默认 15s)。键里带全部影响结果的参数,
|
|
208
|
+
/// 少一个就可能把过滤页当成浏览页返回。
|
|
209
|
+
const pageCache = new Map()
|
|
210
|
+
|
|
211
|
+
/// 缓存键里带**折叠模式**(`none` / `v1` / `summary`)而不是布尔值:三种模式的回包
|
|
212
|
+
/// 形状不同,混用一个键会把概要页当普通页发出去。
|
|
213
|
+
function cacheKey({ sessionId, before, through, limit, maxBytes, roles, foldMode, coverage }) {
|
|
214
|
+
return [sessionId, before, through ?? '', limit, maxBytes,
|
|
215
|
+
roles === undefined ? '' : [...roles].sort().join(','), foldMode,
|
|
216
|
+
coverage === true ? '1' : '0'].join('|')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function cacheGet(key, now = Date.now()) {
|
|
220
|
+
const hit = pageCache.get(key)
|
|
221
|
+
if (hit === undefined) return undefined
|
|
222
|
+
if (now - hit.at > cacheTtlMs) {
|
|
223
|
+
pageCache.delete(key)
|
|
224
|
+
return undefined
|
|
225
|
+
}
|
|
226
|
+
// 重新插入:Map 的插入序当作 LRU 序,命中也要刷新位置。
|
|
227
|
+
pageCache.delete(key)
|
|
228
|
+
pageCache.set(key, hit)
|
|
229
|
+
return hit.value
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function cachePut(key, value, now = Date.now()) {
|
|
233
|
+
if (cacheLimit <= 0) return
|
|
234
|
+
pageCache.delete(key)
|
|
235
|
+
pageCache.set(key, { at: now, value })
|
|
236
|
+
while (pageCache.size > cacheLimit) {
|
|
237
|
+
const oldest = pageCache.keys().next().value
|
|
238
|
+
pageCache.delete(oldest)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
147
241
|
|
|
148
242
|
/// The log's newest seq, cached briefly: it is only needed to clamp a request
|
|
149
243
|
/// that runs past the end (a session can be trimmed between two pages).
|
|
@@ -217,7 +311,8 @@ export function createHistoryService({ logger = console } = {}) {
|
|
|
217
311
|
// 这时不按文本过滤,只按 seq 段扫,按角色筛。
|
|
218
312
|
const browsing = query.length === 0 && roles !== undefined
|
|
219
313
|
if (!browsing && query.length < MIN_QUERY_LENGTH) {
|
|
220
|
-
return failure('link/query-too-short',
|
|
314
|
+
return failure('link/query-too-short',
|
|
315
|
+
`query must be at least ${MIN_QUERY_LENGTH} character (or pass roles to browse)`)
|
|
221
316
|
}
|
|
222
317
|
const limit = Number.isSafeInteger(Number(args.limit)) && Number(args.limit) > 0
|
|
223
318
|
? Math.min(Number(args.limit), 200) : DEFAULT_SEARCH_LIMIT
|
|
@@ -261,15 +356,25 @@ export function createHistoryService({ logger = console } = {}) {
|
|
|
261
356
|
logger.info?.(`mobile-link: full-text index unavailable, scanning instead: ${messageOf(error)}`)
|
|
262
357
|
}
|
|
263
358
|
}
|
|
359
|
+
const needle = query.toLowerCase()
|
|
264
360
|
const from = parseSearchCursor(args.cursor)
|
|
265
|
-
|
|
361
|
+
// `newestSeq` also carries the progress denominator below; it is cached for
|
|
362
|
+
// 5s, and a scan would have fetched it anyway on the first page.
|
|
363
|
+
const newest = await newestSeq(sessionId)
|
|
364
|
+
// 游标是上一页留下的 seq,可能**越过**当前最新(日志被裁剪过、或手机拿着旧游标回来)。
|
|
365
|
+
// 越界时不夹住的话,扫描会从一个不存在的 seq 往下空跑满 4 块,`remainingChunks`
|
|
366
|
+
// 还会算出比 `totalChunks` 更大的数(实测 996/2),界面的"已扫 k/N"随即变成假的。
|
|
367
|
+
const cursorSeq = from !== undefined && newest >= 0 ? Math.min(from, newest) : from
|
|
368
|
+
const ceiling = cursorSeq !== undefined ? cursorSeq : newest
|
|
369
|
+
// 片数(进度的分母):由日志最新 seq 算出,与游标无关,所以第一页就是精确的。
|
|
370
|
+
const total = newest >= 0 ? Math.floor(newest / chunk) + 1 : 0
|
|
266
371
|
const hits = []
|
|
267
372
|
let to = ceiling
|
|
268
373
|
let scanned = 0
|
|
269
374
|
while (to >= 0 && hits.length < limit && scanned < MAX_SEARCH_CHUNKS_PER_CALL) {
|
|
270
375
|
const chunkFrom = Math.max(0, to - chunk + 1)
|
|
376
|
+
// 只按 seq 段取文档;正文匹配在下面自己做(见 `docText` 的头注)。
|
|
271
377
|
const filters = [{ kind: 'seq', from: chunkFrom, to }]
|
|
272
|
-
if (!browsing) filters.push({ kind: 'text', text: query })
|
|
273
378
|
const docs = await withTimeout(
|
|
274
379
|
sessionQuery.filterEvents(sessionId, filters),
|
|
275
380
|
HOST_CALL_TIMEOUT_MS,
|
|
@@ -278,6 +383,12 @@ export function createHistoryService({ logger = console } = {}) {
|
|
|
278
383
|
scanned += 1
|
|
279
384
|
// Newest first inside the chunk: the reader asked about something recent
|
|
280
385
|
// far more often than about something old.
|
|
386
|
+
//
|
|
387
|
+
// **游标必须落在"这一页读到哪儿"**,不能一律跳到整块之前:一页在块中途装满时,
|
|
388
|
+
// 块尾还没读过的文档会被永久跳过——2026-10-17 owner 报的"全局搜索不是用户提问的超集"
|
|
389
|
+
// 就是这条(用户提问正好落在片尾时被吞掉;索引路径不受影响)。
|
|
390
|
+
// `resumeFrom` 默认 = 整块之前(读完这块才成立),中途停下时改成"下一条还没读的 seq"。
|
|
391
|
+
let resumeFrom = chunkFrom - 1
|
|
281
392
|
for (let index = docs.length - 1; index >= 0; index -= 1) {
|
|
282
393
|
const doc = docs[index]
|
|
283
394
|
// **已知局限**:这里只能按文档判角色,而宿主的 `filterEvents` 文档不带 `data.source`,
|
|
@@ -291,13 +402,20 @@ export function createHistoryService({ logger = console } = {}) {
|
|
|
291
402
|
if (roles !== undefined && (role === undefined || !roles.has(role))) continue
|
|
292
403
|
const display = displayRoleOf(doc)
|
|
293
404
|
if (display === undefined) continue
|
|
405
|
+
const text = docText(doc)
|
|
406
|
+
if (!browsing && !text.toLowerCase().includes(needle)) continue
|
|
294
407
|
hits.push({ seq: doc.seq, role: display, ...(query.length > 0
|
|
295
|
-
? makeSnippet(
|
|
296
|
-
: { snippet: String(
|
|
408
|
+
? makeSnippet(text, query)
|
|
409
|
+
: { snippet: truncateSafe(String(text), 72).replace(/\s+/g, ' ').trim(),
|
|
297
410
|
highlightStart: 0, highlightLength: 0 }) })
|
|
298
|
-
if (hits.length >= limit)
|
|
411
|
+
if (hits.length >= limit) {
|
|
412
|
+
// 装满了:这一条已经收进本页,下一页从它的**前一条**继续——不能从整块之前
|
|
413
|
+
// (会丢块尾),也不能从这一条本身(会重复)。
|
|
414
|
+
resumeFrom = doc.seq - 1
|
|
415
|
+
break
|
|
416
|
+
}
|
|
299
417
|
}
|
|
300
|
-
to =
|
|
418
|
+
to = resumeFrom
|
|
301
419
|
}
|
|
302
420
|
const hasMore = to >= 0 && hits.length >= limit ? true : to >= 0
|
|
303
421
|
return {
|
|
@@ -307,6 +425,12 @@ export function createHistoryService({ logger = console } = {}) {
|
|
|
307
425
|
cursor: hasMore ? String(to) : null,
|
|
308
426
|
hasMore,
|
|
309
427
|
scannedChunks: scanned,
|
|
428
|
+
// 进度:片数是**扫描单位**(一段 `chunk` 个 seq,从新往旧),不是命中页数。
|
|
429
|
+
// `totalChunks` 由日志最新 seq 算出,所以从第一页起就是精确的;命中的**条数**
|
|
430
|
+
// 仍然要扫到最早那一片才定稿(不开宿主全文索引的必然结果)。
|
|
431
|
+
totalChunks: total,
|
|
432
|
+
// 剩余片数不可能多于总片数:越界游标、被裁剪的日志都不该让进度超过 100%。
|
|
433
|
+
remainingChunks: to >= 0 ? Math.min(Math.floor(to / chunk) + 1, total) : 0,
|
|
310
434
|
source: 'scan',
|
|
311
435
|
},
|
|
312
436
|
}
|
|
@@ -325,11 +449,60 @@ export function createHistoryService({ logger = console } = {}) {
|
|
|
325
449
|
return Number.isSafeInteger(value) && value >= 0 ? value : undefined
|
|
326
450
|
}
|
|
327
451
|
|
|
452
|
+
/// 概要 v2 的一页(能力 `history-fold-summary-v2`,`fold: "summary"`)。
|
|
453
|
+
///
|
|
454
|
+
/// 为什么走**整份快照**:这一页的单位是"整轮",而一轮可以长达两千级 seq。
|
|
455
|
+
/// 分块回读一次只有 51 条(宿主 `readEvent` 的硬上限),一页要几十次往返;
|
|
456
|
+
/// 快照一次读完整份(本机 13k 事件实测 116–315 ms),之后每页零往返。
|
|
457
|
+
/// 快照不可用(老宿主没 `readSession`、会话超过内存护栏、读失败)时退回分块回读:
|
|
458
|
+
/// 语义不变(仍按整轮、仍不切半轮),只是慢一些,并且在单轮超过回读预算时
|
|
459
|
+
/// 如实回 `link/too-large` 让客户端重试,而不是下发半轮。
|
|
460
|
+
async function summaryWindowValue({ sessionId, before, through, limit, maxBytes }) {
|
|
461
|
+
let snapshot = await snapshots.get(sessionId)
|
|
462
|
+
// 与浏览翻页同一条护栏:快照必须是"这段的上界之内"的。请求上界比快照还新
|
|
463
|
+
// (刚发出去一条消息,或快照刚好过期一瞬)时不能拿旧快照夹上界——那会把
|
|
464
|
+
// 最新的一轮整段漏掉,还谎报 `clamped`("日志变短了")。改走实时读。
|
|
465
|
+
if (snapshot !== undefined && before - 1 > snapshot.newestSeq) snapshot = undefined
|
|
466
|
+
let newest = snapshot?.newestSeq
|
|
467
|
+
if (!Number.isInteger(newest)) newest = await newestSeq(sessionId)
|
|
468
|
+
let upper = Math.min(before - 1, through ?? Number.MAX_SAFE_INTEGER)
|
|
469
|
+
let clamped = false
|
|
470
|
+
if (Number.isInteger(newest) && upper > newest) {
|
|
471
|
+
upper = newest
|
|
472
|
+
clamped = true
|
|
473
|
+
}
|
|
474
|
+
const readOlder = snapshot !== undefined
|
|
475
|
+
? snapshotOlderReader(snapshot)
|
|
476
|
+
: async (cursor) => {
|
|
477
|
+
const { window: result, clamped: chunkClamped } = await readChunk(sessionId, cursor)
|
|
478
|
+
const events = Array.isArray(result.events) ? result.events : []
|
|
479
|
+
const floorSeq = Number.isInteger(result.startSeq) ? result.startSeq : (events[0]?.seq ?? 0)
|
|
480
|
+
return { events, floorSeq, exhausted: floorSeq === 0, clamped: chunkClamped }
|
|
481
|
+
}
|
|
482
|
+
const page = await readSummaryPage({ readOlder, upper, limit, maxBytes, clamped })
|
|
483
|
+
if (page.error !== undefined) return page
|
|
484
|
+
// 字段与普通窗口同形(`startSeq`/`endSeq`/`hasMore`/`truncatedBy`/`clamped`/
|
|
485
|
+
// `scanBoundary`),客户端沿用同一套游标规则即可。**不带 `folds` 副本**:
|
|
486
|
+
// 摘要已经在记录里,再抄一份就是白白多一倍概要字节(设计 §十一 的预算是按一份算的)。
|
|
487
|
+
return {
|
|
488
|
+
records: page.records,
|
|
489
|
+
startSeq: page.startSeq,
|
|
490
|
+
endSeq: page.endSeq,
|
|
491
|
+
hasMore: page.hasMore,
|
|
492
|
+
truncatedBy: page.truncatedBy,
|
|
493
|
+
clamped: page.clamped === true,
|
|
494
|
+
scanBoundary: page.startSeq,
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
328
498
|
async function window(args) {
|
|
329
499
|
const sessionId = idOfAddress(args.address)
|
|
330
500
|
const before = Number(args.before)
|
|
331
501
|
if (sessionId === undefined) return failure('gateway/bad-request', 'address must name a session')
|
|
332
502
|
if (!Number.isSafeInteger(before) || before < 0) return failure('gateway/bad-request', 'before must be a non-negative integer')
|
|
503
|
+
// `limit` 的单位随模式变:普通窗口是**记录条数**;概要 v2(`fold: "summary"`)是
|
|
504
|
+
// **轮数**,因为这一模式的页单位就是整轮(字节预算由 `maxBytes` 兜)。客户端按
|
|
505
|
+
// 自己要知道的"首屏几轮"传即可。
|
|
333
506
|
const limit = Number.isSafeInteger(Number(args.limit)) && Number(args.limit) > 0 ? Number(args.limit) : DEFAULT_LIMIT
|
|
334
507
|
const maxBytes = Number.isSafeInteger(Number(args.maxBytes)) && Number(args.maxBytes) > 0
|
|
335
508
|
? Number(args.maxBytes) : DEFAULT_MAX_BYTES
|
|
@@ -343,10 +516,36 @@ export function createHistoryService({ logger = console } = {}) {
|
|
|
343
516
|
const { roles, error: rolesError } = parseRoles(args.roles)
|
|
344
517
|
if (rolesError) return rolesError
|
|
345
518
|
|
|
519
|
+
// 折叠模式三态:`none`(不传)/ `v1`(`fold: true`)/ `summary`(`fold: "summary"`)。
|
|
520
|
+
// 只有精确的 `"summary"` 切到概要 v2;`fold: true` 与所有其它取值一律按老规矩走,
|
|
521
|
+
// 所以老客户端的回包与改动前逐字节相同(`test/helpers/fold-v1-golden.json`)。
|
|
522
|
+
const summaryFold = args.fold === SUMMARY_FOLD_MODE
|
|
523
|
+
const fold = !summaryFold && args.fold === true
|
|
524
|
+
const foldMode = summaryFold ? 'summary' : fold ? 'v1' : 'none'
|
|
525
|
+
const coverage = args.coverage === true
|
|
526
|
+
// 概要 v2 的语义是"整轮 + 只讲话与概要":角色过滤会切碎轮次,覆盖区间也按原始
|
|
527
|
+
// 记录算,两者与它拼不到一起。明确拒绝,不静默退回另一种语义(否则回包看起来
|
|
528
|
+
// 正常、内容却是错的)。
|
|
529
|
+
if (summaryFold && (roles !== undefined || coverage)) {
|
|
530
|
+
return failure('gateway/bad-request',
|
|
531
|
+
'fold "summary" cannot be combined with roles or coverage')
|
|
532
|
+
}
|
|
533
|
+
const requestKey = cacheKey({ sessionId, before, through, limit, maxBytes, roles, foldMode, coverage })
|
|
534
|
+
const cachedPage = cacheGet(requestKey)
|
|
535
|
+
if (cachedPage !== undefined) {
|
|
536
|
+
return { ok: true, value: maybeCompressHistoryValue(cachedPage, args.encoding) }
|
|
537
|
+
}
|
|
538
|
+
|
|
346
539
|
const key = `window:${sessionId}`
|
|
347
540
|
if (inFlight.has(key)) return failure('link/busy', 'another window read is in flight for this session')
|
|
348
541
|
inFlight.add(key)
|
|
349
542
|
try {
|
|
543
|
+
if (summaryFold) {
|
|
544
|
+
const summary = await summaryWindowValue({ sessionId, before, through, limit, maxBytes })
|
|
545
|
+
if (summary.error !== undefined) return failure(summary.error.code, summary.error.message)
|
|
546
|
+
cachePut(requestKey, summary)
|
|
547
|
+
return { ok: true, value: maybeCompressHistoryValue(summary, args.encoding) }
|
|
548
|
+
}
|
|
350
549
|
// 按角色过滤时走"先定位命中 seq、再定向取完整记录"(spec 016 的"来回次数"一条):
|
|
351
550
|
// 同样一段日志,过滤路径实测从 2.5–4.1 s 降到数百毫秒;未过滤的浏览翻页保持原样
|
|
352
551
|
// (它要的是精确的一段原始事件,`readEvent` 每块 51 条正合适)。
|
|
@@ -354,9 +553,21 @@ export function createHistoryService({ logger = console } = {}) {
|
|
|
354
553
|
// 三级:① 角色过滤 + 宿主能整份读 → 从快照出(一次读、之后每页零往返);
|
|
355
554
|
// ② 角色过滤但快照不可用(没有 readSession / 太大 / 读失败)→ A1 的分块定位;
|
|
356
555
|
// ③ 不带角色 → 原来的分块扫描(浏览翻页要的是精确的一段原始事件)。
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
556
|
+
// 未过滤的浏览翻页也走整份快照:本机 13k 会话一次 `readSession` 约 0.3s,
|
|
557
|
+
// 而逐页 `readEvent` 每页都要付一次宿主地板价(实测 300–500ms/页)。
|
|
558
|
+
// 第一次请求仍走实时读(首屏不能等快照),同时在后台预热;之后的更早页命中快照。
|
|
559
|
+
// 只有"请求上界落在快照最新 seq 之内"才用快照——更新的那段必须实时读,
|
|
560
|
+
// 否则刚发出去的消息会被旧快照挡住。
|
|
561
|
+
let snapshot
|
|
562
|
+
if (roles !== undefined) {
|
|
563
|
+
snapshot = typeof sessionQuery?.readSession === 'function'
|
|
564
|
+
? await snapshots.get(sessionId)
|
|
565
|
+
: undefined
|
|
566
|
+
} else {
|
|
567
|
+
snapshot = snapshots.peek(sessionId)
|
|
568
|
+
if (snapshot === undefined) snapshots.warm(sessionId)
|
|
569
|
+
if (snapshot !== undefined && before - 1 > snapshot.newestSeq) snapshot = undefined
|
|
570
|
+
}
|
|
360
571
|
const scan = snapshot !== undefined
|
|
361
572
|
? scanSnapshot(snapshot, { before, through, limit, maxBytes, roles, roleOf })
|
|
362
573
|
: useFilterScan
|
|
@@ -368,7 +579,23 @@ export function createHistoryService({ logger = console } = {}) {
|
|
|
368
579
|
const page = pageFrom(scan, { limit, maxBytes, roles })
|
|
369
580
|
// 016:下发前统一清掉只用于诊断的宿主模型输出日志(口径只有一份,
|
|
370
581
|
// 见 specs/016-history-load-perf/contracts/history-record-trim.md)。
|
|
371
|
-
|
|
582
|
+
const trimmed = trimRecordsForWire(page.records)
|
|
583
|
+
const folded = fold ? foldProcessRuns(trimmed) : { records: trimmed, folds: [] }
|
|
584
|
+
const value = { ...page, records: folded.records }
|
|
585
|
+
// 已知为空:只覆盖"最老返回记录..窗口上界"。版本取会话最新号;
|
|
586
|
+
// 客户端版本不一致时不采纳(宿主重排号段后旧结论失效)。
|
|
587
|
+
if (coverage) {
|
|
588
|
+
const upper = before - 1
|
|
589
|
+
const lower = Math.max(1, before - limit)
|
|
590
|
+
value.knownEmpty = emptySpans(trimmed, lower, upper)
|
|
591
|
+
value.revision = String(await newestSeq(sessionId))
|
|
592
|
+
}
|
|
593
|
+
// `folds` 只在折叠时出现:老客户端不认识这个字段也不会读它;新客户端没有它
|
|
594
|
+
// 就按普通记录折(老连接器)。
|
|
595
|
+
if (folded.folds.length > 0) value.folds = folded.folds
|
|
596
|
+
cachePut(requestKey, value)
|
|
597
|
+
// 压缩只改回包形态,不改分页语义:解压后逐字段等于不压缩的那份。
|
|
598
|
+
return { ok: true, value: maybeCompressHistoryValue(value, args.encoding) }
|
|
372
599
|
} catch (error) {
|
|
373
600
|
logger.warn?.(`mobile-link: sessionWindow failed: ${messageOf(error)}`)
|
|
374
601
|
return hostFailure(error)
|
package/lib/index.js
CHANGED
|
@@ -56,6 +56,10 @@ const DEFAULTS = {
|
|
|
56
56
|
// 是否接收手机推上来的阶段记录(`_link/diagnostics`)。false = 回 diagnostics/disabled,
|
|
57
57
|
// 手机静默忽略——这是"记录出问题时的回退闸",关掉不影响连接。
|
|
58
58
|
diagnostics: true,
|
|
59
|
+
// 是否接收手机推上来的运行日志(`_link/logs`),落盘目录见 logs.js。
|
|
60
|
+
// 关掉后手机收到的同样是明确拒绝,不会影响连接。
|
|
61
|
+
logs: true,
|
|
62
|
+
logsFile: '',
|
|
59
63
|
heartbeatMs: 20000,
|
|
60
64
|
pongTimeoutMs: 60000,
|
|
61
65
|
maxBackoffMs: 30000,
|
|
@@ -112,6 +116,10 @@ function buildAgent(config, logger) {
|
|
|
112
116
|
? DEFAULTS.slowRequestMs
|
|
113
117
|
: Number(config.slowRequestMs) || 0,
|
|
114
118
|
diagnostics: config.diagnostics !== false,
|
|
119
|
+
// 设备日志落盘(见 lib/logs.js):`logsFile` 换根目录,`logs: false` 关掉接收。
|
|
120
|
+
// 保留期固定 7 天,不做配置项——清理在写入路径上,配错就是悄悄留不住或悄悄写满。
|
|
121
|
+
logs: config.logs !== false,
|
|
122
|
+
logsRoot: typeof config.logsFile === 'string' && config.logsFile ? config.logsFile : undefined,
|
|
115
123
|
heartbeatMs: Number(config.heartbeatMs) || DEFAULTS.heartbeatMs,
|
|
116
124
|
pongTimeoutMs: Number(config.pongTimeoutMs) || DEFAULTS.pongTimeoutMs,
|
|
117
125
|
maxBackoffMs: Number(config.maxBackoffMs) || DEFAULTS.maxBackoffMs,
|
|
@@ -190,6 +198,26 @@ function wireSessionQuery(ctx, history, logger) {
|
|
|
190
198
|
}
|
|
191
199
|
}
|
|
192
200
|
|
|
201
|
+
/**
|
|
202
|
+
* 把宿主的任务与活会话服务交给活动名单(**可选注入**,spec 018)。
|
|
203
|
+
*
|
|
204
|
+
* 两个服务都到位才回答 `_link/jobRoster`;缺一个就保持"不支持",手机整块不显示
|
|
205
|
+
* (缺一个却回空名单,等于把降级说成"没有任务在跑")。
|
|
206
|
+
* 注入可能晚于第一台设备连接——roster 在绑定后会补一次订阅,见 `jobs-roster.js`。
|
|
207
|
+
*/
|
|
208
|
+
function wireJobsServices(ctx, agent, logger) {
|
|
209
|
+
try {
|
|
210
|
+
ctx.inject(['jobs', 'sessions'], (scope) => {
|
|
211
|
+
agent.setJobsServices?.({ jobs: scope.jobs, sessions: scope.sessions })
|
|
212
|
+
// 服务卸载(宿主换代/插件禁用)时同步摘掉宿主任务事件订阅。
|
|
213
|
+
scope.effect?.(() => () => agent.setJobsServices?.({}), 'mobile-link: jobs roster')
|
|
214
|
+
logger.info?.('mobile-link: activity roster bound to ctx.jobs / ctx.sessions')
|
|
215
|
+
})
|
|
216
|
+
} catch (error) {
|
|
217
|
+
logger.warn?.(`mobile-link: jobs service unavailable (activity roster falls back): ${error}`)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
193
221
|
/**
|
|
194
222
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
195
223
|
* @param {Record<string, unknown>} rawConfig
|
|
@@ -205,6 +233,7 @@ export function apply(ctx, rawConfig) {
|
|
|
205
233
|
registerRoutes(ctx, agent, logger, config, history)
|
|
206
234
|
wireEndpointResolver(ctx, agent, logger)
|
|
207
235
|
wireSessionQuery(ctx, history, logger)
|
|
236
|
+
wireJobsServices(ctx, agent, logger)
|
|
208
237
|
logger.info?.(`mobile-link: ready (relay ${config.relayUrl})`)
|
|
209
238
|
}
|
|
210
239
|
|