dsh-instruction-memory 1.0.4 → 1.0.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/README.md CHANGED
@@ -57,7 +57,9 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。
57
57
  > 1.0.0 之前的构建曾把数据写在宿主进程 cwd 下(`.dsh-instruction-memory.json`)。
58
58
  > 首次启动新版本时会自动迁移到上述位置,并把原文件改名为 `*.migrated` 保留。
59
59
 
60
- 该文件可手工编辑,改完点设置页的「重新载入」即可读回。
60
+ 该文件可手工编辑,改完点设置页的「重新载入」即可读回。每次保存先写临时文件再原子替换,
61
+ 进程崩溃不会留下损坏的半截 `memory.json`。若手工编辑后的文件有条目为空或超过 200 条上限,
62
+ 设置页会明确提示哪些条目未能载入(它们将在下次保存时从文件中移除),不会静默丢弃。
61
63
 
62
64
  ## 兼容性
63
65
 
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);
@@ -107,13 +108,25 @@ window.__ModuleLoader__.load({
107
108
  }
108
109
 
109
110
  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()
111
+ // A hung host used to pin the panel on 保存中… forever; fail visibly
112
+ // after a generous timeout instead.
113
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
114
+ const timer = controller !== null ? setTimeout(() => controller.abort(), 15000) : null
115
+ try {
116
+ const response = await fetch(API, {
117
+ method: 'POST',
118
+ headers: { 'content-type': 'application/json' },
119
+ body: JSON.stringify({ method, args: args === undefined ? null : args }),
120
+ signal: controller !== null ? controller.signal : undefined,
121
+ })
122
+ if (!response.ok) throw new Error('宿主返回 HTTP ' + response.status)
123
+ return await response.json()
124
+ } catch (err) {
125
+ if (controller !== null && controller.signal.aborted) throw new Error('请求宿主超时(15 秒无响应)')
126
+ throw err
127
+ } finally {
128
+ if (timer !== null) clearTimeout(timer)
129
+ }
117
130
  }
118
131
 
119
132
  function EntryEditor(props) {
@@ -126,6 +139,7 @@ window.__ModuleLoader__.load({
126
139
  h('input', {
127
140
  className: 'im-input',
128
141
  value: draft.title,
142
+ maxLength: 120,
129
143
  placeholder: '例如:回答语言',
130
144
  onChange: (ev) => set({ title: ev.target.value }),
131
145
  })),
@@ -134,6 +148,7 @@ window.__ModuleLoader__.load({
134
148
  h('textarea', {
135
149
  className: 'im-textarea',
136
150
  value: draft.content,
151
+ maxLength: 6000,
137
152
  placeholder: '例如:所有回答使用简体中文;不确定需求时先提问再动手。',
138
153
  onChange: (ev) => set({ content: ev.target.value }),
139
154
  })),
@@ -164,6 +179,7 @@ window.__ModuleLoader__.load({
164
179
  h('input', {
165
180
  className: 'im-input',
166
181
  value: draft.when,
182
+ maxLength: 200,
167
183
  placeholder: '例如:编写或修改代码时',
168
184
  onChange: (ev) => set({ when: ev.target.value }),
169
185
  })))
@@ -253,7 +269,10 @@ window.__ModuleLoader__.load({
253
269
  }
254
270
 
255
271
  const data = view.data
256
- const storage = view.storage
272
+ // Older hosts can hand over snapshots without storage/limits; fall back
273
+ // instead of throwing mid-render.
274
+ const storage = view.storage || {}
275
+ const limits = view.limits || { minBudget: 800, maxBudget: 40000, maxEntries: 200 }
257
276
  const injection = view.injection || { registered: false, chars: 0, error: null }
258
277
  const entries = data.entries.slice().sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
259
278
  const activeCount = entries.filter((e) => e.enabled && e.content !== '').length
@@ -289,17 +308,17 @@ window.__ModuleLoader__.load({
289
308
  h('input', {
290
309
  className: 'im-input',
291
310
  type: 'number',
292
- min: view.limits.minBudget,
293
- max: view.limits.maxBudget,
311
+ min: limits.minBudget,
312
+ max: limits.maxBudget,
294
313
  step: 100,
295
314
  value: budgetText,
296
315
  disabled: busy,
297
316
  onChange: (ev) => setBudgetText(ev.target.value),
298
317
  onBlur: () => {
299
318
  const parsed = Number(budgetText)
300
- if (!Number.isFinite(parsed) || parsed < view.limits.minBudget || parsed > view.limits.maxBudget) {
319
+ if (!Number.isFinite(parsed) || parsed < limits.minBudget || parsed > limits.maxBudget) {
301
320
  setBudgetText(String(data.budgetChars))
302
- setError('上限需在 ' + view.limits.minBudget + ' ~ ' + view.limits.maxBudget + ' 之间')
321
+ setError('上限需在 ' + limits.minBudget + ' ~ ' + limits.maxBudget + ' 之间')
303
322
  return
304
323
  }
305
324
  send('set-options', { budgetChars: parsed })
@@ -330,6 +349,7 @@ window.__ModuleLoader__.load({
330
349
  storage.migratedFrom
331
350
  ? h('div', { className: 'im-ok' }, '已从旧位置迁移:' + storage.migratedFrom + '(原文件保留为 *.migrated)')
332
351
  : null,
352
+ storage.warning ? h('div', { className: 'im-warn' }, storage.warning) : null,
333
353
  error !== '' ? h('div', { className: 'im-err' }, error) : null,
334
354
  error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
335
355
  error === '' && notice === '' && storage.error ? h('div', { className: 'im-err' }, storage.error) : null,
@@ -374,7 +394,13 @@ window.__ModuleLoader__.load({
374
394
  h('button', {
375
395
  className: 'im-btn im-btn-danger',
376
396
  disabled: busy,
377
- onClick: () => send('delete-entry', { id: entry.id }),
397
+ onClick: () => {
398
+ // confirm() is standard DOM, guarded anyway: if the embedded
399
+ // shell omits it, deletion proceeds rather than breaking.
400
+ if (typeof window.confirm === 'function'
401
+ && !window.confirm('确定删除「' + (entry.title !== '' ? entry.title : '未命名指令') + '」?删除后无法恢复。')) return
402
+ send('delete-entry', { id: entry.id })
403
+ },
378
404
  }, '删除')),
379
405
  h('div', { className: 'im-body' }, entry.content),
380
406
  entry.mode === 'auto' && entry.when !== ''
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,20 @@ 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): 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.
353
+ const tmpPath = storePath + '.tmp'
321
354
  try {
322
355
  await mkdir(dirname(storePath), { recursive: true })
323
- await writeFile(storePath, serialize(), 'utf8')
356
+ await writeFile(tmpPath, serialize(), 'utf8')
357
+ try {
358
+ await rename(tmpPath, storePath)
359
+ } catch (err) {
360
+ await unlink(tmpPath).catch(() => {})
361
+ throw err
362
+ }
324
363
  state.storagePath = storePath
325
364
  state.storageDisplay = storePath
326
365
  state.storageError = null
@@ -340,11 +379,16 @@ export function apply(ctx) {
340
379
  const readFromDisk = async () => {
341
380
  state.storagePath = storePath
342
381
  state.storageDisplay = storePath
382
+ state.storageWarning = null
343
383
 
344
384
  // 1. The canonical location under the harness home.
345
385
  try {
346
386
  const parsed = await readStoreFile(storePath)
347
- if (parsed !== null) state.data = normalizeData(parsed)
387
+ if (parsed !== null) {
388
+ const loaded = normalizeData(parsed)
389
+ state.data = loaded.data
390
+ state.storageWarning = droppedEntriesMessage(loaded.dropped)
391
+ }
348
392
  state.storageError = null
349
393
  return
350
394
  } catch (err) {
@@ -363,7 +407,9 @@ export function apply(ctx) {
363
407
  try {
364
408
  const parsed = await readStoreFile(legacyPath)
365
409
  if (parsed !== null) {
366
- state.data = normalizeData(parsed)
410
+ const loaded = normalizeData(parsed)
411
+ state.data = loaded.data
412
+ state.storageWarning = droppedEntriesMessage(loaded.dropped)
367
413
  if (await writeToDisk()) {
368
414
  state.migratedFrom = legacyPath
369
415
  // Keep the original as evidence instead of deleting user data.
@@ -394,6 +440,21 @@ export function apply(ctx) {
394
440
  syncSection()
395
441
  })
396
442
 
443
+ /**
444
+ * All state-mutating work (edits and 重新载入) runs through this chain, so a
445
+ * save that is still flushing to disk can never interleave with a reload
446
+ * reading the file back — the old lost-update window in which the panel
447
+ * briefly resurrected data the user had just replaced. Each operation
448
+ * returns its full response payload, computed in the same tick as the
449
+ * mutation, so a snapshot can never straddle the next queued operation.
450
+ */
451
+ let diskQueue = Promise.resolve()
452
+ const enqueue = (op) => {
453
+ const run = diskQueue.then(op, op)
454
+ diskQueue = run.then(() => undefined, () => undefined)
455
+ return run
456
+ }
457
+
397
458
  /* ---------------- snapshots ---------------- */
398
459
 
399
460
  const snapshot = () => {
@@ -408,6 +469,7 @@ export function apply(ctx) {
408
469
  storage: {
409
470
  path: state.storageDisplay !== null ? state.storageDisplay : state.storagePath,
410
471
  error: state.storageError,
472
+ warning: state.storageWarning,
411
473
  savedAt: state.savedAt,
412
474
  migratedFrom: state.migratedFrom,
413
475
  },
@@ -431,7 +493,7 @@ export function apply(ctx) {
431
493
 
432
494
  /* ---------------- commands (shared by route and callers) ---------------- */
433
495
 
434
- const saveEntry = async (raw) => {
496
+ const saveEntry = (raw) => enqueue(async () => {
435
497
  const entry = sanitizeEntry(raw)
436
498
  if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
437
499
  entry.updatedAt = Date.now()
@@ -445,16 +507,23 @@ export function apply(ctx) {
445
507
  list.push(entry)
446
508
  }
447
509
  syncSection()
448
- return settle(null, await writeToDisk())
449
- }
510
+ const saved = await writeToDisk()
511
+ // A successful save has rewritten the file to match memory, so a
512
+ // dropped-entries warning from load time ("they will be removed on the
513
+ // next save") has played out and must not keep showing.
514
+ if (saved) state.storageWarning = null
515
+ return settle(null, saved)
516
+ })
450
517
 
451
- const deleteEntry = async (id) => {
518
+ const deleteEntry = (id) => enqueue(async () => {
452
519
  state.data.entries = state.data.entries.filter((entry) => entry.id !== id)
453
520
  syncSection()
454
- return settle(null, await writeToDisk())
455
- }
521
+ const saved = await writeToDisk()
522
+ if (saved) state.storageWarning = null
523
+ return settle(null, saved)
524
+ })
456
525
 
457
- const setOptions = async (patch) => {
526
+ const setOptions = (patch) => enqueue(async () => {
458
527
  if (patch !== null && typeof patch === 'object') {
459
528
  if (typeof patch.enabled === 'boolean') state.data.enabled = patch.enabled
460
529
  if (typeof patch.budgetChars === 'number' && Number.isFinite(patch.budgetChars)) {
@@ -462,8 +531,16 @@ export function apply(ctx) {
462
531
  }
463
532
  }
464
533
  syncSection()
465
- return settle(null, await writeToDisk())
466
- }
534
+ const saved = await writeToDisk()
535
+ if (saved) state.storageWarning = null
536
+ return settle(null, saved)
537
+ })
538
+
539
+ const reload = () => enqueue(async () => {
540
+ await readFromDisk()
541
+ syncSection()
542
+ return { ok: state.storageError === null, saved: true, message: state.storageError, snapshot: snapshot() }
543
+ })
467
544
 
468
545
  const dispatch = async (method, args) => {
469
546
  await boot
@@ -479,9 +556,7 @@ export function apply(ctx) {
479
556
  case 'set-options':
480
557
  return setOptions(args)
481
558
  case 'reload':
482
- await readFromDisk()
483
- syncSection()
484
- return { ok: state.storageError === null, saved: true, message: state.storageError, snapshot: snapshot() }
559
+ return reload()
485
560
  default:
486
561
  return { ok: false, saved: false, message: '未知方法:' + String(method), snapshot: snapshot() }
487
562
  }
@@ -497,14 +572,33 @@ export function apply(ctx) {
497
572
  res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
498
573
  return
499
574
  }
575
+ // A call aborted mid-flight (settings page closed, tab navigated away)
576
+ // makes these streams emit 'error'. A server stream with no error
577
+ // listener turns that into an uncaught exception — i.e. it would take
578
+ // the whole host process down over one dead HTTP request.
579
+ req.on('error', () => {})
580
+ res.on('error', () => {})
500
581
  let body = ''
582
+ // Over the ceiling we stop accumulating but keep draining, so the
583
+ // response can go out as a clean 413 instead of a destroyed socket the
584
+ // Client can only report as a generic network failure.
585
+ let overflow = false
501
586
  req.setEncoding('utf8')
502
587
  req.on('data', (chunk) => {
588
+ if (overflow) return
503
589
  body += chunk
504
- if (body.length > 1_000_000) req.destroy()
590
+ if (body.length > MAX_BODY_CHARS) {
591
+ overflow = true
592
+ body = ''
593
+ }
505
594
  })
506
595
  req.on('end', () => {
507
596
  void (async () => {
597
+ if (overflow) {
598
+ res.writeHead(413, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
599
+ res.end(JSON.stringify({ ok: false, message: '请求体超过 ' + MAX_BODY_CHARS + ' 字符上限' }))
600
+ return
601
+ }
508
602
  let result
509
603
  try {
510
604
  const payload = body === '' ? {} : JSON.parse(body)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "DSH 指令记忆:在设置界面中维护可增删改的长期指令,并自动注入此后每一轮对话的系统提示词。",
5
5
  "license": "MIT",
6
6
  "type": "module",