dsh-instruction-memory 1.4.7 → 1.5.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/lib/client.js CHANGED
@@ -20,7 +20,40 @@ window.__ModuleLoader__.load({
20
20
  const React = require('react')
21
21
  const h = React.createElement
22
22
 
23
- const API = '/instruction-memory/api'
23
+ /**
24
+ * F19: the route path RESOLVED against the page, not assumed to be at root.
25
+ *
26
+ * `/instruction-memory/api` is correct only when the GUI is served from `/`.
27
+ * Behind a reverse proxy that mounts the app under a prefix (`/ui/`) the
28
+ * browser posts to the proxy root instead, and every request 404s even
29
+ * though the panel rendered. The harness itself resolves its own client
30
+ * paths the same way (`new URL(path.slice(1), document.baseURI)`), so this
31
+ * follows the page wherever it is served from.
32
+ *
33
+ * Computed per call, not once at load: a test harness (or a page that
34
+ * rewrites `<base>`) can establish the base after this module evaluates.
35
+ *
36
+ * @param {string} [suffix] query string to append, e.g. `?lite=1`.
37
+ * @returns {string} absolute or root-relative URL for the route.
38
+ */
39
+ const apiEndpoint = (suffix) => {
40
+ const tail = typeof suffix === 'string' ? suffix : ''
41
+ const path = 'instruction-memory/api'
42
+ if (typeof document === 'undefined' || typeof document.baseURI !== 'string' || document.baseURI === '') {
43
+ return '/' + path + tail
44
+ }
45
+ try {
46
+ const base = new URL(document.baseURI)
47
+ // A base of `https://host/ui` (no trailing slash) would otherwise
48
+ // resolve against `/` — `ui` is the mount point itself, so treat it as
49
+ // the directory the app lives in.
50
+ if (!base.pathname.endsWith('/')) base.pathname += '/'
51
+ const url = new URL(path, base)
52
+ return url.pathname + url.search + tail
53
+ } catch {
54
+ return '/' + path + tail
55
+ }
56
+ }
24
57
 
25
58
  // The Host bounds one delivery at 60 s (lib/mailer.js SMTP_TOTAL_TIMEOUT_MS).
26
59
  // Waiting longer than the generic 15 s keeps the panel honest: aborting while
@@ -227,6 +260,7 @@ window.__ModuleLoader__.load({
227
260
  'panel.title': '指令记忆',
228
261
  'panel.subtitle': '保存后自动写入系统提示词,对此后所有对话、问答与任务执行生效。',
229
262
  'panel.loading': '正在读取指令记忆…',
263
+ 'panel.retry': '重新载入',
230
264
  'panel.masterSwitch': '总开关',
231
265
  'status.mounted': '已挂载到系统提示词——后续每轮对话自动生效({chars} 字符)',
232
266
  'status.notMountedEmpty': '未挂载:当前没有启用中的指令',
@@ -258,6 +292,7 @@ window.__ModuleLoader__.load({
258
292
  'editor.cancel': '取消',
259
293
  'editor.hint': '保存后对下一轮对话立即生效',
260
294
  'editor.needTitleOrContent': '请至少填写标题或指令内容',
295
+ 'editor.conflictOverwrite': '这条记忆刚被其他窗口或进程修改过。继续保存会用你当前的编辑内容覆盖对方的修改,确定要继续吗?',
261
296
  'editor.confirmDiscard': '当前编辑内容尚未保存,确定放弃这些修改吗?',
262
297
  'confirm.unavailable': '当前环境无法弹出确认对话框,操作已取消,未做任何改动。',
263
298
  'body.expand': '展开全部',
@@ -383,6 +418,7 @@ window.__ModuleLoader__.load({
383
418
  'diag.copied': '诊断信息已复制到剪贴板',
384
419
  'diag.copyManual': '自动复制不可用;请手动全选复制下方文本。',
385
420
  'diag.hostWarn': '安全提示:设置路由当前绑定在非回环地址({host}),同一网络内的设备可以无鉴权访问此页面,请勿在不可信网络暴露;如需对外发布,请阅读 README 的「经反向代理对外发布」。',
421
+ 'diag.hostWarnFence': '安全提示:设置路由当前绑定在非回环地址({host}),但宿主鉴权门已挂载——访问仍需登录 DSH 后的浏览器会话,未登录请求会被 401/403 拒绝;仍建议只在可信网络暴露。',
386
422
  'diag.groupStorage': '存储',
387
423
  'diag.groupInjection': '注入',
388
424
  'diag.groupBackup': '备份与历史',
@@ -459,7 +495,7 @@ window.__ModuleLoader__.load({
459
495
  'history.itemMetaLite': '{size} KB(文件较大,未统计条目数)',
460
496
  'history.foreign': '来自其他设备({device})',
461
497
  'history.restore': '回滚到此版本',
462
- 'history.restoreConfirm': '确定把当前记忆整体回滚到 {time} 的快照(rev {rev})?当前内容仍保留在 memory.json.bak 中,且回滚本身会生成一份新快照,可以再回滚回来。',
498
+ 'history.restoreConfirm': '确定把当前记忆整体回滚到 {time} 的快照(rev {rev},文件 {name})?当前内容仍保留在 memory.json.bak 中,且回滚本身会生成一份新快照,可以再回滚回来。',
463
499
  'history.delete': '删除',
464
500
  'history.deleteConfirm': '确定删除这份历史快照?删除后无法恢复。',
465
501
  'history.dir': '快照目录:{dir}',
@@ -511,6 +547,7 @@ window.__ModuleLoader__.load({
511
547
  'panel.title': 'Instruction Memory',
512
548
  'panel.subtitle': 'Saved instructions are written into the system prompt and apply to every conversation, answer, and task that follows.',
513
549
  'panel.loading': 'Loading instruction memory…',
550
+ 'panel.retry': 'Reload',
514
551
  'panel.masterSwitch': 'Master switch',
515
552
  'status.mounted': 'Mounted into the system prompt — effective for every turn that follows ({chars} chars)',
516
553
  'status.notMountedEmpty': 'Not mounted: no enabled instructions',
@@ -542,6 +579,7 @@ window.__ModuleLoader__.load({
542
579
  'editor.cancel': 'Cancel',
543
580
  'editor.hint': 'Takes effect for the next conversation turn once saved',
544
581
  'editor.needTitleOrContent': 'Fill in at least a title or the instruction content',
582
+ 'editor.conflictOverwrite': 'Another window or process just changed this entry. Saving now replaces their edit with yours. Continue?',
545
583
  'editor.confirmDiscard': 'The draft has unsaved changes. Discard them?',
546
584
  'confirm.unavailable': 'This shell cannot show a confirmation dialog, so the action was cancelled and nothing changed.',
547
585
  'body.expand': 'Show all',
@@ -667,6 +705,7 @@ window.__ModuleLoader__.load({
667
705
  'diag.copied': 'Diagnostics copied to the clipboard',
668
706
  'diag.copyManual': 'Automatic copy is unavailable; select and copy the text below manually.',
669
707
  'diag.hostWarn': 'Security notice: the settings route is bound to a non-loopback address ({host}); other devices on the same network can reach this page without authentication. Do not expose it on an untrusted network — for public deployments read “Publishing behind a reverse proxy” in the README.',
708
+ 'diag.hostWarnFence': 'Security notice: the settings route is bound to a non-loopback address ({host}), but the host authentication fence is attached — access still requires a signed-in DSH browser session, and unauthenticated requests are refused with 401/403. Keep it on a trusted network anyway.',
670
709
  'diag.groupStorage': 'Storage',
671
710
  'diag.groupInjection': 'Injection',
672
711
  'diag.groupBackup': 'Backup & history',
@@ -743,7 +782,7 @@ window.__ModuleLoader__.load({
743
782
  'history.itemMetaLite': '{size} KB (file too large to count items)',
744
783
  'history.foreign': 'From another device ({device})',
745
784
  'history.restore': 'Roll back to this',
746
- 'history.restoreConfirm': 'Roll the whole memory back to the snapshot from {time} (rev {rev})? The current content stays in memory.json.bak, and the rollback itself creates a new snapshot, so you can roll forward again.',
785
+ 'history.restoreConfirm': 'Roll the whole memory back to the snapshot from {time} (rev {rev}, file {name})? The current content stays in memory.json.bak, and the rollback itself creates a new snapshot, so you can roll forward again.',
747
786
  'history.delete': 'Delete',
748
787
  'history.deleteConfirm': 'Delete this snapshot? This cannot be undone.',
749
788
  'history.dir': 'Snapshot folder: {dir}',
@@ -1130,10 +1169,16 @@ window.__ModuleLoader__.load({
1130
1169
  */
1131
1170
  function normalizeResult(result) {
1132
1171
  if (result === null || typeof result !== 'object') return null
1133
- const snapshot = result.snapshot !== undefined && result.snapshot !== null ? result.snapshot : result
1172
+ const hasEnvelope = result.snapshot !== undefined && result.snapshot !== null
1173
+ const snapshot = hasEnvelope ? result.snapshot : result
1134
1174
  // data === null passes a typeof check but poisons every consumer that
1135
1175
  // reads data.budgetChars; it is exactly as unusable as a missing data.
1136
- if (snapshot === null || typeof snapshot !== 'object' || snapshot.data === undefined || snapshot.data === null) return null
1176
+ if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) return null
1177
+ const data = snapshot.data
1178
+ if (data === undefined || data === null || typeof data !== 'object' || Array.isArray(data)) return null
1179
+ // Every consumer reads data.entries and sortEntries() calls .slice() on
1180
+ // it; a non-array threw mid-render instead of surfacing a readable error.
1181
+ if (!Array.isArray(data.entries)) return null
1137
1182
  return {
1138
1183
  snapshot,
1139
1184
  ok: result.ok !== false,
@@ -1469,7 +1514,7 @@ window.__ModuleLoader__.load({
1469
1514
  const controller = typeof AbortController === 'function' ? new AbortController() : null
1470
1515
  const timer = controller !== null ? setTimeout(() => controller.abort(), timeoutMs) : null
1471
1516
  try {
1472
- const response = await fetch(API, {
1517
+ const response = await fetch(apiEndpoint(), {
1473
1518
  method: 'POST',
1474
1519
  headers: { 'content-type': 'application/json' },
1475
1520
  body: JSON.stringify({ method, args: args === undefined ? null : args }),
@@ -1951,6 +1996,14 @@ window.__ModuleLoader__.load({
1951
1996
  setError(t('err.unrecognized'))
1952
1997
  return
1953
1998
  }
1999
+ // Out-of-order responses: an older request can resolve after a newer one
2000
+ // (an SSE-triggered refresh racing a POST reply) and would restore a
2001
+ // stale snapshot over fresher data. rev only ever increases, so a
2002
+ // snapshot older than the one already rendered is dropped. Equal revs
2003
+ // are still absorbed — they are the same state.
2004
+ const nextRev = revOfFrom(normalized.snapshot)
2005
+ const currentRev = revOf()
2006
+ if (typeof nextRev === 'number' && typeof currentRev === 'number' && nextRev < currentRev) return
1954
2007
  setView(normalized.snapshot)
1955
2008
  viewRef.current = normalized.snapshot
1956
2009
  // Keep the user's in-progress budget edit when a push arrives while
@@ -1972,17 +2025,29 @@ window.__ModuleLoader__.load({
1972
2025
  }
1973
2026
  }
1974
2027
 
2028
+ // A single boolean was cleared by whichever request finished first, which
2029
+ // re-enabled every button while other requests were still in flight.
2030
+ const pendingRef = React.useRef(0)
2031
+ // The panel can be unmounted while a request is in flight; setState after
2032
+ // unmount is a no-op in React 18 but it also loses the error the user
2033
+ // needed to see. Track disposal so late responses are dropped.
2034
+ const disposedRef = React.useRef(false)
2035
+ React.useEffect(() => () => { disposedRef.current = true }, [])
2036
+
1975
2037
  async function send(method, args) {
2038
+ pendingRef.current += 1
1976
2039
  setBusy(true)
1977
2040
  try {
1978
2041
  const result = await callHost(method, args)
2042
+ if (disposedRef.current) return null
1979
2043
  absorb(result)
1980
2044
  return result
1981
2045
  } catch (err) {
1982
- setError(String(err && err.message ? err.message : err))
2046
+ if (!disposedRef.current) setError(String(err && err.message ? err.message : err))
1983
2047
  return null
1984
2048
  } finally {
1985
- setBusy(false)
2049
+ pendingRef.current = Math.max(0, pendingRef.current - 1)
2050
+ if (pendingRef.current === 0 && !disposedRef.current) setBusy(false)
1986
2051
  }
1987
2052
  }
1988
2053
 
@@ -2105,6 +2170,13 @@ window.__ModuleLoader__.load({
2105
2170
  /** Switch the active set. The one click that changes what is injected. */
2106
2171
  function switchSet(id) {
2107
2172
  if (busy || id === '') return
2173
+ // A switch closes every open form. The entry editor has always asked
2174
+ // before discarding; the persona editor did not, so a typed persona was
2175
+ // silently discarded while the entry form next to it would have asked.
2176
+ const openProfile = profileDraftRef.current
2177
+ if (openProfile !== null
2178
+ && profileIsDirty(openProfile, view !== null && Array.isArray(view.data.profiles) ? view.data.profiles : [])
2179
+ && !confirmDiscard()) return
2108
2180
  void send('set-active-set', { id, baseRev: revOf() }).then((result) => {
2109
2181
  // A switch invalidates any list selection: the ids belong to the set
2110
2182
  // that is no longer displayed.
@@ -2191,8 +2263,17 @@ window.__ModuleLoader__.load({
2191
2263
 
2192
2264
  function rollbackTo(item) {
2193
2265
  if (busy) return
2266
+ // Restoring makes the Host read a whole file; a snapshot far larger
2267
+ // than any real store is refused BEFORE the user confirms an operation
2268
+ // that cannot succeed.
2269
+ const sizeVerdict = importSizeVerdict(Number(item.bytes),
2270
+ view !== null && view.limits !== null && typeof view.limits === 'object' ? view.limits.maxBodyChars : undefined)
2271
+ if (!sizeVerdict.ok) {
2272
+ setError(sizeVerdict.message)
2273
+ return
2274
+ }
2194
2275
  const when = new Date(item.epoch).toLocaleString()
2195
- const ask = askConfirm(t('history.restoreConfirm', { time: when, rev: item.rev }))
2276
+ const ask = askConfirm(t('history.restoreConfirm', { time: when, rev: item.rev, name: item.name }))
2196
2277
  if (ask !== CONFIRM_YES) {
2197
2278
  if (ask === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
2198
2279
  return
@@ -2342,11 +2423,16 @@ window.__ModuleLoader__.load({
2342
2423
  function saveSource() {
2343
2424
  if (busy) return
2344
2425
  void send('write-source', { text: sourceText, baseRev: sourceBaseRev }).then((result) => {
2345
- if (result === null || result.ok !== true) return
2426
+ if (result === null) return
2427
+ // A conflict response carries the FRESH snapshot. Keeping the old
2428
+ // baseRev made every retry conflict again against a rev the Host had
2429
+ // already told us about — an unwinnable loop with no way forward.
2430
+ const freshRev = revOfFrom(result.snapshot)
2431
+ if (typeof freshRev === 'number') setSourceBaseRev(freshRev)
2432
+ if (result.ok !== true) return
2346
2433
  // The draft now matches the committed store, so the rev it is based
2347
2434
  // on must move with it — otherwise the very next save would be
2348
2435
  // refused as a conflict against our own write.
2349
- setSourceBaseRev(revOfFrom(result.snapshot))
2350
2436
  sourceBaselineRef.current = sourceText
2351
2437
  void loadHistory()
2352
2438
  })
@@ -2522,7 +2608,7 @@ window.__ModuleLoader__.load({
2522
2608
  // flag ignores the query string and keeps sending full frames — the
2523
2609
  // branch below still accepts those, so the negotiation is additive and
2524
2610
  // one-way: neither side has to know the other's version.
2525
- const source = new EventSource(API + '?lite=1')
2611
+ const source = new EventSource(apiEndpoint('?lite=1'))
2526
2612
  let alive = true
2527
2613
  let refreshing = false
2528
2614
  let missed = false
@@ -2549,9 +2635,13 @@ window.__ModuleLoader__.load({
2549
2635
  const pushed = JSON.parse(ev.data)
2550
2636
  if (pushed !== null && typeof pushed === 'object' && pushed.lite === true) {
2551
2637
  const current = viewRef.current
2552
- // A null view means the initial POST `state` is still in flight;
2553
- // its response already carries the current rev.
2554
- if (current !== null && current.data !== null && current.data.rev !== pushed.rev) refresh()
2638
+ // A null view used to be ignored here on the assumption that the
2639
+ // initial POST `state` is always in flight. When that request
2640
+ // failed, nothing ever refetched and the panel stayed on its
2641
+ // loading text for the rest of the session. A lite frame proves
2642
+ // the Host is alive, so it is a safe trigger to retry.
2643
+ if (current === null) refresh()
2644
+ else if (current.data !== null && current.data.rev !== pushed.rev) refresh()
2555
2645
  return
2556
2646
  }
2557
2647
  if (sameSnapshot(pushed, viewRef.current)) return
@@ -2565,7 +2655,11 @@ window.__ModuleLoader__.load({
2565
2655
 
2566
2656
  if (view === null) {
2567
2657
  return h('div', { className: 'im-root' },
2568
- h('div', { className: 'im-empty' }, error === '' ? t('panel.loading') : error))
2658
+ h('div', { className: 'im-empty' }, error === '' ? t('panel.loading') : error),
2659
+ error === ''
2660
+ ? null
2661
+ : h('div', { className: 'im-row im-row-end' },
2662
+ h('button', { type: 'button', className: 'im-btn', onClick: () => { setError(''); send('state', null) } }, t('panel.retry'))))
2569
2663
  }
2570
2664
 
2571
2665
  if (draft !== null) {
@@ -2583,13 +2677,26 @@ window.__ModuleLoader__.load({
2583
2677
  setDraft(null); setError('')
2584
2678
  },
2585
2679
  onSave: () => {
2680
+ if (busy) return
2586
2681
  if (draft.title.trim() === '' && draft.content.trim() === '') {
2587
2682
  setError(t('editor.needTitleOrContent'))
2588
2683
  return
2589
2684
  }
2590
- send('save-entry', { entry: draft, baseRev: revOf() }).then((result) => {
2591
- if (result && result.ok === true) { setDraft(null); setError('') }
2592
- })
2685
+ const commit = (baseRev, allowOverwrite) => {
2686
+ void send('save-entry', { entry: draft, baseRev }).then((result) => {
2687
+ if (result === null) return
2688
+ if (result.ok === true) { setDraft(null); setError(''); return }
2689
+ // The Host refuses a write built on a stale rev and answers
2690
+ // with the fresh snapshot (already absorbed into the view).
2691
+ // Retrying on our own would replace the other window's edit
2692
+ // with this draft — a silent overwrite. Ask first, and only
2693
+ // then retry against the rev we just learned.
2694
+ if (result.conflict === true && !allowOverwrite) {
2695
+ if (askConfirm(t('editor.conflictOverwrite'), 'allow') === CONFIRM_YES) commit(revOf(), true)
2696
+ }
2697
+ })
2698
+ }
2699
+ commit(revOf(), false)
2593
2700
  },
2594
2701
  }),
2595
2702
  // The editor is an early return, so it must render its OWN error
@@ -2875,9 +2982,15 @@ window.__ModuleLoader__.load({
2875
2982
  feedbackCard,
2876
2983
  // F12: the non-loopback warning stays high in the panel — directly
2877
2984
  // under the feedback entry — so it cannot be missed.
2985
+ // Two different situations, two different texts: with the host fence
2986
+ // attached the route is NOT open to the LAN (it needs a signed-in
2987
+ // browser session), so claiming "no authentication" would be a false
2988
+ // alarm. `authFence` is what the route itself checked on this request.
2878
2989
  security.hostGuarded === false
2879
2990
  ? h('div', { className: 'im-hostwarn', key: 'hostwarn' },
2880
- t('diag.hostWarn', { host: security.boundHost !== '' ? security.boundHost : '?' }))
2991
+ security.authFence === true
2992
+ ? t('diag.hostWarnFence', { host: security.boundHost !== '' ? security.boundHost : '?' })
2993
+ : t('diag.hostWarn', { host: security.boundHost !== '' ? security.boundHost : '?' }))
2881
2994
  : null,
2882
2995
  h('div', { className: 'im-row' },
2883
2996
  h('div', null,
@@ -3666,7 +3779,18 @@ window.__ModuleLoader__.load({
3666
3779
  // published before or after this plugin activates.
3667
3780
  runtimeCtx = ctx
3668
3781
 
3669
- const resolveSlots = () => (ctx.slots !== undefined ? ctx.slots : ctx.get('slots'))
3782
+ const resolveSlots = () => {
3783
+ // ctx.get() can throw on a disposed context, and this call sits outside
3784
+ // safeMount's try — an escaping throw here killed the retry timer with
3785
+ // no message anywhere.
3786
+ try {
3787
+ return ctx.slots !== undefined ? ctx.slots : ctx.get('slots')
3788
+ } catch (err) {
3789
+ console.error('[instruction-memory] 读取 slots 服务失败:'
3790
+ + (err !== null && err !== undefined && typeof err.message === 'string' ? err.message : String(err)))
3791
+ return undefined
3792
+ }
3793
+ }
3670
3794
 
3671
3795
  // A retry can fire after the fiber was disposed (plugin disabled, host
3672
3796
  // shutting down); registering into a dead context throws, and an uncaught
@@ -3676,7 +3800,12 @@ window.__ModuleLoader__.load({
3676
3800
  try {
3677
3801
  mountPanel(ctx, target)
3678
3802
  return true
3679
- } catch {
3803
+ } catch (err) {
3804
+ // F19: this used to swallow the throw outright — the panel simply
3805
+ // never appeared and nothing anywhere said so.
3806
+ const detail = '注册设置页时抛出异常:' + (err !== null && err !== undefined && typeof err.message === 'string' ? err.message : String(err))
3807
+ console.error('[instruction-memory] ' + detail)
3808
+ showMountFailureNotice(detail)
3680
3809
  return false
3681
3810
  }
3682
3811
  }
@@ -3696,10 +3825,10 @@ window.__ModuleLoader__.load({
3696
3825
  return
3697
3826
  }
3698
3827
  if (attempt >= SLOT_RETRY_LIMIT) {
3699
- console.error(
3700
- '[instruction-memory] slots service unavailable after '
3701
- + (SLOT_RETRY_LIMIT * SLOT_RETRY_DELAY_MS) + 'ms of retries; settings page not registered',
3702
- )
3828
+ const detail = '宿主没有发布 slots 服务(重试 '
3829
+ + SLOT_RETRY_LIMIT + ' 次,约 ' + (SLOT_RETRY_LIMIT * SLOT_RETRY_DELAY_MS) + 'ms)'
3830
+ console.error('[instruction-memory] ' + detail + ';settings page not registered')
3831
+ showMountFailureNotice(detail)
3703
3832
  return
3704
3833
  }
3705
3834
  schedule(retry)
@@ -3707,6 +3836,120 @@ window.__ModuleLoader__.load({
3707
3836
  schedule(retry)
3708
3837
  }
3709
3838
 
3839
+ /**
3840
+ * R8: read the running plugin version for the mount-failure notice.
3841
+ *
3842
+ * The settings route does not depend on `slots`, so `state` normally
3843
+ * answers even when the panel could not mount — which is exactly the
3844
+ * moment someone copies a version number into a bug report. Best effort in
3845
+ * every direction: no `fetch`, no route, a refusal by the host's auth
3846
+ * gate, a timeout or a malformed body all resolve to null, and the notice
3847
+ * simply omits the line.
3848
+ */
3849
+ function probePluginVersion() {
3850
+ try {
3851
+ if (typeof fetch !== 'function') return Promise.resolve(null)
3852
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
3853
+ const options = {
3854
+ method: 'POST',
3855
+ headers: { 'content-type': 'application/json' },
3856
+ body: JSON.stringify({ method: 'state', args: null }),
3857
+ }
3858
+ if (controller !== null) {
3859
+ options.signal = controller.signal
3860
+ const timer = setTimeout(() => controller.abort(), 2000)
3861
+ if (timer !== null && typeof timer === 'object' && typeof timer.unref === 'function') timer.unref()
3862
+ }
3863
+ return fetch(apiEndpoint(), options)
3864
+ .then((response) => (response.ok === true ? response.json() : null))
3865
+ .then((payload) => (payload !== null && payload !== undefined
3866
+ && payload.ok === true
3867
+ && payload.snapshot !== null && typeof payload.snapshot === 'object'
3868
+ && typeof payload.snapshot.pluginVersion === 'string'
3869
+ && payload.snapshot.pluginVersion !== ''
3870
+ ? payload.snapshot.pluginVersion
3871
+ : null))
3872
+ .catch(() => null)
3873
+ } catch {
3874
+ return Promise.resolve(null)
3875
+ }
3876
+ }
3877
+
3878
+ /**
3879
+ * F19: make a failed mount VISIBLE instead of console-only.
3880
+ *
3881
+ * The panel is how the user sees and edits their memory. When `slots` never
3882
+ * resolves, the previous code wrote one console.error: the plugin looked
3883
+ * installed, the settings page had no section, and nothing told the user
3884
+ * why. A console line is not a user interface.
3885
+ *
3886
+ * The notice is deliberately dependency-free — plain DOM, inline styles,
3887
+ * `textContent` only (never innerHTML) — because the very thing that failed
3888
+ * is the service that would supply a styled component. It is non-modal and
3889
+ * self-removing so it cannot become a permanent artefact of a transient
3890
+ * race (a late `slots` publish still mounts the panel; the banner then
3891
+ * only says the panel appeared late).
3892
+ */
3893
+ function showMountFailureNotice(detail) {
3894
+ try {
3895
+ if (typeof document === 'undefined' || document.body === null) return
3896
+ const id = 'dsh-instruction-memory-mount-failure'
3897
+ const existing = document.getElementById(id)
3898
+ if (existing !== null) existing.remove()
3899
+
3900
+ const box = document.createElement('div')
3901
+ box.id = id
3902
+ box.setAttribute('role', 'status')
3903
+ box.style.cssText = [
3904
+ 'position:fixed', 'right:16px', 'bottom:16px', 'z-index:2147483000',
3905
+ 'max-width:min(380px,calc(100vw - 32px))',
3906
+ 'background:#3a1d1d', 'color:#ffe8e8', 'border:1px solid #7a3a3a',
3907
+ 'border-radius:10px', 'padding:12px 14px',
3908
+ 'font:13px/1.5 system-ui,-apple-system,Segoe UI,sans-serif',
3909
+ 'box-shadow:0 8px 24px rgba(0,0,0,.45)',
3910
+ ].join(';')
3911
+
3912
+ const title = document.createElement('div')
3913
+ title.style.cssText = 'font-weight:600;margin-bottom:4px'
3914
+ title.textContent = '指令记忆:设置页挂载失败'
3915
+ box.appendChild(title)
3916
+
3917
+ const body = document.createElement('div')
3918
+ body.textContent = detail
3919
+ box.appendChild(body)
3920
+
3921
+ const hint = document.createElement('div')
3922
+ hint.style.cssText = 'margin-top:6px;opacity:.75'
3923
+ hint.textContent = '记忆注入本身仍在工作;请检查宿主版本与插件日志,或重装插件后刷新页面。'
3924
+ box.appendChild(hint)
3925
+
3926
+ const close = document.createElement('button')
3927
+ close.type = 'button'
3928
+ close.textContent = '关闭'
3929
+ close.style.cssText = 'margin-top:8px;background:transparent;color:#ffe8e8;border:1px solid #7a3a3a;border-radius:6px;padding:2px 10px;cursor:pointer;font:inherit'
3930
+ close.addEventListener('click', () => box.remove())
3931
+ box.appendChild(close)
3932
+
3933
+ document.body.appendChild(box)
3934
+ // R8: name the running version. Appended after the banner exists so a
3935
+ // slow or refused probe can never delay the warning itself.
3936
+ probePluginVersion().then((version) => {
3937
+ if (version === null) return
3938
+ const meta = document.createElement('div')
3939
+ meta.style.cssText = 'margin-top:6px;opacity:.75'
3940
+ meta.textContent = '插件版本 ' + version
3941
+ box.appendChild(meta)
3942
+ }).catch(() => {})
3943
+ // Non-modal and self-removing: a failure that persists past 30 s has
3944
+ // already been reported once; leaving a banner on screen forever is
3945
+ // worse than a dismissible one the user can re-trigger by reloading.
3946
+ const timer = setTimeout(() => box.remove(), 30000)
3947
+ if (timer !== null && typeof timer === 'object' && typeof timer.unref === 'function') timer.unref()
3948
+ } catch {
3949
+ // A banner is a courtesy: never let it be the reason the plugin throws.
3950
+ }
3951
+ }
3952
+
3710
3953
  function schedule(fn) {
3711
3954
  const timer = setTimeout(fn, SLOT_RETRY_DELAY_MS)
3712
3955
  // Never hold a process open just to finish retrying.