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 CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  在 DSH 设置界面中维护**长期指令记忆**:保存后自动注入此后每一轮对话的系统提示词。
4
4
 
5
+ ![设置页截图](docs/screenshot.png)
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
- const response = await fetch(API, {
111
- method: 'POST',
112
- headers: { 'content-type': 'application/json' },
113
- body: JSON.stringify({ method, args: args === undefined ? null : args }),
114
- })
115
- if (!response.ok) throw new Error('宿主返回 HTTP ' + response.status)
116
- return await response.json()
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
- const storage = view.storage
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: view.limits.minBudget,
293
- max: view.limits.maxBudget,
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 < view.limits.minBudget || parsed > view.limits.maxBudget) {
393
+ if (!Number.isFinite(parsed) || parsed < limits.minBudget || parsed > limits.maxBudget) {
301
394
  setBudgetText(String(data.budgetChars))
302
- setError('上限需在 ' + view.limits.minBudget + ' ~ ' + view.limits.maxBudget + ' 之间')
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 = entries.map((entry) => {
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: () => send('delete-entry', { id: entry.id }),
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('div', { className: 'im-body' }, entry.content),
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
- : h('div', { className: 'im-list', key: 'list' }, items)
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 out = { version: 1, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] }
125
- if (raw === null || typeof raw !== 'object') return out
126
- if (typeof raw.enabled === 'boolean') out.enabled = raw.enabled
127
- out.budgetChars = clampBudget(raw.budgetChars)
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) continue
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
- out.entries.push(entry)
152
+ data.entries.push(entry)
137
153
  }
138
- return out
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
- const body = block.slice(0, room - note.length) + note
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(storePath, serialize(), 'utf8')
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) state.data = normalizeData(parsed)
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
- state.data = normalizeData(parsed)
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 (raw) => {
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
- return settle(null, await writeToDisk())
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 (id) => {
545
+ const deleteEntry = (id) => enqueue(async () => {
452
546
  state.data.entries = state.data.entries.filter((entry) => entry.id !== id)
453
547
  syncSection()
454
- return settle(null, await writeToDisk())
455
- }
548
+ const saved = await writeToDisk()
549
+ if (saved) state.storageWarning = null
550
+ return settle(null, saved)
551
+ })
456
552
 
457
- const setOptions = async (patch) => {
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
- return settle(null, await writeToDisk())
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
- await readFromDisk()
483
- syncSection()
484
- return { ok: state.storageError === null, saved: true, message: state.storageError, snapshot: snapshot() }
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 > 1_000_000) req.destroy()
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",
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"