dsh-instruction-memory 1.0.4 → 1.0.6
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/README.md +13 -1
- package/lib/client.js +148 -17
- package/lib/index.js +220 -26
- package/package.json +9 -4
package/README.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
在 DSH 设置界面中维护**长期指令记忆**:保存后自动注入此后每一轮对话的系统提示词。
|
|
4
4
|
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
> 想先看看界面?`docs/demo.html` 无需安装 DSH,浏览器直接打开即可体验真实设置页(内置演示数据)。
|
|
8
|
+
|
|
5
9
|
## 安装
|
|
6
10
|
|
|
7
11
|
```sh
|
|
@@ -32,6 +36,7 @@ dsh plugin --profile web remove dsh-instruction-memory
|
|
|
32
36
|
| `contract-test.mjs` | 契约测试:驱动真实路由,验证两半之间的响应格式 |
|
|
33
37
|
| `verify.mjs` | 装载验证:按 DSH 的方式加载两半 |
|
|
34
38
|
| `smoke-test.mjs` | 注入渲染逻辑的单元测试 |
|
|
39
|
+
| `docs/demo.html` | 离线演示页:桩掉 DSH 外壳、加载真实 Client 半渲染设置页(不随 npm 包发布) |
|
|
35
40
|
|
|
36
41
|
## 生效方式
|
|
37
42
|
|
|
@@ -57,7 +62,14 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。
|
|
|
57
62
|
> 1.0.0 之前的构建曾把数据写在宿主进程 cwd 下(`.dsh-instruction-memory.json`)。
|
|
58
63
|
> 首次启动新版本时会自动迁移到上述位置,并把原文件改名为 `*.migrated` 保留。
|
|
59
64
|
|
|
60
|
-
|
|
65
|
+
该文件可手工编辑,改完点设置页的「重新载入」即可读回。每次保存先写临时文件再原子替换,
|
|
66
|
+
进程崩溃不会留下损坏的半截 `memory.json`;保存时上一版会保留为 `memory.json.bak`,
|
|
67
|
+
若 `memory.json` 解析失败会自动临时回退到该备份并明确提示。若手工编辑后的文件有条目为空
|
|
68
|
+
或超过 200 条上限,设置页会明确提示哪些条目未能载入(它们将在下次保存时从文件中移除),
|
|
69
|
+
不会静默丢弃。
|
|
70
|
+
|
|
71
|
+
设置页支持**导出 / 导入**:导出为自带说明的 JSON 文件;导入采用**合并语义**——按 id 与
|
|
72
|
+
「标题+内容」去重,只新增、绝不覆盖或清空现有条目。
|
|
61
73
|
|
|
62
74
|
## 兼容性
|
|
63
75
|
|
package/lib/client.js
CHANGED
|
@@ -57,6 +57,7 @@ window.__ModuleLoader__.load({
|
|
|
57
57
|
border:1px dashed var(--dsw-alias-border-l2,rgba(0,0,0,.18)); color:var(--dsw-alias-label-secondary,#8a9099); }
|
|
58
58
|
.im-err { font-size:12px; color:var(--dsw-alias-state-error-primary,#d94838); }
|
|
59
59
|
.im-ok { font-size:12px; color:var(--dsw-alias-state-success-primary,#18a058); }
|
|
60
|
+
.im-warn { font-size:12px; color:var(--dsw-alias-state-warn-primary,#d97706); }
|
|
60
61
|
.im-meta { font-size:11.5px; color:var(--dsw-alias-label-secondary,#8a9099); word-break:break-all; }
|
|
61
62
|
.im-pre { margin:0; padding:12px 14px; border-radius:8px; font-size:11.5px; line-height:1.65;
|
|
62
63
|
background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04)); color:var(--dsw-alias-label-secondary,#4b5563);
|
|
@@ -70,6 +71,9 @@ window.__ModuleLoader__.load({
|
|
|
70
71
|
.im-dot-on { background:var(--dsw-alias-state-success-primary,#18a058); }
|
|
71
72
|
.im-dot-off { background:var(--dsw-alias-state-warn-primary,#d97706); }
|
|
72
73
|
.im-dot-err { background:var(--dsw-alias-state-error-primary,#d94838); }
|
|
74
|
+
.im-body-open { max-height:none; }
|
|
75
|
+
.im-more { margin-top:4px; font-size:11.5px; color:var(--dsw-alias-brand-primary,#3b6cff); cursor:pointer; user-select:none; }
|
|
76
|
+
.im-search-meta { margin-top:5px; }
|
|
73
77
|
@media (max-width:640px) { .im-grid2 { grid-template-columns:1fr; } }
|
|
74
78
|
`
|
|
75
79
|
|
|
@@ -107,13 +111,25 @@ window.__ModuleLoader__.load({
|
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
async function callHost(method, args) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
// A hung host used to pin the panel on 保存中… forever; fail visibly
|
|
115
|
+
// after a generous timeout instead.
|
|
116
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
117
|
+
const timer = controller !== null ? setTimeout(() => controller.abort(), 15000) : null
|
|
118
|
+
try {
|
|
119
|
+
const response = await fetch(API, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { 'content-type': 'application/json' },
|
|
122
|
+
body: JSON.stringify({ method, args: args === undefined ? null : args }),
|
|
123
|
+
signal: controller !== null ? controller.signal : undefined,
|
|
124
|
+
})
|
|
125
|
+
if (!response.ok) throw new Error('宿主返回 HTTP ' + response.status)
|
|
126
|
+
return await response.json()
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (controller !== null && controller.signal.aborted) throw new Error('请求宿主超时(15 秒无响应)')
|
|
129
|
+
throw err
|
|
130
|
+
} finally {
|
|
131
|
+
if (timer !== null) clearTimeout(timer)
|
|
132
|
+
}
|
|
117
133
|
}
|
|
118
134
|
|
|
119
135
|
function EntryEditor(props) {
|
|
@@ -126,6 +142,7 @@ window.__ModuleLoader__.load({
|
|
|
126
142
|
h('input', {
|
|
127
143
|
className: 'im-input',
|
|
128
144
|
value: draft.title,
|
|
145
|
+
maxLength: 120,
|
|
129
146
|
placeholder: '例如:回答语言',
|
|
130
147
|
onChange: (ev) => set({ title: ev.target.value }),
|
|
131
148
|
})),
|
|
@@ -134,6 +151,7 @@ window.__ModuleLoader__.load({
|
|
|
134
151
|
h('textarea', {
|
|
135
152
|
className: 'im-textarea',
|
|
136
153
|
value: draft.content,
|
|
154
|
+
maxLength: 6000,
|
|
137
155
|
placeholder: '例如:所有回答使用简体中文;不确定需求时先提问再动手。',
|
|
138
156
|
onChange: (ev) => set({ content: ev.target.value }),
|
|
139
157
|
})),
|
|
@@ -164,6 +182,7 @@ window.__ModuleLoader__.load({
|
|
|
164
182
|
h('input', {
|
|
165
183
|
className: 'im-input',
|
|
166
184
|
value: draft.when,
|
|
185
|
+
maxLength: 200,
|
|
167
186
|
placeholder: '例如:编写或修改代码时',
|
|
168
187
|
onChange: (ev) => set({ when: ev.target.value }),
|
|
169
188
|
})))
|
|
@@ -177,6 +196,24 @@ window.__ModuleLoader__.load({
|
|
|
177
196
|
return h('div', { className: 'im-card' }, children)
|
|
178
197
|
}
|
|
179
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Entry body with an explicit 展开/收起 toggle. The collapsed cap lives in
|
|
201
|
+
* CSS (max-height + overflow:hidden), so whether overflow actually happens
|
|
202
|
+
* is measured from the DOM — a short body renders no toggle at all.
|
|
203
|
+
*/
|
|
204
|
+
function EntryBody(props) {
|
|
205
|
+
const bodyRef = React.useRef(null)
|
|
206
|
+
const [overflowing, setOverflowing] = React.useState(false)
|
|
207
|
+
const [open, setOpen] = React.useState(false)
|
|
208
|
+
React.useEffect(() => {
|
|
209
|
+
const el = bodyRef.current
|
|
210
|
+
if (el !== null && el !== undefined) setOverflowing(el.scrollHeight > el.clientHeight + 1)
|
|
211
|
+
}, [props.text])
|
|
212
|
+
return h('div', null,
|
|
213
|
+
h('div', { className: 'im-body' + (open ? ' im-body-open' : ''), ref: bodyRef }, props.text),
|
|
214
|
+
overflowing ? h('div', { className: 'im-more', onClick: () => setOpen(!open) }, open ? '收起' : '展开全部') : null)
|
|
215
|
+
}
|
|
216
|
+
|
|
180
217
|
function InstructionPanel() {
|
|
181
218
|
const [view, setView] = React.useState(null)
|
|
182
219
|
const [error, setError] = React.useState('')
|
|
@@ -185,6 +222,8 @@ window.__ModuleLoader__.load({
|
|
|
185
222
|
const [draft, setDraft] = React.useState(null)
|
|
186
223
|
const [budgetText, setBudgetText] = React.useState('')
|
|
187
224
|
const [showPreview, setShowPreview] = React.useState(false)
|
|
225
|
+
const [query, setQuery] = React.useState('')
|
|
226
|
+
const importInputRef = React.useRef(null)
|
|
188
227
|
|
|
189
228
|
function absorb(result) {
|
|
190
229
|
const normalized = normalizeResult(result)
|
|
@@ -219,6 +258,50 @@ window.__ModuleLoader__.load({
|
|
|
219
258
|
}
|
|
220
259
|
}
|
|
221
260
|
|
|
261
|
+
async function doExport() {
|
|
262
|
+
try {
|
|
263
|
+
const result = await callHost('export-data', null)
|
|
264
|
+
if (result === null || typeof result !== 'object' || result.export === undefined || result.export === null) {
|
|
265
|
+
setError('导出失败:宿主返回了无法识别的数据')
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
const blob = new Blob([JSON.stringify(result.export, null, 2) + '\n'], { type: 'application/json' })
|
|
269
|
+
const url = URL.createObjectURL(blob)
|
|
270
|
+
const link = document.createElement('a')
|
|
271
|
+
link.href = url
|
|
272
|
+
link.download = 'dsh-instruction-memory-' + new Date().toISOString().slice(0, 10) + '.json'
|
|
273
|
+
document.body.appendChild(link)
|
|
274
|
+
link.click()
|
|
275
|
+
if (link.parentNode !== null) link.parentNode.removeChild(link)
|
|
276
|
+
setTimeout(() => { URL.revokeObjectURL(url) }, 1000)
|
|
277
|
+
} catch (err) {
|
|
278
|
+
setError(String(err && err.message ? err.message : err))
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function handleImportFile(file) {
|
|
283
|
+
try {
|
|
284
|
+
const text = typeof file.text === 'function'
|
|
285
|
+
? await file.text()
|
|
286
|
+
: await new Promise((resolve, reject) => {
|
|
287
|
+
const reader = new FileReader()
|
|
288
|
+
reader.onload = () => resolve(String(reader.result))
|
|
289
|
+
reader.onerror = () => reject(reader.error)
|
|
290
|
+
reader.readAsText(file)
|
|
291
|
+
})
|
|
292
|
+
let payload
|
|
293
|
+
try {
|
|
294
|
+
payload = JSON.parse(text)
|
|
295
|
+
} catch (err) {
|
|
296
|
+
setError('导入文件不是有效的 JSON(' + String(err && err.message ? err.message : err) + ')')
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
await send('import-data', { payload })
|
|
300
|
+
} catch (err) {
|
|
301
|
+
setError(String(err && err.message ? err.message : err))
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
222
305
|
React.useEffect(() => {
|
|
223
306
|
let alive = true
|
|
224
307
|
callHost('state', null).then(
|
|
@@ -253,10 +336,20 @@ window.__ModuleLoader__.load({
|
|
|
253
336
|
}
|
|
254
337
|
|
|
255
338
|
const data = view.data
|
|
256
|
-
|
|
339
|
+
// Older hosts can hand over snapshots without storage/limits; fall back
|
|
340
|
+
// instead of throwing mid-render.
|
|
341
|
+
const storage = view.storage || {}
|
|
342
|
+
const limits = view.limits || { minBudget: 800, maxBudget: 40000, maxEntries: 200 }
|
|
257
343
|
const injection = view.injection || { registered: false, chars: 0, error: null }
|
|
258
344
|
const entries = data.entries.slice().sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
|
|
259
345
|
const activeCount = entries.filter((e) => e.enabled && e.content !== '').length
|
|
346
|
+
const keyword = query.trim().toLowerCase()
|
|
347
|
+
const visible = keyword === ''
|
|
348
|
+
? entries
|
|
349
|
+
: entries.filter((entry) =>
|
|
350
|
+
entry.title.toLowerCase().indexOf(keyword) !== -1
|
|
351
|
+
|| entry.content.toLowerCase().indexOf(keyword) !== -1
|
|
352
|
+
|| entry.when.toLowerCase().indexOf(keyword) !== -1)
|
|
260
353
|
|
|
261
354
|
const dotClass = injection.error
|
|
262
355
|
? 'im-dot im-dot-err'
|
|
@@ -289,17 +382,17 @@ window.__ModuleLoader__.load({
|
|
|
289
382
|
h('input', {
|
|
290
383
|
className: 'im-input',
|
|
291
384
|
type: 'number',
|
|
292
|
-
min:
|
|
293
|
-
max:
|
|
385
|
+
min: limits.minBudget,
|
|
386
|
+
max: limits.maxBudget,
|
|
294
387
|
step: 100,
|
|
295
388
|
value: budgetText,
|
|
296
389
|
disabled: busy,
|
|
297
390
|
onChange: (ev) => setBudgetText(ev.target.value),
|
|
298
391
|
onBlur: () => {
|
|
299
392
|
const parsed = Number(budgetText)
|
|
300
|
-
if (!Number.isFinite(parsed) || parsed <
|
|
393
|
+
if (!Number.isFinite(parsed) || parsed < limits.minBudget || parsed > limits.maxBudget) {
|
|
301
394
|
setBudgetText(String(data.budgetChars))
|
|
302
|
-
setError('上限需在 ' +
|
|
395
|
+
setError('上限需在 ' + limits.minBudget + ' ~ ' + limits.maxBudget + ' 之间')
|
|
303
396
|
return
|
|
304
397
|
}
|
|
305
398
|
send('set-options', { budgetChars: parsed })
|
|
@@ -309,6 +402,14 @@ window.__ModuleLoader__.load({
|
|
|
309
402
|
h('label', { className: 'im-label' }, '生效规则'),
|
|
310
403
|
h('div', { className: 'im-meta' },
|
|
311
404
|
'「始终」无条件生效;「按需」由模型按相关性自动判断。超出上限时按优先级保留,其余暂不注入。'))),
|
|
405
|
+
entries.length > 0 ? h('div', { key: 'search' },
|
|
406
|
+
h('input', {
|
|
407
|
+
className: 'im-input',
|
|
408
|
+
value: query,
|
|
409
|
+
placeholder: '搜索指令:标题 / 内容 / 适用场景(共 ' + entries.length + ' 条)',
|
|
410
|
+
onChange: (ev) => setQuery(ev.target.value),
|
|
411
|
+
}),
|
|
412
|
+
query.trim() !== '' ? h('div', { className: 'im-meta im-search-meta' }, '匹配 ' + visible.length + ' / ' + entries.length + ' 条') : null) : null,
|
|
312
413
|
h('div', { className: 'im-row-wrap' },
|
|
313
414
|
h('button', {
|
|
314
415
|
className: 'im-btn im-btn-primary',
|
|
@@ -316,6 +417,15 @@ window.__ModuleLoader__.load({
|
|
|
316
417
|
onClick: () => { setDraft(blankDraft()); setError('') },
|
|
317
418
|
}, '+ 新增指令'),
|
|
318
419
|
h('button', { className: 'im-btn', disabled: busy, onClick: () => send('reload', null) }, '重新载入'),
|
|
420
|
+
h('button', { className: 'im-btn', disabled: busy, onClick: doExport }, '导出'),
|
|
421
|
+
h('button', {
|
|
422
|
+
className: 'im-btn',
|
|
423
|
+
disabled: busy,
|
|
424
|
+
onClick: () => {
|
|
425
|
+
const el = importInputRef.current
|
|
426
|
+
if (el !== null && el !== undefined) el.click()
|
|
427
|
+
},
|
|
428
|
+
}, '导入'),
|
|
319
429
|
h('button', {
|
|
320
430
|
className: 'im-btn',
|
|
321
431
|
disabled: busy,
|
|
@@ -330,14 +440,27 @@ window.__ModuleLoader__.load({
|
|
|
330
440
|
storage.migratedFrom
|
|
331
441
|
? h('div', { className: 'im-ok' }, '已从旧位置迁移:' + storage.migratedFrom + '(原文件保留为 *.migrated)')
|
|
332
442
|
: null,
|
|
443
|
+
storage.warning ? h('div', { className: 'im-warn' }, storage.warning) : null,
|
|
333
444
|
error !== '' ? h('div', { className: 'im-err' }, error) : null,
|
|
334
445
|
error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
|
|
335
446
|
error === '' && notice === '' && storage.error ? h('div', { className: 'im-err' }, storage.error) : null,
|
|
336
447
|
showPreview ? h('div', null,
|
|
337
448
|
h('label', { className: 'im-label' }, '当前注入到每轮系统提示词的内容'),
|
|
338
|
-
h('pre', { className: 'im-pre' }, view.preview === '' ? '(当前没有生效中的指令)' : view.preview)) : null
|
|
449
|
+
h('pre', { className: 'im-pre' }, view.preview === '' ? '(当前没有生效中的指令)' : view.preview)) : null,
|
|
450
|
+
h('input', {
|
|
451
|
+
key: 'import-input',
|
|
452
|
+
type: 'file',
|
|
453
|
+
accept: '.json,application/json',
|
|
454
|
+
style: { display: 'none' },
|
|
455
|
+
ref: importInputRef,
|
|
456
|
+
onChange: (ev) => {
|
|
457
|
+
const file = ev.target.files && ev.target.files.length > 0 ? ev.target.files[0] : null
|
|
458
|
+
ev.target.value = ''
|
|
459
|
+
if (file !== null) void handleImportFile(file)
|
|
460
|
+
},
|
|
461
|
+
}))
|
|
339
462
|
|
|
340
|
-
const items =
|
|
463
|
+
const items = visible.map((entry) => {
|
|
341
464
|
const tags = [
|
|
342
465
|
h('span', {
|
|
343
466
|
className: 'im-tag ' + (entry.mode === 'always' ? 'im-tag-always' : 'im-tag-auto'),
|
|
@@ -374,9 +497,15 @@ window.__ModuleLoader__.load({
|
|
|
374
497
|
h('button', {
|
|
375
498
|
className: 'im-btn im-btn-danger',
|
|
376
499
|
disabled: busy,
|
|
377
|
-
onClick: () =>
|
|
500
|
+
onClick: () => {
|
|
501
|
+
// confirm() is standard DOM, guarded anyway: if the embedded
|
|
502
|
+
// shell omits it, deletion proceeds rather than breaking.
|
|
503
|
+
if (typeof window.confirm === 'function'
|
|
504
|
+
&& !window.confirm('确定删除「' + (entry.title !== '' ? entry.title : '未命名指令') + '」?删除后无法恢复。')) return
|
|
505
|
+
send('delete-entry', { id: entry.id })
|
|
506
|
+
},
|
|
378
507
|
}, '删除')),
|
|
379
|
-
h(
|
|
508
|
+
h(EntryBody, { text: entry.content, key: 'body' }),
|
|
380
509
|
entry.mode === 'auto' && entry.when !== ''
|
|
381
510
|
? h('div', { className: 'im-meta' }, '适用场景:' + entry.when)
|
|
382
511
|
: null)
|
|
@@ -385,7 +514,9 @@ window.__ModuleLoader__.load({
|
|
|
385
514
|
const list = entries.length === 0
|
|
386
515
|
? h('div', { className: 'im-empty', key: 'empty' },
|
|
387
516
|
'还没有指令记忆。点击「+ 新增指令」添加第一条,例如“所有回答使用简体中文”。')
|
|
388
|
-
:
|
|
517
|
+
: visible.length === 0
|
|
518
|
+
? h('div', { className: 'im-empty', key: 'no-match' }, '没有匹配“' + query.trim() + '”的指令。')
|
|
519
|
+
: h('div', { className: 'im-list', key: 'list' }, items)
|
|
389
520
|
|
|
390
521
|
return h('div', { className: 'im-root' }, header, list)
|
|
391
522
|
}
|
package/lib/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* plugin's fiber, so unmounting the row removes all of them.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
13
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
14
14
|
import { homedir } from 'node:os'
|
|
15
15
|
import { dirname, join } from 'node:path'
|
|
16
16
|
|
|
@@ -38,6 +38,8 @@ const LEGACY_FILE_NAME = '.dsh-instruction-memory.json'
|
|
|
38
38
|
const SECTION_NAME = 'instruction-memory'
|
|
39
39
|
const SECTION_ORDER = 900
|
|
40
40
|
const API_PREFIX = '/instruction-memory/api'
|
|
41
|
+
/** Request body ceiling for the settings-page route; anything larger is rejected. */
|
|
42
|
+
const MAX_BODY_CHARS = 1_000_000
|
|
41
43
|
|
|
42
44
|
/**
|
|
43
45
|
* Resolve this plugin's data file under the harness home.
|
|
@@ -120,24 +122,43 @@ export function sanitizeEntry(raw) {
|
|
|
120
122
|
}
|
|
121
123
|
}
|
|
122
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Load a parsed memory file. Returns the sanitized data plus how many array
|
|
127
|
+
* items were discarded (blank entries, or valid ones past the cap). The count
|
|
128
|
+
* is surfaced rather than swallowed because the next save rewrites the file
|
|
129
|
+
* without those items — silently destroying user data is exactly what this
|
|
130
|
+
* plugin must never do.
|
|
131
|
+
*/
|
|
123
132
|
function normalizeData(raw) {
|
|
124
|
-
const
|
|
125
|
-
if (raw === null || typeof raw !== 'object') return
|
|
126
|
-
if (typeof raw.enabled === 'boolean')
|
|
127
|
-
|
|
133
|
+
const data = { version: 1, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] }
|
|
134
|
+
if (raw === null || typeof raw !== 'object') return { data, dropped: 0 }
|
|
135
|
+
if (typeof raw.enabled === 'boolean') data.enabled = raw.enabled
|
|
136
|
+
data.budgetChars = clampBudget(raw.budgetChars)
|
|
128
137
|
const list = Array.isArray(raw.entries) ? raw.entries : []
|
|
129
138
|
const seen = new Set()
|
|
139
|
+
let dropped = 0
|
|
130
140
|
for (const candidate of list) {
|
|
131
|
-
if (out.entries.length >= MAX_ENTRIES) break
|
|
132
141
|
const entry = sanitizeEntry(candidate)
|
|
133
|
-
if (entry === null)
|
|
142
|
+
if (entry === null) {
|
|
143
|
+
dropped += 1
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
if (data.entries.length >= MAX_ENTRIES) {
|
|
147
|
+
dropped += 1
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
134
150
|
if (seen.has(entry.id)) entry.id = makeId()
|
|
135
151
|
seen.add(entry.id)
|
|
136
|
-
|
|
152
|
+
data.entries.push(entry)
|
|
137
153
|
}
|
|
138
|
-
return
|
|
154
|
+
return { data, dropped }
|
|
139
155
|
}
|
|
140
156
|
|
|
157
|
+
const droppedEntriesMessage = (count) =>
|
|
158
|
+
count > 0
|
|
159
|
+
? '存储文件中有 ' + count + ' 条条目未能载入(内容为空或超过 ' + MAX_ENTRIES + ' 条上限);下次保存时它们将从文件中移除'
|
|
160
|
+
: null
|
|
161
|
+
|
|
141
162
|
function formatEntry(entry, tag) {
|
|
142
163
|
const lines = ['[' + tag + '|优先级 ' + priorityLabel(entry.priority) + '] ' + entry.title]
|
|
143
164
|
if (entry.mode === 'auto' && entry.when !== '') lines.push('适用场景:' + entry.when)
|
|
@@ -212,7 +233,13 @@ export function buildBlock(data) {
|
|
|
212
233
|
const room = budget - used - 2 - reserve
|
|
213
234
|
const note = '\n…(本条因注入字数上限被截断)'
|
|
214
235
|
if (room > note.length + 20) {
|
|
215
|
-
|
|
236
|
+
// Cut in UTF-16 units but back off one char when that lands between a
|
|
237
|
+
// surrogate pair, so the truncation point can never end on the broken
|
|
238
|
+
// half of an emoji or CJK ext-B character.
|
|
239
|
+
let head = block.slice(0, room - note.length)
|
|
240
|
+
const last = head.charCodeAt(head.length - 1)
|
|
241
|
+
if (last >= 0xd800 && last <= 0xdbff) head = head.slice(0, -1)
|
|
242
|
+
const body = head + note
|
|
216
243
|
blocks.push(body)
|
|
217
244
|
used += body.length + 2
|
|
218
245
|
return
|
|
@@ -241,6 +268,7 @@ export function apply(ctx) {
|
|
|
241
268
|
storagePath: null,
|
|
242
269
|
storageDisplay: null,
|
|
243
270
|
storageError: null,
|
|
271
|
+
storageWarning: null,
|
|
244
272
|
savedAt: null,
|
|
245
273
|
migratedFrom: null,
|
|
246
274
|
sectionRegistered: false,
|
|
@@ -318,9 +346,27 @@ export function apply(ctx) {
|
|
|
318
346
|
const serialize = () => JSON.stringify(state.data, null, 2) + '\n'
|
|
319
347
|
|
|
320
348
|
const writeToDisk = async () => {
|
|
349
|
+
// Atomic replace (write a sibling temp file, then rename) with a rolling
|
|
350
|
+
// one-version backup: a crash mid-write must never leave a truncated
|
|
351
|
+
// memory.json, and an edit the user regrets must still be recoverable
|
|
352
|
+
// from memory.json.bak. The .bak holds the previous good version.
|
|
353
|
+
const tmpPath = storePath + '.tmp'
|
|
354
|
+
const bakPath = storePath + '.bak'
|
|
321
355
|
try {
|
|
322
356
|
await mkdir(dirname(storePath), { recursive: true })
|
|
323
|
-
await writeFile(
|
|
357
|
+
await writeFile(tmpPath, serialize(), 'utf8')
|
|
358
|
+
try {
|
|
359
|
+
await rename(storePath, bakPath)
|
|
360
|
+
} catch (err) {
|
|
361
|
+
// No previous version on the very first save; anything else is real.
|
|
362
|
+
if (err.code !== 'ENOENT') throw err
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
await rename(tmpPath, storePath)
|
|
366
|
+
} catch (err) {
|
|
367
|
+
await unlink(tmpPath).catch(() => {})
|
|
368
|
+
throw err
|
|
369
|
+
}
|
|
324
370
|
state.storagePath = storePath
|
|
325
371
|
state.storageDisplay = storePath
|
|
326
372
|
state.storageError = null
|
|
@@ -340,15 +386,40 @@ export function apply(ctx) {
|
|
|
340
386
|
const readFromDisk = async () => {
|
|
341
387
|
state.storagePath = storePath
|
|
342
388
|
state.storageDisplay = storePath
|
|
389
|
+
state.storageWarning = null
|
|
343
390
|
|
|
344
391
|
// 1. The canonical location under the harness home.
|
|
345
392
|
try {
|
|
346
393
|
const parsed = await readStoreFile(storePath)
|
|
347
|
-
if (parsed !== null)
|
|
394
|
+
if (parsed !== null) {
|
|
395
|
+
const loaded = normalizeData(parsed)
|
|
396
|
+
state.data = loaded.data
|
|
397
|
+
state.storageWarning = droppedEntriesMessage(loaded.dropped)
|
|
398
|
+
}
|
|
348
399
|
state.storageError = null
|
|
349
400
|
return
|
|
350
401
|
} catch (err) {
|
|
351
402
|
if (err === null || err === undefined || err.code !== 'ENOENT') {
|
|
403
|
+
// The main file is unreadable or corrupt. A previous good version
|
|
404
|
+
// may survive in the rolling backup — recover from it rather than
|
|
405
|
+
// presenting an empty memory, but say so loudly. The corrupt file
|
|
406
|
+
// itself stays untouched for hand repair; it gets overwritten on the
|
|
407
|
+
// next save (the pre-existing and disclosed behaviour).
|
|
408
|
+
if (err instanceof SyntaxError) {
|
|
409
|
+
try {
|
|
410
|
+
const backup = await readStoreFile(storePath + '.bak')
|
|
411
|
+
if (backup !== null) {
|
|
412
|
+
const loaded = normalizeData(backup)
|
|
413
|
+
state.data = loaded.data
|
|
414
|
+
state.storageWarning = (droppedEntriesMessage(loaded.dropped) || '')
|
|
415
|
+
+ (loaded.dropped > 0 ? ';' : '')
|
|
416
|
+
+ 'memory.json 解析失败(' + describeError(err) + '),已临时回退到最近备份 memory.json.bak;损坏文件已保留,下次保存时会覆盖它'
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
} catch {
|
|
420
|
+
// No usable backup either; fall through to the error below.
|
|
421
|
+
}
|
|
422
|
+
}
|
|
352
423
|
// A file we cannot understand must never be silently overwritten.
|
|
353
424
|
state.storageError = err instanceof SyntaxError
|
|
354
425
|
? '配置文件解析失败(' + describeError(err) + '),原文件已保留;你下次保存时会覆盖它'
|
|
@@ -363,7 +434,9 @@ export function apply(ctx) {
|
|
|
363
434
|
try {
|
|
364
435
|
const parsed = await readStoreFile(legacyPath)
|
|
365
436
|
if (parsed !== null) {
|
|
366
|
-
|
|
437
|
+
const loaded = normalizeData(parsed)
|
|
438
|
+
state.data = loaded.data
|
|
439
|
+
state.storageWarning = droppedEntriesMessage(loaded.dropped)
|
|
367
440
|
if (await writeToDisk()) {
|
|
368
441
|
state.migratedFrom = legacyPath
|
|
369
442
|
// Keep the original as evidence instead of deleting user data.
|
|
@@ -394,6 +467,21 @@ export function apply(ctx) {
|
|
|
394
467
|
syncSection()
|
|
395
468
|
})
|
|
396
469
|
|
|
470
|
+
/**
|
|
471
|
+
* All state-mutating work (edits and 重新载入) runs through this chain, so a
|
|
472
|
+
* save that is still flushing to disk can never interleave with a reload
|
|
473
|
+
* reading the file back — the old lost-update window in which the panel
|
|
474
|
+
* briefly resurrected data the user had just replaced. Each operation
|
|
475
|
+
* returns its full response payload, computed in the same tick as the
|
|
476
|
+
* mutation, so a snapshot can never straddle the next queued operation.
|
|
477
|
+
*/
|
|
478
|
+
let diskQueue = Promise.resolve()
|
|
479
|
+
const enqueue = (op) => {
|
|
480
|
+
const run = diskQueue.then(op, op)
|
|
481
|
+
diskQueue = run.then(() => undefined, () => undefined)
|
|
482
|
+
return run
|
|
483
|
+
}
|
|
484
|
+
|
|
397
485
|
/* ---------------- snapshots ---------------- */
|
|
398
486
|
|
|
399
487
|
const snapshot = () => {
|
|
@@ -408,6 +496,7 @@ export function apply(ctx) {
|
|
|
408
496
|
storage: {
|
|
409
497
|
path: state.storageDisplay !== null ? state.storageDisplay : state.storagePath,
|
|
410
498
|
error: state.storageError,
|
|
499
|
+
warning: state.storageWarning,
|
|
411
500
|
savedAt: state.savedAt,
|
|
412
501
|
migratedFrom: state.migratedFrom,
|
|
413
502
|
},
|
|
@@ -431,7 +520,7 @@ export function apply(ctx) {
|
|
|
431
520
|
|
|
432
521
|
/* ---------------- commands (shared by route and callers) ---------------- */
|
|
433
522
|
|
|
434
|
-
const saveEntry = async (
|
|
523
|
+
const saveEntry = (raw) => enqueue(async () => {
|
|
435
524
|
const entry = sanitizeEntry(raw)
|
|
436
525
|
if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
|
|
437
526
|
entry.updatedAt = Date.now()
|
|
@@ -445,16 +534,23 @@ export function apply(ctx) {
|
|
|
445
534
|
list.push(entry)
|
|
446
535
|
}
|
|
447
536
|
syncSection()
|
|
448
|
-
|
|
449
|
-
|
|
537
|
+
const saved = await writeToDisk()
|
|
538
|
+
// A successful save has rewritten the file to match memory, so a
|
|
539
|
+
// dropped-entries warning from load time ("they will be removed on the
|
|
540
|
+
// next save") has played out and must not keep showing.
|
|
541
|
+
if (saved) state.storageWarning = null
|
|
542
|
+
return settle(null, saved)
|
|
543
|
+
})
|
|
450
544
|
|
|
451
|
-
const deleteEntry = async (
|
|
545
|
+
const deleteEntry = (id) => enqueue(async () => {
|
|
452
546
|
state.data.entries = state.data.entries.filter((entry) => entry.id !== id)
|
|
453
547
|
syncSection()
|
|
454
|
-
|
|
455
|
-
|
|
548
|
+
const saved = await writeToDisk()
|
|
549
|
+
if (saved) state.storageWarning = null
|
|
550
|
+
return settle(null, saved)
|
|
551
|
+
})
|
|
456
552
|
|
|
457
|
-
const setOptions = async (
|
|
553
|
+
const setOptions = (patch) => enqueue(async () => {
|
|
458
554
|
if (patch !== null && typeof patch === 'object') {
|
|
459
555
|
if (typeof patch.enabled === 'boolean') state.data.enabled = patch.enabled
|
|
460
556
|
if (typeof patch.budgetChars === 'number' && Number.isFinite(patch.budgetChars)) {
|
|
@@ -462,8 +558,73 @@ export function apply(ctx) {
|
|
|
462
558
|
}
|
|
463
559
|
}
|
|
464
560
|
syncSection()
|
|
465
|
-
|
|
466
|
-
|
|
561
|
+
const saved = await writeToDisk()
|
|
562
|
+
if (saved) state.storageWarning = null
|
|
563
|
+
return settle(null, saved)
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
const reload = () => enqueue(async () => {
|
|
567
|
+
await readFromDisk()
|
|
568
|
+
syncSection()
|
|
569
|
+
return { ok: state.storageError === null, saved: true, message: state.storageError, snapshot: snapshot() }
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
const exportData = () => enqueue(async () => ({
|
|
573
|
+
ok: true,
|
|
574
|
+
saved: true,
|
|
575
|
+
message: null,
|
|
576
|
+
// A self-describing envelope: re-importable by this plugin, and readable
|
|
577
|
+
// as plain JSON by anything else.
|
|
578
|
+
export: { app: 'dsh-instruction-memory', version: 1, exportedAt: new Date().toISOString(), data: snapshot().data },
|
|
579
|
+
snapshot: snapshot(),
|
|
580
|
+
}))
|
|
581
|
+
|
|
582
|
+
const importData = (payload) => enqueue(async () => {
|
|
583
|
+
if (payload === null || typeof payload !== 'object') {
|
|
584
|
+
return { ok: false, saved: false, message: '导入内容不是有效的 JSON 对象', snapshot: snapshot() }
|
|
585
|
+
}
|
|
586
|
+
// Accept both the plugin's own export envelope ({app, data:{entries}}) and
|
|
587
|
+
// a bare {entries} array (e.g. a hand-assembled file).
|
|
588
|
+
const rawEntries = Array.isArray(payload.entries)
|
|
589
|
+
? payload.entries
|
|
590
|
+
: (payload.data !== null && typeof payload.data === 'object' && Array.isArray(payload.data.entries)
|
|
591
|
+
? payload.data.entries
|
|
592
|
+
: null)
|
|
593
|
+
if (rawEntries === null || rawEntries.length === 0) {
|
|
594
|
+
return { ok: false, saved: false, message: '导入文件中没有找到条目', snapshot: snapshot() }
|
|
595
|
+
}
|
|
596
|
+
// Merge, never replace: importing must not be able to wipe what is here.
|
|
597
|
+
// Duplicates (same id, or same title+content) and invalid items are
|
|
598
|
+
// skipped and counted, not silently dropped.
|
|
599
|
+
const existing = state.data.entries
|
|
600
|
+
const seenIds = new Set(existing.map((entry) => entry.id))
|
|
601
|
+
const seenBodies = new Set(existing.map((entry) => entry.title + '\u0000' + entry.content))
|
|
602
|
+
let imported = 0
|
|
603
|
+
let skipped = 0
|
|
604
|
+
for (const candidate of rawEntries) {
|
|
605
|
+
const entry = sanitizeEntry(candidate)
|
|
606
|
+
if (entry === null || seenIds.has(entry.id) || seenBodies.has(entry.title + '\u0000' + entry.content)) {
|
|
607
|
+
skipped += 1
|
|
608
|
+
continue
|
|
609
|
+
}
|
|
610
|
+
if (existing.length >= MAX_ENTRIES) {
|
|
611
|
+
skipped += 1
|
|
612
|
+
continue
|
|
613
|
+
}
|
|
614
|
+
entry.updatedAt = Date.now()
|
|
615
|
+
existing.push(entry)
|
|
616
|
+
seenIds.add(entry.id)
|
|
617
|
+
seenBodies.add(entry.title + '\u0000' + entry.content)
|
|
618
|
+
imported += 1
|
|
619
|
+
}
|
|
620
|
+
if (imported === 0) {
|
|
621
|
+
return { ok: true, saved: true, message: '没有新增条目:' + skipped + ' 条已存在或无效', snapshot: snapshot() }
|
|
622
|
+
}
|
|
623
|
+
syncSection()
|
|
624
|
+
const saved = await writeToDisk()
|
|
625
|
+
if (saved) state.storageWarning = null
|
|
626
|
+
return settle('导入完成:新增 ' + imported + ' 条,跳过 ' + skipped + ' 条(已存在或无效)', saved)
|
|
627
|
+
})
|
|
467
628
|
|
|
468
629
|
const dispatch = async (method, args) => {
|
|
469
630
|
await boot
|
|
@@ -479,9 +640,11 @@ export function apply(ctx) {
|
|
|
479
640
|
case 'set-options':
|
|
480
641
|
return setOptions(args)
|
|
481
642
|
case 'reload':
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
return
|
|
643
|
+
return reload()
|
|
644
|
+
case 'export-data':
|
|
645
|
+
return exportData()
|
|
646
|
+
case 'import-data':
|
|
647
|
+
return importData(args !== null && typeof args === 'object' ? args.payload : null)
|
|
485
648
|
default:
|
|
486
649
|
return { ok: false, saved: false, message: '未知方法:' + String(method), snapshot: snapshot() }
|
|
487
650
|
}
|
|
@@ -497,14 +660,45 @@ export function apply(ctx) {
|
|
|
497
660
|
res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
|
|
498
661
|
return
|
|
499
662
|
}
|
|
663
|
+
// The settings page always posts application/json. Enforcing it turns
|
|
664
|
+
// the anti-CSRF coincidence (a plain HTML form cannot fabricate a JSON
|
|
665
|
+
// body) into a hard rule: a cross-site or malformed post now gets a
|
|
666
|
+
// readable 415 instead of a confusing parse failure.
|
|
667
|
+
const contentType = String(
|
|
668
|
+
(req.headers !== null && typeof req.headers === 'object') ? (req.headers['content-type'] || '') : '',
|
|
669
|
+
)
|
|
670
|
+
if (contentType.indexOf('application/json') === -1) {
|
|
671
|
+
res.writeHead(415, { 'content-type': 'application/json; charset=utf-8' })
|
|
672
|
+
res.end(JSON.stringify({ ok: false, message: 'content-type 必须是 application/json' }))
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
// A call aborted mid-flight (settings page closed, tab navigated away)
|
|
676
|
+
// makes these streams emit 'error'. A server stream with no error
|
|
677
|
+
// listener turns that into an uncaught exception — i.e. it would take
|
|
678
|
+
// the whole host process down over one dead HTTP request.
|
|
679
|
+
req.on('error', () => {})
|
|
680
|
+
res.on('error', () => {})
|
|
500
681
|
let body = ''
|
|
682
|
+
// Over the ceiling we stop accumulating but keep draining, so the
|
|
683
|
+
// response can go out as a clean 413 instead of a destroyed socket the
|
|
684
|
+
// Client can only report as a generic network failure.
|
|
685
|
+
let overflow = false
|
|
501
686
|
req.setEncoding('utf8')
|
|
502
687
|
req.on('data', (chunk) => {
|
|
688
|
+
if (overflow) return
|
|
503
689
|
body += chunk
|
|
504
|
-
if (body.length >
|
|
690
|
+
if (body.length > MAX_BODY_CHARS) {
|
|
691
|
+
overflow = true
|
|
692
|
+
body = ''
|
|
693
|
+
}
|
|
505
694
|
})
|
|
506
695
|
req.on('end', () => {
|
|
507
696
|
void (async () => {
|
|
697
|
+
if (overflow) {
|
|
698
|
+
res.writeHead(413, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
699
|
+
res.end(JSON.stringify({ ok: false, message: '请求体超过 ' + MAX_BODY_CHARS + ' 字符上限' }))
|
|
700
|
+
return
|
|
701
|
+
}
|
|
508
702
|
let result
|
|
509
703
|
try {
|
|
510
704
|
const payload = body === '' ? {} : JSON.parse(body)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-instruction-memory",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "DSH
|
|
3
|
+
"version": "1.0.6",
|
|
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",
|
|
7
7
|
"repository": {
|
|
@@ -33,10 +33,15 @@
|
|
|
33
33
|
"dsh",
|
|
34
34
|
"dsh-plugin",
|
|
35
35
|
"deepseek-harness",
|
|
36
|
-
"cordis",
|
|
37
36
|
"instruction-memory",
|
|
37
|
+
"memory",
|
|
38
|
+
"记忆",
|
|
39
|
+
"instructions",
|
|
40
|
+
"custom-instructions",
|
|
41
|
+
"persistent-instructions",
|
|
38
42
|
"system-prompt",
|
|
39
|
-
"settings"
|
|
43
|
+
"settings",
|
|
44
|
+
"cordis"
|
|
40
45
|
],
|
|
41
46
|
"engines": {
|
|
42
47
|
"node": ">=20"
|