dsh-instruction-memory 1.0.5 → 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
 
@@ -58,8 +63,13 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。
58
63
  > 首次启动新版本时会自动迁移到上述位置,并把原文件改名为 `*.migrated` 保留。
59
64
 
60
65
  该文件可手工编辑,改完点设置页的「重新载入」即可读回。每次保存先写临时文件再原子替换,
61
- 进程崩溃不会留下损坏的半截 `memory.json`。若手工编辑后的文件有条目为空或超过 200 条上限,
62
- 设置页会明确提示哪些条目未能载入(它们将在下次保存时从文件中移除),不会静默丢弃。
66
+ 进程崩溃不会留下损坏的半截 `memory.json`;保存时上一版会保留为 `memory.json.bak`,
67
+ 若 `memory.json` 解析失败会自动临时回退到该备份并明确提示。若手工编辑后的文件有条目为空
68
+ 或超过 200 条上限,设置页会明确提示哪些条目未能载入(它们将在下次保存时从文件中移除),
69
+ 不会静默丢弃。
70
+
71
+ 设置页支持**导出 / 导入**:导出为自带说明的 JSON 文件;导入采用**合并语义**——按 id 与
72
+ 「标题+内容」去重,只新增、绝不覆盖或清空现有条目。
63
73
 
64
74
  ## 兼容性
65
75
 
package/lib/client.js CHANGED
@@ -71,6 +71,9 @@ window.__ModuleLoader__.load({
71
71
  .im-dot-on { background:var(--dsw-alias-state-success-primary,#18a058); }
72
72
  .im-dot-off { background:var(--dsw-alias-state-warn-primary,#d97706); }
73
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; }
74
77
  @media (max-width:640px) { .im-grid2 { grid-template-columns:1fr; } }
75
78
  `
76
79
 
@@ -193,6 +196,24 @@ window.__ModuleLoader__.load({
193
196
  return h('div', { className: 'im-card' }, children)
194
197
  }
195
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
+
196
217
  function InstructionPanel() {
197
218
  const [view, setView] = React.useState(null)
198
219
  const [error, setError] = React.useState('')
@@ -201,6 +222,8 @@ window.__ModuleLoader__.load({
201
222
  const [draft, setDraft] = React.useState(null)
202
223
  const [budgetText, setBudgetText] = React.useState('')
203
224
  const [showPreview, setShowPreview] = React.useState(false)
225
+ const [query, setQuery] = React.useState('')
226
+ const importInputRef = React.useRef(null)
204
227
 
205
228
  function absorb(result) {
206
229
  const normalized = normalizeResult(result)
@@ -235,6 +258,50 @@ window.__ModuleLoader__.load({
235
258
  }
236
259
  }
237
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
+
238
305
  React.useEffect(() => {
239
306
  let alive = true
240
307
  callHost('state', null).then(
@@ -276,6 +343,13 @@ window.__ModuleLoader__.load({
276
343
  const injection = view.injection || { registered: false, chars: 0, error: null }
277
344
  const entries = data.entries.slice().sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
278
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)
279
353
 
280
354
  const dotClass = injection.error
281
355
  ? 'im-dot im-dot-err'
@@ -328,6 +402,14 @@ window.__ModuleLoader__.load({
328
402
  h('label', { className: 'im-label' }, '生效规则'),
329
403
  h('div', { className: 'im-meta' },
330
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,
331
413
  h('div', { className: 'im-row-wrap' },
332
414
  h('button', {
333
415
  className: 'im-btn im-btn-primary',
@@ -335,6 +417,15 @@ window.__ModuleLoader__.load({
335
417
  onClick: () => { setDraft(blankDraft()); setError('') },
336
418
  }, '+ 新增指令'),
337
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
+ }, '导入'),
338
429
  h('button', {
339
430
  className: 'im-btn',
340
431
  disabled: busy,
@@ -355,9 +446,21 @@ window.__ModuleLoader__.load({
355
446
  error === '' && notice === '' && storage.error ? h('div', { className: 'im-err' }, storage.error) : null,
356
447
  showPreview ? h('div', null,
357
448
  h('label', { className: 'im-label' }, '当前注入到每轮系统提示词的内容'),
358
- 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
+ }))
359
462
 
360
- const items = entries.map((entry) => {
463
+ const items = visible.map((entry) => {
361
464
  const tags = [
362
465
  h('span', {
363
466
  className: 'im-tag ' + (entry.mode === 'always' ? 'im-tag-always' : 'im-tag-auto'),
@@ -402,7 +505,7 @@ window.__ModuleLoader__.load({
402
505
  send('delete-entry', { id: entry.id })
403
506
  },
404
507
  }, '删除')),
405
- h('div', { className: 'im-body' }, entry.content),
508
+ h(EntryBody, { text: entry.content, key: 'body' }),
406
509
  entry.mode === 'auto' && entry.when !== ''
407
510
  ? h('div', { className: 'im-meta' }, '适用场景:' + entry.when)
408
511
  : null)
@@ -411,7 +514,9 @@ window.__ModuleLoader__.load({
411
514
  const list = entries.length === 0
412
515
  ? h('div', { className: 'im-empty', key: 'empty' },
413
516
  '还没有指令记忆。点击「+ 新增指令」添加第一条,例如“所有回答使用简体中文”。')
414
- : 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)
415
520
 
416
521
  return h('div', { className: 'im-root' }, header, list)
417
522
  }
package/lib/index.js CHANGED
@@ -346,14 +346,21 @@ export function apply(ctx) {
346
346
  const serialize = () => JSON.stringify(state.data, null, 2) + '\n'
347
347
 
348
348
  const writeToDisk = async () => {
349
- // Atomic replace (write a sibling temp file, then rename): a crash
350
- // mid-write must never leave a truncated memory.json behind, because the
351
- // reader refuses to touch a file it cannot parse — half-written JSON
352
- // would wedge every future boot until hand-repaired.
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
353
  const tmpPath = storePath + '.tmp'
354
+ const bakPath = storePath + '.bak'
354
355
  try {
355
356
  await mkdir(dirname(storePath), { recursive: true })
356
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
+ }
357
364
  try {
358
365
  await rename(tmpPath, storePath)
359
366
  } catch (err) {
@@ -393,6 +400,26 @@ export function apply(ctx) {
393
400
  return
394
401
  } catch (err) {
395
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
+ }
396
423
  // A file we cannot understand must never be silently overwritten.
397
424
  state.storageError = err instanceof SyntaxError
398
425
  ? '配置文件解析失败(' + describeError(err) + '),原文件已保留;你下次保存时会覆盖它'
@@ -542,6 +569,63 @@ export function apply(ctx) {
542
569
  return { ok: state.storageError === null, saved: true, message: state.storageError, snapshot: snapshot() }
543
570
  })
544
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
+ })
628
+
545
629
  const dispatch = async (method, args) => {
546
630
  await boot
547
631
  switch (method) {
@@ -557,6 +641,10 @@ export function apply(ctx) {
557
641
  return setOptions(args)
558
642
  case 'reload':
559
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)
560
648
  default:
561
649
  return { ok: false, saved: false, message: '未知方法:' + String(method), snapshot: snapshot() }
562
650
  }
@@ -572,6 +660,18 @@ export function apply(ctx) {
572
660
  res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
573
661
  return
574
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
+ }
575
675
  // A call aborted mid-flight (settings page closed, tab navigated away)
576
676
  // makes these streams emit 'error'. A server stream with no error
577
677
  // listener turns that into an uncaught exception — i.e. it would take
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.0.5",
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"