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/CHANGELOG.md +84 -0
- package/README.md +151 -14
- package/docs/demo.html +671 -0
- package/lib/client.js +170 -14
- package/lib/index.js +87 -11
- package/memory.schema.json +2 -2
- package/package.json +3 -2
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
|
|
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
|
-
//
|
|
1041
|
-
//
|
|
1042
|
-
//
|
|
1043
|
-
//
|
|
1044
|
-
|
|
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
|
|
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) =>
|
|
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 =
|
|
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
|
-
+
|
|
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
|
|
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
|
|
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' })
|
package/memory.schema.json
CHANGED
|
@@ -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
|
+
"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",
|