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,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.
|
|
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
|
|
14
|
-
*
|
|
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
|
|