dsh-plugin-mobile-link 0.8.0 → 0.8.1

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,121 @@
1
+ /**
2
+ * 预览 ↔ 原图 的关联索引(Host 侧持久状态)。
3
+ *
4
+ * 为什么需要它:Host 的 `session/attachment` 只认**图像**引用,没有按文件引用
5
+ * 读回字节的 RPC;而一张图的原图是逐字节保存的 file 附件(`FileAttachmentRef`),
6
+ * 与 Host 归一化后的预览图是两个内容地址。手机要"按需取原图",就必须能从预览的
7
+ * `attachmentId` 找到原图引用。
8
+ *
9
+ * 关联放在**电脑上**而不是 App 里:需求要求重装、重新配对、换设备后仍能取回原图,
10
+ * 而客户端的任何缓存都会在重装时消失。键是预览的 `attachmentId`(内容哈希,全局唯一),
11
+ * 所以索引不需要会话信息。写这条索引的有两处:连接器(手机发送的图)与
12
+ * `send_image` 插件(工具结果图),格式见 `docs/IMAGE-VARIANTS.md` 与 /tmp 契约 §1.4。
13
+ *
14
+ * 追加写而不是重写:两个写者(连接器与图片插件)在同一进程里,但各自是独立的模块实例,
15
+ * 读-改-写会互相覆盖。`O_APPEND` 的单次整行写入在 POSIX 上不会交错;读取端按行解析、
16
+ * 同一预览取最后一条。索引坏掉最坏的后果是"原图不可用",绝不会变成错误或数据丢失。
17
+ */
18
+
19
+ import { closeSync, mkdirSync, openSync, readFileSync, statSync, writeSync } from 'node:fs'
20
+ import { dirname, join } from 'node:path'
21
+
22
+ import { dshHome } from './state.js'
23
+
24
+ /** 索引版本;字段变化时递增,读取端忽略不认识的版本。 */
25
+ export const INDEX_VERSION = 1
26
+
27
+ /** 索引文件最大可读字节数:超过说明文件被写坏了,宁可不认也不要整读进内存。 */
28
+ export const MAX_INDEX_BYTES = 8 * 1024 * 1024
29
+
30
+ /** 索引文件路径(`$DSH_HOME/mobile-link/image-originals.jsonl`)。 */
31
+ export function indexPath(home = dshHome()) {
32
+ return join(home, 'mobile-link', 'image-originals.jsonl')
33
+ }
34
+
35
+ /** 一条索引记录。字段与图片插件的写入端必须一致。 */
36
+ export function indexEntry({ previewId, original, source = 'composer', savedAt = new Date().toISOString() }) {
37
+ return {
38
+ v: INDEX_VERSION,
39
+ previewId,
40
+ original: {
41
+ attachmentId: original.attachmentId,
42
+ name: original.name,
43
+ bytes: original.bytes,
44
+ ...(original.mediaType === undefined ? {} : { mediaType: original.mediaType }),
45
+ },
46
+ savedAt,
47
+ source,
48
+ }
49
+ }
50
+
51
+ /**
52
+ * 追加一条记录。
53
+ *
54
+ * 失败只上报给日志,不抛给调用方:原图已经存进 Host 的对象库了,索引写不进去
55
+ * 只意味着"这张图暂时取不到原图",不该让整次发送失败。
56
+ * @returns {boolean} 是否写入成功(测试与诊断使用)。
57
+ */
58
+ export function appendIndex(entry, { home = dshHome(), logger = console } = {}) {
59
+ const path = indexPath(home)
60
+ try {
61
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
62
+ const line = `${JSON.stringify(entry)}\n`
63
+ const fd = openSync(path, 'a', 0o600)
64
+ try {
65
+ writeSync(fd, line)
66
+ } finally {
67
+ closeSync(fd)
68
+ }
69
+ return true
70
+ } catch (error) {
71
+ logger.warn?.(`mobile-link: image-originals append failed: ${error?.message ?? error}`)
72
+ return false
73
+ }
74
+ }
75
+
76
+ /**
77
+ * 把整个索引读成 `previewId → 原图元数据`。
78
+ *
79
+ * 坏行(半行、JSON 不完整、字段类型不对)逐行跳过;同键后写覆盖先写。
80
+ * 文件不存在返回空表——"没有原图"是正常状态,不是错误。
81
+ */
82
+ export function readIndex({ home = dshHome() } = {}) {
83
+ const path = indexPath(home)
84
+ let text
85
+ try {
86
+ if (statSync(path).size > MAX_INDEX_BYTES) return new Map()
87
+ text = readFileSync(path, 'utf8')
88
+ } catch {
89
+ return new Map()
90
+ }
91
+ const map = new Map()
92
+ for (const line of text.split('\n')) {
93
+ if (!line.trim()) continue
94
+ let parsed
95
+ try {
96
+ parsed = JSON.parse(line)
97
+ } catch {
98
+ continue
99
+ }
100
+ if (parsed?.v !== INDEX_VERSION) continue
101
+ const previewId = parsed.previewId
102
+ const original = parsed.original
103
+ if (typeof previewId !== 'string' || previewId.length === 0) continue
104
+ if (typeof original?.attachmentId !== 'string' || !original.attachmentId) continue
105
+ if (typeof original.name !== 'string' || !original.name) continue
106
+ if (!Number.isSafeInteger(original.bytes) || original.bytes < 0) continue
107
+ map.set(previewId, {
108
+ attachmentId: original.attachmentId,
109
+ name: original.name,
110
+ bytes: original.bytes,
111
+ ...(typeof original.mediaType === 'string' ? { mediaType: original.mediaType } : {}),
112
+ })
113
+ }
114
+ return map
115
+ }
116
+
117
+ /** 查一条原图元数据;没有就是 `undefined`(旧数据只有一份附件时正是这种)。 */
118
+ export function lookupOriginal(previewId, options = {}) {
119
+ if (typeof previewId !== 'string' || !previewId) return undefined
120
+ return readIndex(options).get(previewId)
121
+ }
@@ -0,0 +1,315 @@
1
+ /**
2
+ * 会话图片的"预览 + 可选原图"两变体能力(连接器侧)。
3
+ *
4
+ * 这一层只做三件事,且都在电脑上完成:
5
+ * 1. 把手机已经落盘的暂存字节交给 Host 的附件服务:预览走
6
+ * `fileUploads.uploadImageStream`(Host 归一化,返回 prompt 可引用的 receipt),
7
+ * 原图走 `attachments.saveFileStream`(逐字节保存,不做任何重编码)。
8
+ * 2. 把"预览 attachmentId → 原图 file 引用"写进 Host 侧的索引
9
+ * (`lib/image-originals.js`),于是重装 App / 换设备后仍能取回原图。
10
+ * 3. 按需把原图按区间读回给手机(元数据 + 分片),供"查看原图/保存原图"使用。
11
+ *
12
+ * 为什么不改 Host:Host 没有"按预览 id 读原图文件"的 RPC,`session/attachment`
13
+ * 也只认图像引用。连接的保留方法(`_link/*`)本来就是连接器自己回答的通道,
14
+ * 把这一段放在这里既不需要改 Host 契约,也不会新增中转的帧类型。
15
+ *
16
+ * 服务是**可选注入**:宿主缺少 `attachments` / `fileUploads`(老版本或裁剪部署)时,
17
+ * 三个方法都回 `link/unsupported`,手机据此显示"原图不可用",预览仍照常工作。
18
+ */
19
+
20
+ import { closeSync, createReadStream, openSync, readSync, statSync, unlinkSync } from 'node:fs'
21
+ import { basename, dirname, resolve } from 'node:path'
22
+
23
+ import { appendIndex, indexEntry, lookupOriginal } from './image-originals.js'
24
+ import { dshHome } from './state.js'
25
+
26
+ /** 手机侧调用的三个保留方法(`_link/` 前缀,永不转发给 Host)。 */
27
+ export const IMAGE_COMMIT = '_link/imageCommit'
28
+ export const IMAGE_ORIGINAL = '_link/imageOriginal'
29
+ export const IMAGE_ORIGINAL_CHUNK = '_link/imageOriginalChunk'
30
+
31
+ /** 一次分片读取的默认字节数与上限:base64 后仍远低于 32 MB 的帧上限。 */
32
+ export const DEFAULT_CHUNK_BYTES = 512 * 1024
33
+ export const MAX_CHUNK_BYTES = 1024 * 1024
34
+
35
+ /** 手机发来的图必须落在这个目录里(`FileInbox` 的落盘位置)。 */
36
+ function inboxDirectory(home, sessionId) {
37
+ return resolve(home, 'inbox', sessionId)
38
+ }
39
+
40
+ /**
41
+ * 交给 Host 的字节流。
42
+ *
43
+ * 挂一个空错误处理:Host 可能在读完之前就拒绝(超限、格式不接受),而我们随后会删掉
44
+ * 暂存文件——被丢弃的流在 unlink 之后再打开会抛 ENOENT,没人接住的 `error` 事件会变成
45
+ * 进程级 uncaughtException。消费方替身靠 async iterator 仍能拿到真正的失败,这里只是
46
+ * 保证"我们自己制造的那条尾巴"不会冒到进程上。
47
+ */
48
+ function readStream(path) {
49
+ const stream = createReadStream(path)
50
+ stream.on('error', () => {})
51
+ return stream
52
+ }
53
+
54
+ /** 一个带稳定错误码的失败:路由把它原样翻成 `{ok:false,error}`。 */
55
+ class ImageVariantError extends Error {
56
+ constructor(code, message, details = {}) {
57
+ super(message)
58
+ this.code = code
59
+ this.details = details
60
+ }
61
+ }
62
+
63
+ /** 宿主失败 → 保留方法错误码。认不出的按上传失败处理。 */
64
+ function commitFailure(error) {
65
+ const code = error?.code
66
+ const reason = error?.details?.reason
67
+ if (code === 'session/not-found') return new ImageVariantError('session/not-found', '会话不存在或未挂上 Agent。')
68
+ if (reason === 'UNSUPPORTED_IMAGE_TYPE') {
69
+ return new ImageVariantError('image/unsupported-type', '这个部署不接受该图片格式。')
70
+ }
71
+ if (reason === 'IMAGE_TOO_LARGE' || reason === 'IMAGES_TOO_LARGE') {
72
+ return new ImageVariantError('image/too-large', '预览图超过宿主的单图上限。')
73
+ }
74
+ return new ImageVariantError('image/upload-failed', error?.message ?? String(error))
75
+ }
76
+
77
+ /** 是否是本模块回答的方法。 */
78
+ export function isImageVariantMethod(method) {
79
+ return method === IMAGE_COMMIT || method === IMAGE_ORIGINAL || method === IMAGE_ORIGINAL_CHUNK
80
+ }
81
+
82
+ /** Owns the image-variant methods for one connector. */
83
+ export class ImageVariants {
84
+ /**
85
+ * @param {object} options
86
+ * @param {object} [options.attachments] 宿主附件服务(`ctx.attachments`)
87
+ * @param {object} [options.fileUploads] 宿主上传服务(`ctx.fileUploads`)
88
+ * @param {object} [options.logger]
89
+ * @param {string} [options.home] 索引与收件箱所在的 DSH_HOME(测试注入)
90
+ */
91
+ constructor({ attachments, fileUploads, logger = console, home = dshHome() } = {}) {
92
+ this.attachments = attachments
93
+ this.fileUploads = fileUploads
94
+ this.logger = logger
95
+ this.home = home
96
+ }
97
+
98
+ /** 两个宿主服务都到位才答原图;缺一个就如实报不支持。 */
99
+ get available() {
100
+ return Boolean(this.attachments && this.fileUploads)
101
+ }
102
+
103
+ /** Answers one reserved call. Throws an `ImageVariantError` for the router to report. */
104
+ async handle(method, args = {}) {
105
+ switch (method) {
106
+ case IMAGE_COMMIT: return this.#commit(args)
107
+ case IMAGE_ORIGINAL: return this.#metadata(args)
108
+ case IMAGE_ORIGINAL_CHUNK: return this.#chunk(args)
109
+ default: throw new ImageVariantError('link/unknown-method', `unknown image method ${method}`)
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 校验并解析一个暂存路径。
115
+ *
116
+ * 只接受收件箱的**直接子文件**:手机本来就只能把文件写进那里,
117
+ * 放宽到子目录或任意路径等于让手机指定电脑上的任意文件。
118
+ */
119
+ #stagedPath(rawPath, sessionId) {
120
+ if (typeof rawPath !== 'string' || rawPath.length === 0) {
121
+ throw new ImageVariantError('image/invalid-path', '缺少暂存文件路径。')
122
+ }
123
+ const resolved = resolve(rawPath)
124
+ if (dirname(resolved) !== inboxDirectory(this.home, sessionId)) {
125
+ throw new ImageVariantError('image/invalid-path', '暂存文件不在本会话的收件箱里。')
126
+ }
127
+ try {
128
+ if (!statSync(resolved).isFile()) throw new Error('not a file')
129
+ } catch {
130
+ throw new ImageVariantError('image/invalid-path', '暂存文件不存在。')
131
+ }
132
+ return resolved
133
+ }
134
+
135
+ /** 删掉本次用到的暂存文件:成品已经进了 Host 对象库,留在收件箱只会越积越多。 */
136
+ #discard(paths) {
137
+ for (const path of paths) {
138
+ if (!path) continue
139
+ try {
140
+ unlinkSync(path)
141
+ } catch {
142
+ /* best effort:删不掉不影响已经完成的上传 */
143
+ }
144
+ }
145
+ }
146
+
147
+ async #commit(args) {
148
+ if (!this.available) {
149
+ throw new ImageVariantError('link/unsupported', '这台电脑的宿主不支持图片变体上传。')
150
+ }
151
+ const sessionId = typeof args.sessionId === 'string' ? args.sessionId : ''
152
+ if (!sessionId) throw new ImageVariantError('session/not-found', '缺少 sessionId。')
153
+
154
+ const previewPath = args.preview ? this.#stagedPath(args.preview.path, sessionId) : undefined
155
+ const previewMediaType = typeof args.preview?.mediaType === 'string' && args.preview.mediaType
156
+ ? args.preview.mediaType
157
+ : 'image/jpeg'
158
+ const previewName = typeof args.preview?.name === 'string' && args.preview.name
159
+ ? basename(args.preview.name)
160
+ : 'image.jpg'
161
+
162
+ const originalPath = args.original ? this.#stagedPath(args.original.path, sessionId) : undefined
163
+ const originalName = typeof args.original?.name === 'string' && args.original.name
164
+ ? basename(args.original.name)
165
+ : basename(originalPath ?? 'original')
166
+ const originalMediaType = typeof args.original?.mediaType === 'string' ? args.original.mediaType : undefined
167
+ const sendOriginalAsFile = args.sendOriginalAsFile === true
168
+ if (!previewPath && !originalPath) {
169
+ throw new ImageVariantError('image/invalid-path', '预览与原图至少要有一份。')
170
+ }
171
+ if (sendOriginalAsFile && !originalPath) {
172
+ throw new ImageVariantError('image/invalid-path', '要求按文件发送原图,但没有提供原图文件。')
173
+ }
174
+ if (!previewPath && !sendOriginalAsFile) {
175
+ // 没有预览还能发出去的唯一形态就是"按文件发送原图";否则用户既看不到图,
176
+ // 也不知道原图在哪。
177
+ throw new ImageVariantError('image/invalid-path', '没有预览时只能按文件发送原图。')
178
+ }
179
+
180
+ const discard = [previewPath, originalPath]
181
+ try {
182
+ let preview
183
+ if (previewPath) {
184
+ try {
185
+ preview = await this.fileUploads.uploadImageStream({
186
+ sessionId,
187
+ data: readStream(previewPath),
188
+ mediaType: previewMediaType,
189
+ name: previewName,
190
+ })
191
+ } catch (error) {
192
+ throw commitFailure(error)
193
+ }
194
+ }
195
+
196
+ const result = {
197
+ preview: preview ? { receiptId: preview.receiptId, image: preview.image } : null,
198
+ original: null,
199
+ }
200
+
201
+ if (originalPath) {
202
+ if (sendOriginalAsFile) {
203
+ // 超限或不可解码:按**文件**交回给手机,由它额外发一个 file part。
204
+ // 不写索引、不产生 original 变体——预览绝不会被当成原图。
205
+ let file
206
+ try {
207
+ file = await this.fileUploads.uploadStream({
208
+ sessionId,
209
+ data: readStream(originalPath),
210
+ name: originalName,
211
+ })
212
+ } catch (error) {
213
+ throw commitFailure(error)
214
+ }
215
+ result.fileReceiptId = file.receiptId
216
+ result.originalReason = typeof args.originalReason === 'string' && args.originalReason
217
+ ? args.originalReason
218
+ : 'tooLarge'
219
+ } else {
220
+ try {
221
+ const stored = await this.attachments.saveFileStream({
222
+ data: readStream(originalPath),
223
+ name: originalName,
224
+ })
225
+ const metadata = {
226
+ attachmentId: stored.attachmentId,
227
+ name: stored.name,
228
+ bytes: stored.bytes,
229
+ ...(originalMediaType === undefined ? {} : { mediaType: originalMediaType }),
230
+ }
231
+ if (preview) {
232
+ appendIndex(
233
+ indexEntry({ previewId: preview.image.attachmentId, original: metadata, source: 'composer' }),
234
+ { home: this.home, logger: this.logger },
235
+ )
236
+ }
237
+ result.original = metadata
238
+ } catch (error) {
239
+ // 原图存不进去不该挡下发图:预览照发,手机显示"原图不可用"。
240
+ this.logger.warn?.(`mobile-link: original save failed: ${error?.message ?? error}`)
241
+ result.originalReason = 'failed'
242
+ }
243
+ }
244
+ }
245
+
246
+ return result
247
+ } finally {
248
+ this.#discard(discard)
249
+ }
250
+ }
251
+
252
+ /** 原图是否存在、多大、叫什么。不读字节。 */
253
+ #metadata(args) {
254
+ const previewId = typeof args.attachmentId === 'string' ? args.attachmentId : ''
255
+ if (!previewId) return { available: false, reason: 'missing' }
256
+ const entry = lookupOriginal(previewId, { home: this.home })
257
+ if (!entry) return { available: false, reason: 'missing' }
258
+ if (!this.available) return { available: false, reason: 'unsupported' }
259
+ const path = this.#hostPath(entry)
260
+ if (!path) return { available: false, reason: 'cleaned' }
261
+ return { available: true, original: entry }
262
+ }
263
+
264
+ /** 原图对象在电脑上的路径;Host 不提供(对象已清理、提供者不支持)时返回 undefined。 */
265
+ #hostPath(entry) {
266
+ try {
267
+ const path = this.attachments.fileHostPath({
268
+ attachmentId: entry.attachmentId,
269
+ name: entry.name,
270
+ bytes: entry.bytes,
271
+ })
272
+ if (typeof path !== 'string' || !path) return undefined
273
+ if (!statSync(path).isFile()) return undefined
274
+ return path
275
+ } catch {
276
+ return undefined
277
+ }
278
+ }
279
+
280
+ /** 读原图的一个区间。只读请求的那一段,不把整文件塞进内存。 */
281
+ #chunk(args) {
282
+ const previewId = typeof args.attachmentId === 'string' ? args.attachmentId : ''
283
+ const entry = previewId ? lookupOriginal(previewId, { home: this.home }) : undefined
284
+ if (!entry) throw new ImageVariantError('image-original/missing', '没有这张图的原图。')
285
+ if (!this.available) throw new ImageVariantError('image-original/unsupported', '这台电脑的宿主读不到原图。')
286
+ const path = this.#hostPath(entry)
287
+ if (!path) throw new ImageVariantError('image-original/cleaned', '原图已被清理。')
288
+
289
+ const total = entry.bytes
290
+ const offset = Number.isFinite(args.offset) ? Math.max(0, Math.trunc(args.offset)) : 0
291
+ const requested = Number.isFinite(args.length) && args.length > 0
292
+ ? Math.trunc(args.length)
293
+ : DEFAULT_CHUNK_BYTES
294
+ const length = Math.min(requested, MAX_CHUNK_BYTES)
295
+ if (offset >= total) {
296
+ return { data: '', offset, eof: true, bytes: total }
297
+ }
298
+ const toRead = Math.min(length, total - offset)
299
+ const buffer = Buffer.alloc(toRead)
300
+ const fd = openSync(path, 'r')
301
+ try {
302
+ const read = readSync(fd, buffer, 0, toRead, offset)
303
+ return {
304
+ data: buffer.subarray(0, read).toString('base64'),
305
+ offset,
306
+ eof: offset + read >= total,
307
+ bytes: total,
308
+ }
309
+ } catch (error) {
310
+ throw new ImageVariantError('image-original/read-failed', error?.message ?? String(error))
311
+ } finally {
312
+ closeSync(fd)
313
+ }
314
+ }
315
+ }
package/lib/index.js CHANGED
@@ -32,6 +32,7 @@
32
32
  import { MobileLinkAgent } from './link.js'
33
33
  import { registerMobileLinkRoutes } from './routes.js'
34
34
  import { createHistoryService } from './history.js'
35
+ import { ImageVariants } from './image-variants.js'
35
36
 
36
37
  /** Cordis function-plugin name (the loader row id is separate). */
37
38
  export const name = 'mobile-link'
@@ -205,6 +206,32 @@ function wireSessionQuery(ctx, history, logger) {
205
206
  }
206
207
  }
207
208
 
209
+ /**
210
+ * 把宿主的附件与上传服务交给图片变体能力(**可选注入**)。
211
+ *
212
+ * 两个服务都到位才回答 `_link/image*`:预览要经 `fileUploads` 拿 prompt receipt,
213
+ * 原图要经 `attachments` 逐字节保存并读回。缺一个就保持"不支持",手机显示
214
+ * "原图不可用"——这是如实降级,不是错误;预览仍走原有内联路径。
215
+ *
216
+ * 服务卸载(宿主换代 / 插件禁用)时把绑定摘掉,避免路由拿着失效的服务对象。
217
+ */
218
+ function wireImageVariants(ctx, agent, logger) {
219
+ try {
220
+ ctx.inject(['attachments', 'fileUploads'], (scope) => {
221
+ const variants = new ImageVariants({
222
+ attachments: scope.attachments,
223
+ fileUploads: scope.fileUploads,
224
+ logger,
225
+ })
226
+ agent.setImageVariants?.(variants)
227
+ scope.effect?.(() => () => agent.setImageVariants?.(undefined), 'mobile-link: image variants')
228
+ logger.info?.('mobile-link: image variants bound to ctx.attachments / ctx.fileUploads')
229
+ })
230
+ } catch (error) {
231
+ logger.warn?.(`mobile-link: image variants unavailable (preview-only): ${error}`)
232
+ }
233
+ }
234
+
208
235
  /**
209
236
  * 把宿主的任务与活会话服务交给活动名单(**可选注入**,spec 018)。
210
237
  *
@@ -225,6 +252,27 @@ function wireJobsServices(ctx, agent, logger) {
225
252
  }
226
253
  }
227
254
 
255
+ /**
256
+ * 把「哪些会话值得提醒」的宿主账本交给路由(SR-02.08)。
257
+ *
258
+ * 两个服务都到位才启用过滤:`ctx.sessions` 判子会话、`ctx.workspaceRegistry` 判归档与
259
+ * 工作区归属。缺一个就维持旧行为(全报)——拿不到账本时把提醒全吞掉,比多报几条更难发现。
260
+ */
261
+ function wireSessionScope(ctx, agent, logger) {
262
+ try {
263
+ ctx.inject(['sessions', 'workspaceRegistry'], (scope) => {
264
+ agent.setSessionScopeServices?.({
265
+ sessions: scope.sessions,
266
+ workspaces: scope.workspaceRegistry,
267
+ })
268
+ scope.effect?.(() => () => agent.setSessionScopeServices?.(undefined), 'mobile-link: session scope')
269
+ logger.info?.('mobile-link: reminders limited to formal sessions')
270
+ })
271
+ } catch (error) {
272
+ logger.warn?.(`mobile-link: session scope unavailable (reminders keep legacy scope): ${error}`)
273
+ }
274
+ }
275
+
228
276
  /**
229
277
  * @param {import('@deepseek-ai/cordis').Context} ctx
230
278
  * @param {Record<string, unknown>} rawConfig
@@ -241,6 +289,8 @@ export function apply(ctx, rawConfig) {
241
289
  wireEndpointResolver(ctx, agent, logger)
242
290
  wireSessionQuery(ctx, history, logger)
243
291
  wireJobsServices(ctx, agent, logger)
292
+ wireSessionScope(ctx, agent, logger)
293
+ wireImageVariants(ctx, agent, logger)
244
294
  logger.info?.(`mobile-link: ready (relay ${config.relayUrl})`)
245
295
  }
246
296
 
package/lib/journal.js CHANGED
@@ -65,6 +65,8 @@ export const EVENT_KINDS = Object.freeze([
65
65
  'app.first-render',
66
66
  'app.scene',
67
67
  'app.push',
68
+ // §3 之后追加(2026-11-12):一次取图在自动尝试用尽后仍失败
69
+ 'attachment.load',
68
70
  // 内部:写失败与超大条目自报(不在契约的对外表里,但同属写入侧词表)
69
71
  'journal.error',
70
72
  ])