dsh-instruction-memory 1.5.3 → 1.5.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/lib/client.js CHANGED
@@ -69,7 +69,10 @@ window.__ModuleLoader__.load({
69
69
 
70
70
  const CSS = `
71
71
  .im-root { display:flex; flex-direction:column; gap:16px; padding:6px 2px 56px; max-width:860px;
72
- color:var(--dsw-alias-label-primary,#1f2329); font-size:13px; line-height:1.6; }
72
+ color:var(--dsw-alias-label-primary,#1f2329); font-size:13px; line-height:1.6;
73
+ /* Android WebView honours the system text zoom unless this is pinned: at
74
+ 150% the fixed-px cards reflow into unreadable ribbons. */
75
+ -webkit-text-size-adjust:100%; text-size-adjust:100%; }
73
76
  .im-h { margin:0; font-size:14px; font-weight:600; }
74
77
  .im-sub { margin:3px 0 0; font-size:12px; color:var(--dsw-alias-label-secondary,#8a9099); }
75
78
  .im-card { display:flex; flex-direction:column; gap:11px; padding:15px; border-radius:12px;
@@ -357,7 +360,16 @@ window.__ModuleLoader__.load({
357
360
  'profiles.confirmDelete': '确定删除用户形象「{name}」?删除后无法恢复。',
358
361
  'profiles.empty': '还没有用户形象。点击「+ 新增形象」添加关于你本人的信息;不勾选任何形象时,AI 完全按原本方式回答。',
359
362
  'profiles.count': '共 {total} 个,使用中 {active} 个',
360
- 'profiles.chars': '已启用形象合计 {count} 字符(独立于指令字数上限,完整注入、不做裁剪)',
363
+ 'profiles.chars': '已启用形象合计 {count} 字符,与指令共用同一注入字数上限(超出时按序截断,段末会说明)',
364
+ 'profiles.truncated': '已截断',
365
+ 'profiles.truncatedTitle': '这张形象超过注入字数上限,只有开头进入本轮提示',
366
+ 'profiles.omitted': '本轮未包含',
367
+ 'profiles.omittedTitle': '这张形象因注入字数上限未进入本轮提示',
368
+ 'export.saved': '已开始下载;若没有文件出现,请检查浏览器的下载列表',
369
+ 'export.fallbackHint': '当前环境无法触发文件下载,完整 JSON 已放在下面:全选复制后自行保存',
370
+ 'export.fallbackClose': '关闭',
371
+ 'import.webviewHint': 'Android / WebView 上若文件选择器不弹出,请改用「源码模式」粘贴 JSON',
372
+ 'sse.down': '实时更新已断开(连续重试仍失败),数据可能是旧的:请点「重新载入」',
361
373
  'profiles.saving': '保存中…',
362
374
  'action.new': '+ 新增指令',
363
375
  'action.reload': '重新载入',
@@ -645,7 +657,16 @@ window.__ModuleLoader__.load({
645
657
  'profiles.confirmDelete': 'Delete the persona “{name}”? This cannot be undone.',
646
658
  'profiles.empty': 'No persona yet. Click “+ New persona” to add facts about yourself; with no persona ticked, the AI answers exactly as it normally would.',
647
659
  'profiles.count': '{total} in total, {active} in use',
648
- 'profiles.chars': 'Active personas total {count} characters (separate from the instruction budget; injected in full, never trimmed)',
660
+ 'profiles.chars': 'Active personas total {count} characters, counted against the same injection limit as instructions (anything over is truncated in order and disclosed at the end)',
661
+ 'profiles.truncated': 'Truncated',
662
+ 'profiles.truncatedTitle': 'This persona exceeds the injection limit; only its head reached this turn',
663
+ 'profiles.omitted': 'Not in this turn',
664
+ 'profiles.omittedTitle': 'This persona was dropped by the injection limit and did not reach this turn',
665
+ 'export.saved': 'Download started — if no file appears, check your browser’s download list',
666
+ 'export.fallbackHint': 'This environment cannot start a file download; the full JSON is below — select all and copy it',
667
+ 'export.fallbackClose': 'Close',
668
+ 'import.webviewHint': 'On Android / WebView, if the file picker does not open, paste the JSON into “Source mode” instead',
669
+ 'sse.down': 'Live updates stopped (retries kept failing) and the data may be stale — click “Reload”',
649
670
  'profiles.saving': 'Saving…',
650
671
  'action.new': '+ New instruction',
651
672
  'action.reload': 'Reload',
@@ -1029,7 +1050,7 @@ window.__ModuleLoader__.load({
1029
1050
  * @param {string} message
1030
1051
  * @returns {{ kind: 'unavailable' } | { kind: 'cancel' } | { kind: 'answer', text: string }}
1031
1052
  */
1032
- function batchTagOutcome(ask, message) {
1053
+ function batchTagOutcome(ask, message, options) {
1033
1054
  if (typeof ask !== 'function') return { kind: 'unavailable' }
1034
1055
  let answer
1035
1056
  try {
@@ -1037,11 +1058,16 @@ window.__ModuleLoader__.load({
1037
1058
  } catch (err) {
1038
1059
  return { kind: 'unavailable' }
1039
1060
  }
1040
- // Only a real `null` means the user pressed Cancel. `undefined` and any
1041
- // non-string (a broken polyfill, a Promise) mean the runtime cannot
1042
- // deliver an answer, so the caller must show the fallback form instead of
1043
- // swallowing the click.
1044
- if (answer === null) return { kind: 'cancel' }
1061
+ // Android WebView is a FOURTH shape this helper did not know about: it
1062
+ // keeps `window.prompt` as a function and does not throw, but with no
1063
+ // WebChromeClient.onJsPrompt it returns `null` immediately — never
1064
+ // showing a dialog. Treating that as "the user pressed Cancel" turned the
1065
+ // bulk-tag action into a silent no-op, and the inline fallback form below
1066
+ // was unreachable. The caller passes `nullMeansUnavailable` when it knows
1067
+ // the runtime cannot show a dialog.
1068
+ const nullIsUnavailable = options !== null && typeof options === 'object'
1069
+ && options.nullMeansUnavailable === true
1070
+ if (answer === null) return nullIsUnavailable ? { kind: 'unavailable' } : { kind: 'cancel' }
1045
1071
  if (typeof answer !== 'string') return { kind: 'unavailable' }
1046
1072
  return { kind: 'answer', text: answer }
1047
1073
  }
@@ -1997,6 +2023,10 @@ window.__ModuleLoader__.load({
1997
2023
  // pretending the copy happened.
1998
2024
  const [diagOpen, setDiagOpen] = React.useState(false)
1999
2025
  const [diagFallback, setDiagFallback] = React.useState(null)
2026
+ /** Export JSON rendered in-panel when the runtime has no download path. */
2027
+ const [exportFallback, setExportFallback] = React.useState(null)
2028
+ /** True once the event stream has failed long enough to stop pretending. */
2029
+ const [liveDown, setLiveDown] = React.useState(false)
2000
2030
  // Feedback form: closed by default so the settings card stays compact.
2001
2031
  // The draft lives in the panel (not in a child) because the send handler
2002
2032
  // needs the same values the inputs show, and keeping them here survives
@@ -2268,7 +2298,11 @@ window.__ModuleLoader__.load({
2268
2298
  const ask = typeof window.prompt === 'function'
2269
2299
  ? (message, initial) => window.prompt(message, initial)
2270
2300
  : null
2271
- const outcome = batchTagOutcome(ask, t('batch.tagPrompt', { count: ids.length }))
2301
+ const outcome = batchTagOutcome(ask, t('batch.tagPrompt', { count: ids.length }), {
2302
+ // A WebView without onJsPrompt answers `null` instantly and shows no
2303
+ // dialog: that is "cannot ask", not "the user said no".
2304
+ nullMeansUnavailable: isAndroidWebView(),
2305
+ })
2272
2306
  if (outcome.kind === 'cancel') return
2273
2307
  if (outcome.kind === 'answer') {
2274
2308
  if (commitBatchTags(outcome.text)) closeBatchTags()
@@ -2678,6 +2712,30 @@ window.__ModuleLoader__.load({
2678
2712
  }
2679
2713
  }
2680
2714
 
2715
+ /**
2716
+ * Is this an Android WebView (as opposed to a normal browser or Electron)?
2717
+ *
2718
+ * The distinction matters because a WebView can answer `typeof
2719
+ * window.prompt === 'function'` and still never show a dialog, and it can
2720
+ * expose `<a download>` while having no download pipeline behind it. Both
2721
+ * fail silently, so the panel has to detect the environment and take the
2722
+ * in-page route instead of pretending the browser feature exists.
2723
+ */
2724
+ function isAndroidWebView() {
2725
+ try {
2726
+ const ua = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
2727
+ ? navigator.userAgent
2728
+ : ''
2729
+ // `; wv)` is the canonical WebView marker; DSH Go's webview island may
2730
+ // also report a stock Chromium UA, so fall back to "Android without a
2731
+ // full browser brand".
2732
+ return /;\s*wv\)/i.test(ua)
2733
+ || (/Android/i.test(ua) && !/Chrome\/\d+/i.test(ua))
2734
+ } catch {
2735
+ return false
2736
+ }
2737
+ }
2738
+
2681
2739
  async function doExport() {
2682
2740
  try {
2683
2741
  const result = await callHost('export-data', null)
@@ -2685,7 +2743,21 @@ window.__ModuleLoader__.load({
2685
2743
  setError(t('export.failed'))
2686
2744
  return
2687
2745
  }
2688
- const blob = new Blob([JSON.stringify(result.export, null, 2) + '\n'], { type: 'application/json' })
2746
+ const text = JSON.stringify(result.export, null, 2) + '\n'
2747
+ /**
2748
+ * A WebView can expose `<a download>` and still have nowhere to put
2749
+ * the bytes: Android only saves when the embedding app implements a
2750
+ * DownloadListener, and Chromium's WebView cannot hand it a `blob:`.
2751
+ * The click then produces no file, no error, and no second chance (the
2752
+ * object URL is revoked a second later), so Android takes the in-panel
2753
+ * route unconditionally.
2754
+ */
2755
+ if (isAndroidWebView() || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') {
2756
+ setExportFallback(text)
2757
+ setNotice(t('export.fallbackHint'))
2758
+ return
2759
+ }
2760
+ const blob = new Blob([text], { type: 'application/json' })
2689
2761
  const url = URL.createObjectURL(blob)
2690
2762
  const link = document.createElement('a')
2691
2763
  link.href = url
@@ -2694,6 +2766,7 @@ window.__ModuleLoader__.load({
2694
2766
  link.click()
2695
2767
  if (link.parentNode !== null) link.parentNode.removeChild(link)
2696
2768
  setTimeout(() => { URL.revokeObjectURL(url) }, 1000)
2769
+ setNotice(t('export.saved'))
2697
2770
  } catch (err) {
2698
2771
  setError(String(err && err.message ? err.message : err))
2699
2772
  }
@@ -2761,6 +2834,18 @@ window.__ModuleLoader__.load({
2761
2834
  let alive = true
2762
2835
  let refreshing = false
2763
2836
  let missed = false
2837
+ /** Consecutive stream errors; reset by any successful frame. */
2838
+ let failures = 0
2839
+ let down = false
2840
+ /**
2841
+ * Monotonic frame counter carried by lite frames. `rev` only moves when
2842
+ * the store is WRITTEN, so a snapshot that changed without a write (the
2843
+ * F7 pause expiring, an external hand-edit that keeps its rev) used to
2844
+ * produce a frame identical to the last one and the panel stayed stale
2845
+ * for the rest of the session. `null` means the Host predates the field
2846
+ * and the rev comparison alone drives the refetch.
2847
+ */
2848
+ let lastSeq = null
2764
2849
  const refresh = () => {
2765
2850
  // One refetch at a time. A frame that arrives mid-flight means the
2766
2851
  // store moved again, and is re-checked after this response lands —
@@ -2780,10 +2865,18 @@ window.__ModuleLoader__.load({
2780
2865
  })
2781
2866
  }
2782
2867
  source.onmessage = (ev) => {
2868
+ failures = 0
2869
+ if (down) {
2870
+ down = false
2871
+ setLiveDown(false)
2872
+ }
2783
2873
  try {
2784
2874
  const pushed = JSON.parse(ev.data)
2785
2875
  if (pushed !== null && typeof pushed === 'object' && pushed.lite === true) {
2786
2876
  const current = viewRef.current
2877
+ const seq = typeof pushed.seq === 'number' ? pushed.seq : null
2878
+ const moved = seq !== null && lastSeq !== null && seq !== lastSeq
2879
+ if (seq !== null) lastSeq = seq
2787
2880
  // A null view used to be ignored here on the assumption that the
2788
2881
  // initial POST `state` is always in flight. When that request
2789
2882
  // failed, nothing ever refetched and the panel stayed on its
@@ -2791,6 +2884,7 @@ window.__ModuleLoader__.load({
2791
2884
  // the Host is alive, so it is a safe trigger to retry.
2792
2885
  if (current === null) refresh()
2793
2886
  else if (current.data !== null && current.data.rev !== pushed.rev) refresh()
2887
+ else if (moved) refresh()
2794
2888
  return
2795
2889
  }
2796
2890
  if (sameSnapshot(pushed, viewRef.current)) return
@@ -2799,6 +2893,21 @@ window.__ModuleLoader__.load({
2799
2893
  // A malformed frame is dropped; the next frame, or 重新载入, catches up.
2800
2894
  }
2801
2895
  }
2896
+ /**
2897
+ * Android kills the WebView renderer, expires the session cookie or
2898
+ * freezes the page in Doze; EventSource then retries every 5 s forever
2899
+ * and the panel shows stale data as if it were live. Six consecutive
2900
+ * failures (≈30 s) is past any transient blip, so say so and let the
2901
+ * user reload — the stream keeps retrying, and the next frame that
2902
+ * lands clears the warning.
2903
+ */
2904
+ source.onerror = () => {
2905
+ failures += 1
2906
+ if (!down && failures >= 6) {
2907
+ down = true
2908
+ setLiveDown(true)
2909
+ }
2910
+ }
2802
2911
  return () => { alive = false; source.close() }
2803
2912
  }, [])
2804
2913
 
@@ -2937,6 +3046,15 @@ window.__ModuleLoader__.load({
2937
3046
  const simById = new Map(sim.results.map((hit) => [hit.id, hit]))
2938
3047
  const omittedById = new Map()
2939
3048
  const truncatedById = new Map()
3049
+ /**
3050
+ * Persona budget marks, from the same plan. `injection.profiles` lists
3051
+ * every persona that actually reached the prompt, with `truncated` when
3052
+ * the ceiling cut it; an enabled persona MISSING from that list was
3053
+ * dropped whole. Both were published by the Host and never read here, so
3054
+ * the panel kept showing full text the model never received.
3055
+ */
3056
+ const renderedProfileById = new Map()
3057
+ const truncatedProfileById = new Map()
2940
3058
  {
2941
3059
  const planItems = injection && typeof injection === 'object' ? injection : {}
2942
3060
  for (const item of Array.isArray(planItems.omitted) ? planItems.omitted : []) {
@@ -2947,6 +3065,12 @@ window.__ModuleLoader__.load({
2947
3065
  truncatedById.set(item.id, item)
2948
3066
  }
2949
3067
  }
3068
+ for (const item of Array.isArray(planItems.profiles) ? planItems.profiles : []) {
3069
+ if (item !== null && typeof item === 'object' && typeof item.id === 'string') {
3070
+ renderedProfileById.set(item.id, item)
3071
+ if (item.truncated === true) truncatedProfileById.set(item.id, item)
3072
+ }
3073
+ }
2950
3074
  }
2951
3075
  const keyword = query.trim().toLowerCase()
2952
3076
  const filtersActive = tagFilter !== '' || modeFilter !== '' || statusFilter !== ''
@@ -3279,13 +3403,28 @@ window.__ModuleLoader__.load({
3279
3403
  : null,
3280
3404
  profiles.length === 0 && profileDraft === null
3281
3405
  ? h('div', { className: 'im-empty' }, t('profiles.empty'))
3282
- : profiles.map((profile) => h('div', {
3406
+ : profiles.map((profile) => {
3407
+ // Only an ENABLED persona with content can be in the plan, so a
3408
+ // disabled card never wears a stale mark. A Host that publishes no
3409
+ // `injection.profiles` leaves both maps empty and no mark renders.
3410
+ const personaMark = profile.enabled !== true || profile.content === ''
3411
+ ? null
3412
+ : (truncatedProfileById.has(profile.id)
3413
+ ? 'truncated'
3414
+ : (renderedProfileById.has(profile.id) ? null : 'omitted'))
3415
+ return h('div', {
3283
3416
  className: 'im-profile-item' + (profile.enabled === true ? '' : ' im-profile-item-off'),
3284
3417
  key: profile.id,
3285
3418
  },
3286
3419
  h('div', { className: 'im-item-head' },
3287
3420
  h('span', { className: 'im-profile-name' },
3288
3421
  profile.name !== '' ? profile.name : t('profiles.untitled')),
3422
+ personaMark === null
3423
+ ? null
3424
+ : h('span', {
3425
+ className: 'im-tag ' + (personaMark === 'omitted' ? 'im-tag-off' : 'im-tag-cut'),
3426
+ title: personaMark === 'omitted' ? t('profiles.omittedTitle') : t('profiles.truncatedTitle'),
3427
+ }, personaMark === 'omitted' ? t('profiles.omitted') : t('profiles.truncated')),
3289
3428
  h('span', { className: 'im-spacer' }),
3290
3429
  h('label', { className: 'im-switch' },
3291
3430
  h('input', {
@@ -3308,7 +3447,8 @@ window.__ModuleLoader__.load({
3308
3447
  h(EntryBody, { text: profile.content, key: 'body' }),
3309
3448
  profile.updatedAt > 0
3310
3449
  ? h('div', { className: 'im-meta' }, t('entry.updatedAt', { time: new Date(profile.updatedAt).toLocaleString() }))
3311
- : null)),
3450
+ : null)
3451
+ }),
3312
3452
  activeProfiles > 0
3313
3453
  ? h('div', { className: 'im-meta im-meter-line' }, t('profiles.chars', { count: profileChars }))
3314
3454
  : null),
@@ -3533,6 +3673,21 @@ window.__ModuleLoader__.load({
3533
3673
  }, batchMode ? t('batch.exit') : t('action.batch')),
3534
3674
  h('span', { className: 'im-spacer' }),
3535
3675
  h('span', { className: 'im-meta' }, t('stats.count', { total: entries.length, active: activeCount }))),
3676
+ exportFallback !== null
3677
+ ? h('div', { className: 'im-diag-fallback' },
3678
+ h('div', { className: 'im-meta' }, t('export.fallbackHint')),
3679
+ h('textarea', {
3680
+ className: 'im-textarea',
3681
+ readOnly: true,
3682
+ value: exportFallback,
3683
+ onFocus: (ev) => { if (typeof ev.target.select === 'function') ev.target.select() },
3684
+ }),
3685
+ h('div', { className: 'im-row-wrap' },
3686
+ h('button', { className: 'im-btn', onClick: () => setExportFallback(null) }, t('export.fallbackClose'))))
3687
+ : null,
3688
+ isAndroidWebView()
3689
+ ? h('div', { className: 'im-meta' }, t('import.webviewHint'))
3690
+ : null,
3536
3691
  h('div', { className: 'im-divider' }),
3537
3692
  h('div', { className: 'im-meta' },
3538
3693
  storage.path ? t('storage.file', { path: storage.path }) : t('storage.noFile')),
@@ -3653,6 +3808,7 @@ window.__ModuleLoader__.load({
3653
3808
  }))
3654
3809
  : null)
3655
3810
  : null),
3811
+ liveDown ? h('div', { className: 'im-meta', role: 'status' }, t('sse.down')) : null,
3656
3812
  error !== '' ? h('div', { className: 'im-err', role: 'alert' }, error) : null,
3657
3813
  error === '' && notice !== '' ? h('div', { className: 'im-ok', role: 'status', 'aria-live': 'polite' }, notice) : null,
3658
3814
  error === '' && notice === '' && storage.error ? h('div', { className: 'im-err', role: 'alert' }, storage.error) : null,
@@ -4018,7 +4174,7 @@ window.__ModuleLoader__.load({
4018
4174
  box.id = id
4019
4175
  box.setAttribute('role', 'status')
4020
4176
  box.style.cssText = [
4021
- 'position:fixed', 'right:16px', 'bottom:16px', 'z-index:2147483000',
4177
+ 'position:fixed', 'right:16px', 'bottom:calc(16px + env(safe-area-inset-bottom,0px))', 'z-index:2147483000',
4022
4178
  'max-width:min(380px,calc(100vw - 32px))',
4023
4179
  'background:#3a1d1d', 'color:#ffe8e8', 'border:1px solid #7a3a3a',
4024
4180
  'border-radius:10px', 'padding:12px 14px',
package/lib/index.js CHANGED
@@ -182,6 +182,24 @@ const TRUSTED_HOSTS_ENV = 'DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS'
182
182
  * volume.
183
183
  */
184
184
  const ALLOW_EXTERNAL_BACKUP_ENV = 'DSH_INSTRUCTION_MEMORY_ALLOW_EXTERNAL_BACKUP'
185
+ /**
186
+ * Opt-in "no fence, no route" switch.
187
+ *
188
+ * Without a `connection` service the plugin falls back to its own loopback /
189
+ * same-origin rule. On a desktop that is a real boundary; on Android it is not,
190
+ * because loopback lives in a namespace every app on the device shares — any
191
+ * app with INTERNET can then rewrite the instructions of every later turn.
192
+ * DSH Go's build-time route guard normally closes that, but the guard is a
193
+ * platform property: a `dsh web` started without `--patch` silently loses it.
194
+ * Setting this to 1 makes the plugin refuse instead of degrade, so the failure
195
+ * mode becomes a settings page that cannot load rather than a writable one.
196
+ */
197
+ const REQUIRE_AUTH_FENCE_ENV = 'DSH_INSTRUCTION_MEMORY_REQUIRE_AUTH_FENCE'
198
+ /** Is the fail-closed switch on? Read per request so a restart is not needed. */
199
+ const requireAuthFence = () => {
200
+ const raw = process.env[REQUIRE_AUTH_FENCE_ENV]
201
+ return typeof raw === 'string' && raw.trim() === '1'
202
+ }
185
203
  /**
186
204
  * How often the store file is stat-ed as a fallback for `fs.watch` (F19).
187
205
  *
@@ -1774,7 +1792,14 @@ export function buildBlock(data, now = Date.now()) {
1774
1792
  * Plugin
1775
1793
  * ------------------------------------------------------------------ */
1776
1794
 
1777
- export function apply(ctx) {
1795
+ export function apply(ctx, deps = {}) {
1796
+ /**
1797
+ * Test seam for the OS watcher. Production passes nothing and this resolves
1798
+ * to `node:fs`'s `watch`; the regression that matters — the watcher dying
1799
+ * and the 5s poller having to take over — otherwise needs a real directory
1800
+ * to be deleted mid-test, which no contract test can do reproducibly.
1801
+ */
1802
+ const watchStore = typeof deps.watch === 'function' ? deps.watch : watch
1778
1803
  const systemPrompt = ctx.systemPrompt
1779
1804
 
1780
1805
  const state = {
@@ -2209,6 +2234,18 @@ export function apply(ctx) {
2209
2234
  * pushSnapshot).
2210
2235
  */
2211
2236
  const sseStreams = new Map()
2237
+ /**
2238
+ * Monotonic counter of pushed frames. It exists because `rev` is a DISK
2239
+ * revision: it only moves when the store is written. Two legitimate pushes
2240
+ * change what the prompt sees without writing anything — the F7 pause
2241
+ * expiring (see the mute timer) and an external reload that keeps the file's
2242
+ * rev — and a lite subscriber compares rev to decide whether to refetch. Both
2243
+ * therefore produced a frame identical to the one already on screen, so the
2244
+ * panel kept showing the muted/old state while the host had already moved on.
2245
+ * `seq` is process-local and never persisted; a full-frame subscriber never
2246
+ * reads it.
2247
+ */
2248
+ let stateSeq = 0
2212
2249
  /**
2213
2250
  * Shared heartbeat over every live event stream, started with the first
2214
2251
  * subscriber and stopped with the last. Two jobs:
@@ -2264,12 +2301,14 @@ export function apply(ctx) {
2264
2301
 
2265
2302
  const pushSnapshot = () => {
2266
2303
  if (disposed || sseStreams.size === 0) return
2304
+ // Every broadcast bumps the counter, whether or not the disk rev moved.
2305
+ stateSeq += 1
2267
2306
  // A lite stream only needs to know that the store moved: it fetches the
2268
2307
  // snapshot through POST `state` when the rev it sees differs from the one
2269
2308
  // on screen. The full frame is built LAZILY, so a page whose subscribers
2270
2309
  // are all lite (the current client) never pays for serialising the whole
2271
2310
  // store — up to ~3.4 MB at the 200 × 6,000-character cap — on every save.
2272
- const litePayload = 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true }) + '\n\n'
2311
+ const litePayload = 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true, seq: stateSeq }) + '\n\n'
2273
2312
  let fullPayload = null
2274
2313
  for (const [send, entry] of Array.from(sseStreams)) {
2275
2314
  try {
@@ -2401,7 +2440,7 @@ export function apply(ctx) {
2401
2440
  const startWatching = () => {
2402
2441
  if (disposed || storeWatcher !== null) return
2403
2442
  try {
2404
- storeWatcher = watch(dirname(storePath), { persistent: false }, (event, filename) => {
2443
+ storeWatcher = watchStore(dirname(storePath), { persistent: false }, (event, filename) => {
2405
2444
  // Only events naming memory.json matter; the .tmp/.bak/.lock churn of
2406
2445
  // our own writes is filtered out here (the content compare in
2407
2446
  // onStoreFileEvent catches what slips through).
@@ -2511,6 +2550,17 @@ export function apply(ctx) {
2511
2550
  const raw = process.env[ALLOW_EXTERNAL_BACKUP_ENV]
2512
2551
  return typeof raw === 'string' && raw.trim() === '1'
2513
2552
  }
2553
+ /**
2554
+ * The "set this env var and restart" footer shared by the three backup paths.
2555
+ *
2556
+ * On Linux — and therefore on every Android build, DSH Go included — the
2557
+ * engine is started through `/usr/bin/env -i`, which leaves no way for the
2558
+ * user to set it. Telling them to export a variable they cannot export is a
2559
+ * dead instruction; point at the route that does work instead.
2560
+ */
2561
+ const externalBackupHint = () => (process.platform === 'linux'
2562
+ ? '要使用其他磁盘请设置 ' + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主(DSH Go / Android 用 env -i 启动引擎,该变量设不了,请改用「导出」把 JSON 复制出来备份)'
2563
+ : '要使用其他磁盘请设置 ' + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主')
2514
2564
  /** `path`'s real location, or null when it (or a parent) is unavailable. */
2515
2565
  const realPathOrNull = (path) => {
2516
2566
  try {
@@ -2576,7 +2626,7 @@ export function apply(ctx) {
2576
2626
  if (refusedBackupDir !== configured) {
2577
2627
  refusedBackupDir = configured
2578
2628
  state.storageWarning = '备份位置 ' + displayPath(configured) + ' 不在 DSH 主目录内,已忽略(备份与历史快照一律写回默认位置);'
2579
- + '要使用其他磁盘请设置 ' + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主'
2629
+ + externalBackupHint()
2580
2630
  logInfo('ignoring backupDir outside the harness home: ' + configured)
2581
2631
  }
2582
2632
  return ''
@@ -3986,8 +4036,7 @@ export function apply(ctx) {
3986
4036
  return {
3987
4037
  ok: false,
3988
4038
  saved: false,
3989
- message: '备份位置必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');要使用其他磁盘请设置环境变量 '
3990
- + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
4039
+ message: '备份位置必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');' + externalBackupHint(),
3991
4040
  snapshot: snapshot(),
3992
4041
  }
3993
4042
  }
@@ -4620,8 +4669,7 @@ export function apply(ctx) {
4620
4669
  return {
4621
4670
  ok: false,
4622
4671
  saved: false,
4623
- message: '导出目录必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');要写入其他磁盘请设置环境变量 '
4624
- + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
4672
+ message: '导出目录必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');' + externalBackupHint(),
4625
4673
  snapshot: snapshot(),
4626
4674
  }
4627
4675
  }
@@ -5313,7 +5361,7 @@ export function apply(ctx) {
5313
5361
  * `?lite=1` is additive on purpose: a client that does not ask (every build
5314
5362
  * before this one) keeps receiving the bare snapshot exactly as documented,
5315
5363
  * so an old client against a new Host is unchanged. A lite subscriber gets
5316
- * `{ rev, lite: true }` and fetches the snapshot itself when the rev differs
5364
+ * `{ rev, lite, seq }` and fetches the snapshot itself when either moved
5317
5365
  * — which is the difference between pushing ~3.4 MB per save (maximal store)
5318
5366
  * and pushing ~40 bytes.
5319
5367
  *
@@ -5342,7 +5390,7 @@ export function apply(ctx) {
5342
5390
  *
5343
5391
  * Frames carry the bare snapshot — the same shape `state` answers with —
5344
5392
  * which the Client's normalizer already accepts. A subscriber that appends
5345
- * `?lite=1` gets `{ rev, lite: true }` frames instead (see pushSnapshot).
5393
+ * `?lite=1` gets `{ rev, lite, seq }` frames instead (see pushSnapshot).
5346
5394
  */
5347
5395
  const handleEventStream = (req, res) => {
5348
5396
  // Same fence as the POST path: an EventSource carries the login cookie, so
@@ -5367,6 +5415,16 @@ export function apply(ctx) {
5367
5415
  'cache-control': 'no-store',
5368
5416
  connection: 'keep-alive',
5369
5417
  })
5418
+ // Mobile renderers are killed without a FIN (Android LMK) or lose their
5419
+ // network mid-stream (Wi-Fi → cellular). The heartbeat cannot notice a peer
5420
+ // that never errors — every write succeeds into the socket buffer — so at
5421
+ // least let the OS probe for a dead peer, and never sit on Nagle.
5422
+ try {
5423
+ if (res.socket && typeof res.socket.setKeepAlive === 'function') res.socket.setKeepAlive(true, 30_000)
5424
+ if (res.socket && typeof res.socket.setNoDelay === 'function') res.socket.setNoDelay(true)
5425
+ } catch {
5426
+ // A double without a socket (tests) or a closed one: nothing to tune.
5427
+ }
5370
5428
  // Ask the browser to retry politely if the stream drops (e.g. the host was
5371
5429
  // restarted) instead of hammering the route.
5372
5430
  res.write('retry: 5000\n\n')
@@ -5399,7 +5457,7 @@ export function apply(ctx) {
5399
5457
  if (closed) return
5400
5458
  try {
5401
5459
  send(lite
5402
- ? 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true }) + '\n\n'
5460
+ ? 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true, seq: stateSeq }) + '\n\n'
5403
5461
  : 'data: ' + JSON.stringify(snapshot()) + '\n\n')
5404
5462
  } catch {
5405
5463
  cleanup()
@@ -5463,6 +5521,24 @@ export function apply(ctx) {
5463
5521
  }
5464
5522
  return 'gate'
5465
5523
  }
5524
+ /**
5525
+ * Deeper defence for deployments where loopback is not a boundary: on
5526
+ * Android every app shares the loopback namespace, so "reachable only from
5527
+ * this machine" stops meaning "reachable only from this user". With the
5528
+ * switch set, a profile without a fence gets NO route at all — a settings
5529
+ * page that fails to load is a far better outcome than one that can be
5530
+ * rewritten by any installed app.
5531
+ */
5532
+ if (requireAuthFence()) {
5533
+ logInfo('route refused a request: no connection fence and ' + REQUIRE_AUTH_FENCE_ENV + '=1')
5534
+ res.writeHead(503, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
5535
+ res.end(JSON.stringify({
5536
+ ok: false,
5537
+ message: '该 profile 没有宿主的 connection 服务,已按 ' + REQUIRE_AUTH_FENCE_ENV
5538
+ + '=1 拒绝请求(fail-closed);如需在无鉴权宿主上使用,请移除该环境变量。',
5539
+ }))
5540
+ return 'rejected'
5541
+ }
5466
5542
  if (!guardHost) {
5467
5543
  logInfo('route refused a request: non-loopback bind without an authentication fence')
5468
5544
  res.writeHead(503, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
@@ -37,7 +37,7 @@
37
37
  "type": "integer",
38
38
  "minimum": 800,
39
39
  "maximum": 40000,
40
- "description": "指令注入字数上限(字符,非 token)。超出部分按优先级取舍,未注入的条目会在设置页标记。用户形象不占用此上限。",
40
+ "description": "指令注入字数上限(字符,非 token)。超出部分按优先级取舍,未注入的条目会在设置页标记。用户形象同样计入此上限:形象先占用、指令拿余量。",
41
41
  "default": 4000
42
42
  },
43
43
  "backupDir": {
@@ -135,7 +135,7 @@
135
135
  "profiles": {
136
136
  "type": "array",
137
137
  "maxItems": 50,
138
- "description": "用户形象:关于**用户本人**的身份背景、喜好、性格与追求。它们不参与指令字数上限,始终完整注入,并带有一段「仅供调整表达侧重、不得为迎合而改变事实」的护栏说明;形象不是让模型扮演的角色。",
138
+ "description": "用户形象:关于**用户本人**的身份背景、喜好、性格与追求。它们与指令共用同一个注入字数上限(形象先占用、指令拿余量),形象段自身另有 4000 字符上限,超出时按序截断并在段末说明;带有一段「仅供调整表达侧重、不得为迎合而改变事实」的护栏说明;形象不是让模型扮演的角色。",
139
139
  "items": {
140
140
  "type": "object",
141
141
  "properties": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
4
  "description": "User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the model has no write access. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,8 @@
31
31
  "CHANGELOG.md",
32
32
  "LICENSE",
33
33
  "memory.schema.json",
34
- "docs/screenshot.png"
34
+ "docs/screenshot.png",
35
+ "docs/demo.html"
35
36
  ],
36
37
  "keywords": [
37
38
  "dsh",