dsh-plugin-mobile-link 0.4.1 → 0.6.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.
@@ -0,0 +1,195 @@
1
+ import { HOST_CALL_TIMEOUT_MS, withTimeout, messageOf } from './shared.js'
2
+ import { bytesOf, summarizeEvents } from './records.js'
3
+
4
+ /**
5
+ * 只读测量(spec 007 T008/T047):`GET /mobile-link/history-probe` 用,锁定映射之后删。
6
+ *
7
+ * 它只读、不改任何状态;`getQuery` 是个取值函数而不是服务对象本身,因为宿主注入
8
+ * (`ctx.inject(['sessionQuery'])`)可能在服务建好之后才发生。
9
+ */
10
+ export function createProbeRunner({ getQuery, logger }) {
11
+ return async function probe(args = {}) {
12
+ const sessionId = args.sessionId
13
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
14
+ return { ok: false, error: 'sessionId is required' }
15
+ }
16
+ if (getQuery() === undefined) {
17
+ return { ok: false, error: 'ctx.getQuery() is not mounted in this Host' }
18
+ }
19
+ const limit = Number.isFinite(Number(args.n)) && Number(args.n) > 0 ? Number(args.n) : 60
20
+ const out = { ok: true, sessionId, limit }
21
+
22
+ // 1. Whole log: how many events, and is log position == seq?
23
+ const t0 = Date.now()
24
+ try {
25
+ const records = await withTimeout(getQuery().listEvents(sessionId), HOST_CALL_TIMEOUT_MS, 'listEvents')
26
+ const mismatches = []
27
+ records.forEach((record, index) => {
28
+ if (record.seq !== index && mismatches.length < 5) mismatches.push({ index, seq: record.seq })
29
+ })
30
+ out.listEvents = {
31
+ ...summarizeEvents(records),
32
+ ms: Date.now() - t0,
33
+ offsetEqualsSeq: mismatches.length === 0,
34
+ firstMismatches: mismatches,
35
+ }
36
+ } catch (error) {
37
+ out.listEvents = { ms: Date.now() - t0, error: messageOf(error) }
38
+ }
39
+
40
+ // 2. Exact window read around a seq (`readEvent`): raw events, contiguous.
41
+ const targetSeq = Number.isFinite(Number(args.seq)) ? Number(args.seq) : out.listEvents?.lastSeq
42
+ if (Number.isInteger(targetSeq) && targetSeq >= 0) {
43
+ const t1 = Date.now()
44
+ try {
45
+ const window = await withTimeout(
46
+ getQuery().readEvent({ sessionId, seq: targetSeq, before: limit, after: 0 }),
47
+ HOST_CALL_TIMEOUT_MS,
48
+ 'readEvent',
49
+ )
50
+ out.readEvent = {
51
+ requested: { seq: targetSeq, before: limit, after: 0 },
52
+ ...summarizeEvents(window.events),
53
+ startSeq: window.startSeq ?? null,
54
+ endSeq: window.endSeq ?? null,
55
+ rawEventCount: window.events?.length ?? 0,
56
+ bytes: bytesOf(window.events),
57
+ ms: Date.now() - t1,
58
+ }
59
+ } catch (error) {
60
+ out.readEvent = { requested: { seq: targetSeq, before: limit }, ms: Date.now() - t1, error: messageOf(error) }
61
+ }
62
+ }
63
+
64
+ // 3. Seq-range filter (`filterEvents`): message-aligned semantic documents.
65
+ if (Number.isInteger(targetSeq) && targetSeq >= 0) {
66
+ const from = Math.max(0, targetSeq - limit + 1)
67
+ const t2 = Date.now()
68
+ try {
69
+ const docs = await withTimeout(
70
+ getQuery().filterEvents(sessionId, [{ kind: 'seq', from, to: targetSeq }]),
71
+ HOST_CALL_TIMEOUT_MS,
72
+ 'filterEvents(seq)',
73
+ )
74
+ out.filterEventsSeq = {
75
+ requested: { from, to: targetSeq },
76
+ ...summarizeEvents(docs),
77
+ bytes: bytesOf(docs),
78
+ sample: docs.slice(0, 2).map((doc) => ({ seq: doc.seq, type: doc.type, textLen: doc.text?.length ?? 0 })),
79
+ ms: Date.now() - t2,
80
+ }
81
+ } catch (error) {
82
+ out.filterEventsSeq = { requested: { from, to: targetSeq }, ms: Date.now() - t2, error: messageOf(error) }
83
+ }
84
+ }
85
+
86
+ // 4. Literal text scan (`filterEvents(text)`): the fallback for search when
87
+ // the full-text index is closed (base bundle ships `openAt: never`).
88
+ if (typeof args.text === 'string' && args.text.length > 0) {
89
+ const t3 = Date.now()
90
+ try {
91
+ const docs = await withTimeout(
92
+ getQuery().filterEvents(sessionId, [{ kind: 'text', text: args.text }]),
93
+ HOST_CALL_TIMEOUT_MS,
94
+ 'filterEvents(text)',
95
+ )
96
+ out.filterEventsText = {
97
+ query: args.text,
98
+ ...summarizeEvents(docs),
99
+ sample: docs.slice(0, 3).map((doc) => ({
100
+ seq: doc.seq,
101
+ type: doc.type,
102
+ textLen: doc.text?.length ?? 0,
103
+ head: (doc.text ?? '').slice(0, 60),
104
+ })),
105
+ ms: Date.now() - t3,
106
+ }
107
+ } catch (error) {
108
+ out.filterEventsText = { query: args.text, ms: Date.now() - t3, error: messageOf(error) }
109
+ }
110
+ }
111
+
112
+ // 5. Indexed search (`searchEvents`): expected to fail while the SQLite
113
+ // index is closed, which is why step 4 must be able to carry search.
114
+ if (typeof args.text === 'string' && args.text.length > 0) {
115
+ const t4 = Date.now()
116
+ try {
117
+ const page = await withTimeout(
118
+ getQuery().searchEvents({ sessionId, query: args.text, limit: 20 }),
119
+ HOST_CALL_TIMEOUT_MS,
120
+ 'searchEvents',
121
+ )
122
+ out.searchEvents = {
123
+ query: args.text,
124
+ count: page.items?.length ?? 0,
125
+ hasMore: page.hasMore ?? null,
126
+ first: page.items?.[0] ? { seq: page.items[0].seq, type: page.items[0].type } : null,
127
+ ms: Date.now() - t4,
128
+ }
129
+ } catch (error) {
130
+ out.searchEvents = { query: args.text, ms: Date.now() - t4, error: messageOf(error) }
131
+ }
132
+ }
133
+
134
+ // 7. Chunked literal scan: `filterEvents(text)` returns every match with its
135
+ // full text, so the connector must bound each call by a seq range and
136
+ // stop at the requested hit count. This measures that shape.
137
+ if (typeof args.text === 'string' && args.text.length > 0 && Number.isInteger(out.listEvents?.lastSeq ?? null)) {
138
+ const chunk = Number.isFinite(Number(args.chunk)) && Number(args.chunk) > 0 ? Number(args.chunk) : 2000
139
+ const wanted = Number.isFinite(Number(args.hits)) && Number(args.hits) > 0 ? Number(args.hits) : 50
140
+ const lastSeq = out.listEvents.lastSeq
141
+ const t6 = Date.now()
142
+ const hits = []
143
+ let calls = 0
144
+ let to = lastSeq
145
+ try {
146
+ while (to >= 0 && hits.length < wanted && calls < 50) {
147
+ const from = Math.max(0, to - chunk + 1)
148
+ const docs = await withTimeout(
149
+ getQuery().filterEvents(sessionId, [
150
+ { kind: 'seq', from, to },
151
+ { kind: 'text', text: args.text },
152
+ ]),
153
+ HOST_CALL_TIMEOUT_MS,
154
+ `filterEvents(chunk ${from}-${to})`,
155
+ )
156
+ calls++
157
+ for (const doc of docs) {
158
+ if (hits.length >= wanted) break
159
+ hits.push({ seq: doc.seq, type: doc.type, textLen: doc.text?.length ?? 0 })
160
+ }
161
+ to = from - 1
162
+ }
163
+ out.chunkedText = {
164
+ query: args.text,
165
+ chunk,
166
+ calls,
167
+ hits: hits.length,
168
+ firstSeq: hits[0]?.seq ?? null,
169
+ lastSeq: hits[hits.length - 1]?.seq ?? null,
170
+ scannedFrom: to + 1,
171
+ ms: Date.now() - t6,
172
+ }
173
+ } catch (error) {
174
+ out.chunkedText = { query: args.text, calls, hits: hits.length, ms: Date.now() - t6, error: messageOf(error) }
175
+ }
176
+ }
177
+
178
+ // 6. Cold read: no Agent activation. `readSession` returns the log snapshot.
179
+ const t5 = Date.now()
180
+ try {
181
+ const snapshot = await withTimeout(getQuery().readSession(sessionId), HOST_CALL_TIMEOUT_MS, 'readSession')
182
+ out.readSession = {
183
+ events: snapshot.events?.length ?? null,
184
+ startSeq: snapshot.startSeq ?? null,
185
+ endSeq: snapshot.endSeq ?? null,
186
+ headerKeys: snapshot.session ? Object.keys(snapshot.session).slice(0, 8) : [],
187
+ ms: Date.now() - t5,
188
+ }
189
+ } catch (error) {
190
+ out.readSession = { ms: Date.now() - t5, error: messageOf(error) }
191
+ }
192
+
193
+ return out
194
+ }
195
+ }
@@ -0,0 +1,97 @@
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
+ export function bytesOfRecords(records) {
51
+ return bytesOf(records)
52
+ }
53
+
54
+ /// Drop the **oldest** end until the page fits. The newest edge must stay exact
55
+ /// (it is the seq the phone's window abuts), so trimming only ever happens here.
56
+ export function trimToBytes(records, maxBytes) {
57
+ let truncated = false
58
+ while (records.length > 1 && bytesOfRecords(records) > maxBytes) {
59
+ records.shift()
60
+ truncated = true
61
+ }
62
+ return truncated
63
+ }
64
+
65
+ /// Which of the three transcript roles one event belongs to (the phone's own
66
+ /// `TranscriptSearchHit.Role`).
67
+ ///
68
+ /// `user/message` only counts as **me** when the event really came from the
69
+ /// user: measured on a real session, ~24% of `user/message` events are
70
+ /// plugin/system injections (`data.source.kind` other than `"user"`), and
71
+ /// agent-relayed ones carry a `data.source.rpcId` starting with `agent-`.
72
+ /// Events with no `data.source` at all predate the marker and stay `me`
73
+ /// (old-data compatibility). Non-`me` user messages return `undefined` —
74
+ /// they remain visible to full-text search, they just never enter the
75
+ /// "my questions" set (spec 009 T031).
76
+ export function roleOf(event) {
77
+ const type = event?.type
78
+ if (type === 'user/message') {
79
+ const source = event?.data?.source
80
+ if (source !== undefined && source !== null && typeof source === 'object') {
81
+ if (source.kind !== undefined && source.kind !== 'user') return undefined
82
+ if (typeof source.rpcId === 'string' && source.rpcId.startsWith('agent-')) return undefined
83
+ }
84
+ return 'me'
85
+ }
86
+ if (type === 'assistant/message') return 'agent'
87
+ if (type === 'tool/call' || type === 'tool/result') return 'tool'
88
+ return undefined
89
+ }
90
+
91
+ /// What the search hit is *displayed* as. Injected/relayed user messages are
92
+ /// not "me" for the curated set, but they are still user speech in full-text
93
+ /// search (spec 009: excluded from "我的提问", not from the corpus), so an
94
+ /// unfiltered search shows them under the user bucket.
95
+ export function displayRoleOf(event) {
96
+ return roleOf(event) ?? (event?.type === 'user/message' ? 'me' : undefined)
97
+ }
@@ -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
+ }
@@ -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
+ }