dsh-instruction-memory 1.1.0 → 1.2.0

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/lib/index.js CHANGED
@@ -8,14 +8,30 @@
8
8
  *
9
9
  * Lifecycle: every side effect (prompt section, web route) belongs to this
10
10
  * plugin's fiber, so unmounting the row removes all of them.
11
+ *
12
+ * Storage format v2 (F13 多情境配置集 / instruction sets): the file holds a
13
+ * named `sets` list plus one flat `entries`/`profiles` array where every item
14
+ * carries a `setId`; `activeSetId` names the one set that is injected. A v1
15
+ * file (no `sets` field) loads transparently — every item is folded into the
16
+ * single 默认 set — and is rewritten as v2 by the next save. Switching the
17
+ * active set is ALWAYS an explicit user command; nothing here ever derives a
18
+ * set from the process cwd (a real incident: a cwd-derived store location made
19
+ * memory appear to vanish when the user relaunched from another directory).
20
+ *
21
+ * Also in v2: timestamped history snapshots (F14) written into a `history/`
22
+ * subdirectory — deliberately NOT into the watched store directory entry that
23
+ * `fs.watch` listens on — a JSON Schema shipped for hand editing (F15), a raw
24
+ * source read/write pair for the settings page (F16), and an optional
25
+ * user-chosen backup directory (F18).
11
26
  */
12
27
 
13
- import { copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
28
+ import { copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
14
29
  import { watch } from 'node:fs'
15
30
  import { randomUUID } from 'node:crypto'
16
31
  import { isIP } from 'node:net'
17
- import { homedir } from 'node:os'
18
- import { basename, dirname, join } from 'node:path'
32
+ import { homedir, hostname } from 'node:os'
33
+ import { basename, dirname, isAbsolute, join } from 'node:path'
34
+ import { fileURLToPath } from 'node:url'
19
35
 
20
36
  export const name = 'instruction-memory'
21
37
 
@@ -48,6 +64,34 @@ const STORE_FILE_NAME = 'memory.json'
48
64
  /** Pre-release location, kept only so existing installs can migrate off it. */
49
65
  const LEGACY_FILE_NAME = '.dsh-instruction-memory.json'
50
66
  const SECTION_NAME = 'instruction-memory'
67
+ /**
68
+ * The store format this build writes.
69
+ *
70
+ * v2 introduced named instruction sets (F13). The bump is not cosmetic: an
71
+ * OLDER build of this plugin reads `version` and refuses anything that is not
72
+ * 1, so a downgrade meets an explicit "unsupported version" refusal with
73
+ * mutations paused instead of silently loading a v2 file as a v1 one (which
74
+ * would hide every set name and, on the next save, rewrite the file without
75
+ * them). v1 files keep loading here — every entry is folded into the single
76
+ * 默认 set — and the next save writes v2.
77
+ */
78
+ const FORMAT_VERSION = 2
79
+ const SUPPORTED_VERSIONS = [1, 2]
80
+ /** The id/name of the set a fresh or migrated store gets. Stable on purpose. */
81
+ const DEFAULT_SET_ID = 'st-default'
82
+ const DEFAULT_SET_NAME = '默认'
83
+ const MAX_SETS = 20
84
+ const MAX_SET_NAME = 30
85
+ /** Rolling history snapshots (F14): how many are kept PER writing device. */
86
+ const MAX_HISTORY = 20
87
+ const HISTORY_DIR_NAME = 'history'
88
+ /** Name of the JSON Schema shipped with the package and copied next to the store. */
89
+ const SCHEMA_FILE_NAME = 'memory.schema.json'
90
+ /** The `$schema` value written into every store file (resolves next to it). */
91
+ const SCHEMA_REF = './' + SCHEMA_FILE_NAME
92
+ /** Upper bound for a user-chosen backup directory path. */
93
+ const MAX_BACKUP_DIR = 512
94
+ const BACKUP_FILE_SUFFIX = '.bak'
51
95
  /**
52
96
  * Placement of the injected instruction block among the system prompt's
53
97
  * ordered sections. Sections are concatenated ascending; equal orders fall
@@ -128,7 +172,33 @@ const BATCH_OPS = ['set-enabled', 'set-priority', 'add-tags', 'remove-tags', 'de
128
172
  * wrapper.
129
173
  */
130
174
  export const MAX_BODY_CHARS = (MAX_ENTRIES * (MAX_CONTENT + MAX_TITLE + MAX_WHEN + MAX_TAGS_PER_ENTRY * MAX_TAG_LENGTH + 200)
131
- + MAX_PROFILES * (MAX_PROFILE_CONTENT + MAX_PROFILE_NAME + 200)) * 2 + 100_000
175
+ + MAX_PROFILES * (MAX_PROFILE_CONTENT + MAX_PROFILE_NAME + 200)
176
+ + MAX_SETS * (MAX_SET_NAME + 200)) * 2 + 100_000
177
+
178
+ /**
179
+ * Every numeric ceiling in one object, published on the snapshot and used by
180
+ * verify.mjs to pin `memory.schema.json` (the hand-edit JSON Schema) to the
181
+ * code. Two sources of truth that must never drift: a schema advertising a
182
+ * larger limit than the loader enforces would let a hand edit be silently
183
+ * truncated while the IDE reported it as valid.
184
+ */
185
+ export const LIMITS = Object.freeze({
186
+ formatVersion: FORMAT_VERSION,
187
+ maxEntries: MAX_ENTRIES,
188
+ maxTitle: MAX_TITLE,
189
+ maxContent: MAX_CONTENT,
190
+ maxWhen: MAX_WHEN,
191
+ maxTagsPerEntry: MAX_TAGS_PER_ENTRY,
192
+ maxTagLength: MAX_TAG_LENGTH,
193
+ maxProfiles: MAX_PROFILES,
194
+ maxProfileName: MAX_PROFILE_NAME,
195
+ maxProfileContent: MAX_PROFILE_CONTENT,
196
+ maxSets: MAX_SETS,
197
+ maxSetName: MAX_SET_NAME,
198
+ minBudget: MIN_BUDGET,
199
+ maxBudget: MAX_BUDGET,
200
+ maxHistory: MAX_HISTORY,
201
+ })
132
202
 
133
203
  /** How long a crashed holder's `memory.json.lock` may block writes before it is stolen. */
134
204
  const LOCK_STALE_MS = 10_000
@@ -174,6 +244,10 @@ function makeId() {
174
244
  return 'im-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8)
175
245
  }
176
246
 
247
+ function makeSetId() {
248
+ return 'st-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8)
249
+ }
250
+
177
251
  function priorityLabel(value) {
178
252
  if (value === 2) return '高'
179
253
  if (value === 0) return '低'
@@ -222,10 +296,40 @@ export function sanitizeEntry(raw) {
222
296
  priority: raw.priority === 2 || raw.priority === 0 ? raw.priority : 1,
223
297
  enabled: raw.enabled !== false,
224
298
  tags: sanitizeTags(raw.tags),
299
+ /**
300
+ * Which instruction set (F13) this entry belongs to. Preserved here — NOT
301
+ * re-derived — because both the loader and the importer need to honour a
302
+ * declared membership; the interactive save path overwrites it with the
303
+ * active set instead (an edit can never move an entry between sets).
304
+ * `normalizeData` guarantees a valid value on everything this plugin
305
+ * serves, so a blank/absent id on a hand-built object means "unattributed"
306
+ * (see inScope), not "no set".
307
+ */
308
+ setId: typeof raw.setId === 'string' && raw.setId !== '' ? raw.setId.slice(0, 64) : '',
225
309
  updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
226
310
  }
227
311
  }
228
312
 
313
+ /**
314
+ * Normalise one instruction set (F13).
315
+ *
316
+ * Shape: { id, name }. A set carries no content of its own — entries and
317
+ * user profiles point AT it through `setId` — so "switching the active set"
318
+ * never moves a byte of user text, which is what makes the operation safe to
319
+ * perform in one transaction. `id` is stable (it is what every item stores);
320
+ * `name` is a short single-line label. A blank name is rejected (null): unlike
321
+ * an entry, a set has nothing else to derive a label from.
322
+ */
323
+ export function sanitizeSet(raw) {
324
+ if (raw === null || typeof raw !== 'object') return null
325
+ const name = singleLine(raw.name, MAX_SET_NAME)
326
+ if (name === '') return null
327
+ return {
328
+ id: typeof raw.id === 'string' && raw.id !== '' ? raw.id.slice(0, 64) : makeSetId(),
329
+ name,
330
+ }
331
+ }
332
+
229
333
  /**
230
334
  * Normalise one "用户形象" (a fact about the USER, not a role for the model).
231
335
  *
@@ -246,10 +350,124 @@ export function sanitizeProfile(raw) {
246
350
  name: name === '' ? singleLine(content, 20) : name,
247
351
  content,
248
352
  enabled: raw.enabled !== false,
353
+ /** Instruction set membership (F13); see the note in sanitizeEntry. */
354
+ setId: typeof raw.setId === 'string' && raw.setId !== '' ? raw.setId.slice(0, 64) : '',
249
355
  updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
250
356
  }
251
357
  }
252
358
 
359
+ /**
360
+ * The scope decision behind everything that renders or lists items: with a
361
+ * usable `sets` list plus a matching `activeSetId`, only the active set is in
362
+ * scope; without them the store is the legacy single-set shape and everything
363
+ * is in scope.
364
+ *
365
+ * Exported because THREE places must never disagree — the injection planner,
366
+ * the snapshot the settings page renders, and the commands that edit — and a
367
+ * missed filter here is exactly how a 某客户 set would leak into a 个人
368
+ * conversation. One predicate, used three times.
369
+ *
370
+ * The fallback when `activeSetId` names nothing (a hand-edited file): the
371
+ * FIRST set, which is also what normalizeData adopts. That keeps a pure
372
+ * consumer (planInjection called directly, as the tests do) in agreement with
373
+ * the loader instead of inventing a third behaviour.
374
+ */
375
+ export function activeScope(data) {
376
+ if (data === null || typeof data !== 'object') return { scoped: false, activeId: '', ids: [] }
377
+ const sets = (Array.isArray(data.sets) ? data.sets : [])
378
+ .filter((set) => set !== null && typeof set === 'object' && typeof set.id === 'string' && set.id !== '')
379
+ if (sets.length === 0) return { scoped: false, activeId: '', ids: [] }
380
+ const wanted = typeof data.activeSetId === 'string' && data.activeSetId !== '' ? data.activeSetId : ''
381
+ const activeId = sets.some((set) => set.id === wanted) ? wanted : sets[0].id
382
+ return { scoped: true, activeId, ids: sets.map((set) => set.id) }
383
+ }
384
+
385
+ /**
386
+ * Whether one entry/profile takes part under a scope from activeScope().
387
+ * An item with no `setId` counts as belonging to the active set, mirroring
388
+ * the loader's stamping rule — an unattributed item must follow the user's
389
+ * current context rather than disappear from both the list and the prompt.
390
+ * The comparison is exact and case-sensitive: set ids are minted here.
391
+ */
392
+ export function inScope(item, scope) {
393
+ if (scope === null || typeof scope !== 'object' || scope.scoped !== true) return true
394
+ const id = item !== null && typeof item === 'object' && typeof item.setId === 'string' ? item.setId : ''
395
+ return id === '' || id === scope.activeId
396
+ }
397
+
398
+ /* ------------------------------------------------------------------ *
399
+ * History snapshot naming (F14) — pure, so the format is pinnable
400
+ * ------------------------------------------------------------------ */
401
+
402
+ /**
403
+ * A filesystem-safe UTC stamp: `20261015T072355123Z`. Fixed width, so plain
404
+ * lexicographic sorting on a filename is chronological sorting — which is what
405
+ * lets the history index sort without reading a single file.
406
+ */
407
+ export function historyStamp(epoch) {
408
+ const date = new Date(epoch)
409
+ const pad = (value, width) => String(value).padStart(width, '0')
410
+ return pad(date.getUTCFullYear(), 4) + pad(date.getUTCMonth() + 1, 2) + pad(date.getUTCDate(), 2)
411
+ + 'T' + pad(date.getUTCHours(), 2) + pad(date.getUTCMinutes(), 2) + pad(date.getUTCSeconds(), 2)
412
+ + pad(date.getUTCMilliseconds(), 3) + 'Z'
413
+ }
414
+
415
+ /** This machine's short tag, used so two devices sharing one backup folder stay distinguishable. */
416
+ export function deviceTag(rawHostname) {
417
+ const source = typeof rawHostname === 'string' && rawHostname !== '' ? rawHostname : 'unknown'
418
+ const tag = source.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 16)
419
+ return tag === '' ? 'unknown' : tag
420
+ }
421
+
422
+ /**
423
+ * `20261015T072355123Z-r12-laptop.json` — stamp, revision, writing device.
424
+ * The revision is IN the name so a snapshot is identifiable in a plain file
425
+ * listing, and the device tag is sanitised to `[a-z0-9_]` so it can never be
426
+ * confused with the separators.
427
+ */
428
+ export function historyFileName(epoch, rev, device) {
429
+ return historyStamp(epoch) + '-r' + (Number.isFinite(rev) ? rev : 0) + '-' + device + '.json'
430
+ }
431
+
432
+ /**
433
+ * What counts as one of OUR snapshots, and what we are therefore allowed to
434
+ * prune or delete. Anything else in the history directory — a hand-made file,
435
+ * another tool's backup, a cloud client's conflict copy — fails this pattern
436
+ * and is left strictly alone.
437
+ */
438
+ export const HISTORY_FILE_PATTERN = /^\d{8}T\d{9}Z-r\d+-[a-z0-9_]+(?:\.json)$/
439
+
440
+ /** The device tag inside a snapshot name, or '' when the name is not ours. */
441
+ export function historyDeviceOf(name) {
442
+ if (typeof name !== 'string') return ''
443
+ const match = /-r\d+-([a-z0-9_]+)\.json$/.exec(name)
444
+ return match === null ? '' : match[1]
445
+ }
446
+
447
+ /** The revision inside a snapshot name, or 0 when the name is not ours. */
448
+ export function historyRevOf(name) {
449
+ if (typeof name !== 'string') return 0
450
+ const match = /-r(\d+)-[a-z0-9_]+\.json$/.exec(name)
451
+ return match === null ? 0 : Number(match[1])
452
+ }
453
+
454
+ /** The epoch (ms) from a snapshot name's UTC stamp, or 0 when it is not ours. */
455
+ export function historyEpochOf(name) {
456
+ if (typeof name !== 'string') return 0
457
+ const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z-r/.exec(name)
458
+ if (match === null) return 0
459
+ return Date.UTC(
460
+ Number(match[1]), Number(match[2]) - 1, Number(match[3]),
461
+ Number(match[4]), Number(match[5]), Number(match[6]), Number(match[7]),
462
+ )
463
+ }
464
+
465
+ /** Exported for the settings page: the stamp a manual export file carries. */
466
+ export function exportFileName(epoch) {
467
+ const stamp = historyStamp(epoch)
468
+ return 'dsh-instruction-memory-' + stamp.slice(0, 8) + '-' + stamp.slice(9, 15) + 'Z.json'
469
+ }
470
+
253
471
  /**
254
472
  * Which fields of a raw entry had to be cut to fit the stored limits.
255
473
  *
@@ -282,6 +500,15 @@ export function truncatedProfileFields(raw) {
282
500
  return cut
283
501
  }
284
502
 
503
+ /** Same disclosure, for an instruction set's name. */
504
+ export function truncatedSetFields(raw) {
505
+ if (raw === null || typeof raw !== 'object') return []
506
+ const unlimited = Number.MAX_SAFE_INTEGER
507
+ return typeof raw.name === 'string' && singleLine(raw.name, unlimited) !== singleLine(raw.name, MAX_SET_NAME)
508
+ ? ['配置集名称']
509
+ : []
510
+ }
511
+
285
512
  /** JSON.stringify keeps quotes/type visible; the String() tail is for values JSON cannot encode. */
286
513
  const describeValue = (value) => {
287
514
  const text = JSON.stringify(value)
@@ -300,24 +527,42 @@ const describeValue = (value) => {
300
527
  * snapshot (another window, another process) can be refused instead of
301
528
  * silently overwriting it. Missing (pre-rev files) reads as 0.
302
529
  *
303
- * A numeric `version` other than 1 sets `rejectedVersion` and leaves the data
304
- * untouched: a future format's fields cannot be best-effort parsed as v1, and
305
- * adopting a misread would let the next save rewrite the file in the wrong
306
- * shape. A MISSING version still loads as v1 — the field has been written as
307
- * 1 since the first release, so absence is an old/hand-made file, not a
308
- * foreign format.
530
+ * Format versions: 1 and 2 both load; anything else numeric sets
531
+ * `rejectedVersion` and leaves the data untouched — a future format's fields
532
+ * cannot be best-effort parsed, and adopting a misread would let the next save
533
+ * rewrite the file in the wrong shape. A MISSING version still loads as v1 —
534
+ * the field has been written as 1 since the first release, so absence is an
535
+ * old/hand-made file, not a foreign format.
536
+ *
537
+ * v1 → v2 (F13) is fully automatic and never loses an item: with no usable
538
+ * `sets` list the store gets one 默认 set, and every entry/profile is stamped
539
+ * with its id. Items already naming a set that does not exist are re-attributed
540
+ * to the active set and COUNTED (`reattributed`), because that is a hand edit
541
+ * this loader had to overrule and the user must be told.
309
542
  *
310
543
  * `optionFixes` lists options the file carried but the loader had to correct
311
- * (a non-boolean `enabled`, an out-of-range or non-numeric `budgetChars`).
312
- * Each is a human-readable "field 原值 → 修正值 (原因)" string, surfaced the
313
- * same way as `dropped`: the next save persists the corrected values, so the
314
- * user must be told their hand edit did not survive verbatim.
544
+ * (a non-boolean `enabled`, an out-of-range or non-numeric `budgetChars`, a
545
+ * non-string/over-long `backupDir`, an `activeSetId` naming no set). Each is a
546
+ * human-readable "field 原值 → 修正值 (原因)" string, surfaced the same way as
547
+ * `dropped`: the next save persists the corrected values, so the user must be
548
+ * told their hand edit did not survive verbatim.
315
549
  */
316
550
  function normalizeData(raw) {
317
- const data = { version: 1, rev: 0, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [], profiles: [] }
318
- if (raw === null || typeof raw !== 'object') return { data, dropped: 0, optionFixes: [] }
319
- if (typeof raw.version === 'number' && raw.version !== 1) {
320
- return { data, dropped: 0, optionFixes: [], rejectedVersion: raw.version }
551
+ const data = {
552
+ version: FORMAT_VERSION,
553
+ rev: 0,
554
+ enabled: true,
555
+ budgetChars: DEFAULT_BUDGET,
556
+ backupDir: '',
557
+ activeSetId: DEFAULT_SET_ID,
558
+ sets: [],
559
+ entries: [],
560
+ profiles: [],
561
+ }
562
+ const loaded = { data, dropped: 0, droppedProfiles: 0, droppedSets: 0, reattributed: 0, optionFixes: [], formatNote: null }
563
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return loaded
564
+ if (typeof raw.version === 'number' && !SUPPORTED_VERSIONS.includes(raw.version)) {
565
+ return { data, dropped: 0, droppedProfiles: 0, droppedSets: 0, reattributed: 0, optionFixes: [], rejectedVersion: raw.version }
321
566
  }
322
567
  if (typeof raw.rev === 'number' && Number.isFinite(raw.rev) && raw.rev >= 0) {
323
568
  data.rev = Math.round(raw.rev)
@@ -325,7 +570,7 @@ function normalizeData(raw) {
325
570
  // Hand-edited options the loader has to correct are disclosed, not silently
326
571
  // adopted: the next save would otherwise rewrite the file with the fixed
327
572
  // values and the user would never learn their edit had been ignored.
328
- const optionFixes = []
573
+ const optionFixes = loaded.optionFixes
329
574
  if (typeof raw.enabled === 'boolean') {
330
575
  data.enabled = raw.enabled
331
576
  } else if ('enabled' in raw) {
@@ -340,21 +585,87 @@ function normalizeData(raw) {
340
585
  }
341
586
  data.budgetChars = clamped
342
587
  }
588
+ // Custom backup location (F18). A path is machine-local data, so anything
589
+ // unusable is dropped rather than guessed at — with a disclosure, because a
590
+ // silently ignored path would look like "backups point there" while they do
591
+ // not.
592
+ if (typeof raw.backupDir === 'string') {
593
+ const wanted = raw.backupDir.trim()
594
+ if (wanted.length > MAX_BACKUP_DIR) {
595
+ optionFixes.push('backupDir 超过 ' + MAX_BACKUP_DIR + ' 字符上限 → 已清空')
596
+ } else {
597
+ data.backupDir = wanted
598
+ }
599
+ } else if ('backupDir' in raw && raw.backupDir !== null && raw.backupDir !== '') {
600
+ optionFixes.push('backupDir ' + describeValue(raw.backupDir) + ' → ""(需要字符串路径)')
601
+ }
602
+
603
+ // Instruction sets (F13). A missing/blank list means a v1 file (or a hand
604
+ // edit that removed it): synthesise the single 默认 set and record the fact,
605
+ // so the panel can say what happened instead of appearing to have invented a
606
+ // set out of nowhere.
607
+ const rawSets = Array.isArray(raw.sets) ? raw.sets : []
608
+ const seenSetIds = new Set()
609
+ let synthesized = false
610
+ for (const candidate of rawSets) {
611
+ const set = sanitizeSet(candidate)
612
+ if (set === null || data.sets.length >= MAX_SETS) {
613
+ loaded.droppedSets += 1
614
+ continue
615
+ }
616
+ if (seenSetIds.has(set.id)) set.id = makeSetId()
617
+ seenSetIds.add(set.id)
618
+ data.sets.push(set)
619
+ }
620
+ if (data.sets.length === 0) {
621
+ data.sets.push({ id: DEFAULT_SET_ID, name: DEFAULT_SET_NAME })
622
+ synthesized = true
623
+ }
624
+ const rawActive = typeof raw.activeSetId === 'string' ? raw.activeSetId.trim() : ''
625
+ if (rawActive !== '' && data.sets.some((set) => set.id === rawActive)) {
626
+ data.activeSetId = rawActive
627
+ } else {
628
+ data.activeSetId = data.sets[0].id
629
+ if (rawActive !== '') {
630
+ optionFixes.push('activeSetId ' + describeValue(raw.activeSetId) + ' → ' + data.activeSetId + '(没有该配置集)')
631
+ } else if ('activeSetId' in raw && raw.activeSetId !== null) {
632
+ optionFixes.push('activeSetId ' + describeValue(raw.activeSetId) + ' → ' + data.activeSetId + '(需要配置集 id 字符串)')
633
+ }
634
+ }
635
+ const knownSet = (id) => data.sets.some((set) => set.id === id)
636
+ // A silent load is the normal path on a current-format file; everything else
637
+ // is stated outright, because both branches change what the NEXT save writes.
638
+ const declaredVersion = typeof raw.version === 'number' ? raw.version : null
639
+ if (synthesized) {
640
+ loaded.formatNote = '存储文件未包含配置集信息(' + (declaredVersion !== null ? '格式版本 ' + declaredVersion : '未声明格式版本')
641
+ + '),其中的条目与形象已归入「' + DEFAULT_SET_NAME + '」配置集;下次保存时文件将写为格式版本 ' + FORMAT_VERSION
642
+ } else if (declaredVersion !== FORMAT_VERSION) {
643
+ loaded.formatNote = declaredVersion === null
644
+ ? '存储文件未声明格式版本,已按格式版本 ' + FORMAT_VERSION + ' 载入;下次保存时文件将补写版本号'
645
+ : '存储文件为旧版格式(版本 ' + declaredVersion + '),已按格式版本 ' + FORMAT_VERSION + ' 载入;下次保存时文件将写为版本 ' + FORMAT_VERSION
646
+ }
647
+
343
648
  const list = Array.isArray(raw.entries) ? raw.entries : []
344
649
  const seen = new Set()
345
- let dropped = 0
346
650
  for (const candidate of list) {
347
651
  const entry = sanitizeEntry(candidate)
348
652
  if (entry === null) {
349
- dropped += 1
653
+ loaded.dropped += 1
350
654
  continue
351
655
  }
352
656
  if (data.entries.length >= MAX_ENTRIES) {
353
- dropped += 1
657
+ loaded.dropped += 1
354
658
  continue
355
659
  }
356
660
  if (seen.has(entry.id)) entry.id = makeId()
357
661
  seen.add(entry.id)
662
+ if (entry.setId === '' || !knownSet(entry.setId)) {
663
+ // A blank setId is simply an unfiled item (v1 file, hand-added entry) and
664
+ // files silently; a NON-blank id naming a set that does not exist is a
665
+ // broken reference the loader overrules, and that is counted.
666
+ if (entry.setId !== '') loaded.reattributed += 1
667
+ entry.setId = data.activeSetId
668
+ }
358
669
  data.entries.push(entry)
359
670
  }
360
671
  // User profiles ("用户形象") follow the same tolerant rules: a missing
@@ -363,18 +674,21 @@ function normalizeData(raw) {
363
674
  // silently (the next save rewrites the file without them).
364
675
  const profileList = Array.isArray(raw.profiles) ? raw.profiles : []
365
676
  const seenProfileIds = new Set()
366
- let droppedProfiles = 0
367
677
  for (const candidate of profileList) {
368
678
  const profile = sanitizeProfile(candidate)
369
679
  if (profile === null || data.profiles.length >= MAX_PROFILES) {
370
- droppedProfiles += 1
680
+ loaded.droppedProfiles += 1
371
681
  continue
372
682
  }
373
683
  if (seenProfileIds.has(profile.id)) profile.id = makeId()
374
684
  seenProfileIds.add(profile.id)
685
+ if (profile.setId === '' || !knownSet(profile.setId)) {
686
+ if (profile.setId !== '') loaded.reattributed += 1
687
+ profile.setId = data.activeSetId
688
+ }
375
689
  data.profiles.push(profile)
376
690
  }
377
- return { data, dropped, droppedProfiles, optionFixes }
691
+ return loaded
378
692
  }
379
693
 
380
694
  /**
@@ -388,10 +702,13 @@ function normalizeData(raw) {
388
702
  */
389
703
  function cloneData(data) {
390
704
  return {
391
- version: 1,
705
+ version: FORMAT_VERSION,
392
706
  rev: data.rev || 0,
393
707
  enabled: data.enabled === true,
394
708
  budgetChars: data.budgetChars,
709
+ backupDir: typeof data.backupDir === 'string' ? data.backupDir : '',
710
+ activeSetId: data.activeSetId,
711
+ sets: (Array.isArray(data.sets) ? data.sets : []).map((set) => ({ ...set })),
395
712
  entries: (Array.isArray(data.entries) ? data.entries : []).map((entry) => ({ ...entry })),
396
713
  profiles: (Array.isArray(data.profiles) ? data.profiles : []).map((profile) => ({ ...profile })),
397
714
  }
@@ -407,6 +724,16 @@ const droppedProfilesMessage = (count) =>
407
724
  ? '存储文件中有 ' + count + ' 个用户形象未能载入(名称与内容同时为空或超过 ' + MAX_PROFILES + ' 个上限);下次保存时它们将从文件中移除'
408
725
  : null
409
726
 
727
+ const droppedSetsMessage = (count) =>
728
+ count > 0
729
+ ? '存储文件中有 ' + count + ' 个配置集未能载入(名称为空或超过 ' + MAX_SETS + ' 个上限);下次保存时它们将从文件中移除,其条目会归入当前配置集'
730
+ : null
731
+
732
+ const reattributedMessage = (count) =>
733
+ count > 0
734
+ ? '存储文件中有 ' + count + ' 项(条目或形象)指向不存在的配置集,已归入当前配置集'
735
+ : null
736
+
410
737
  /**
411
738
  * The load-time disclosures for one normalizeData result: dropped entries and
412
739
  * corrected hand-edited options, joined with a full-width semicolon.
@@ -415,10 +742,15 @@ const droppedProfilesMessage = (count) =>
415
742
  */
416
743
  const loadWarnings = (loaded) => {
417
744
  const parts = []
745
+ if (typeof loaded.formatNote === 'string' && loaded.formatNote !== '') parts.push(loaded.formatNote)
418
746
  const dropped = droppedEntriesMessage(loaded.dropped)
419
747
  if (dropped !== null) parts.push(dropped)
420
748
  const droppedProfiles = droppedProfilesMessage(loaded.droppedProfiles || 0)
421
749
  if (droppedProfiles !== null) parts.push(droppedProfiles)
750
+ const droppedSets = droppedSetsMessage(loaded.droppedSets || 0)
751
+ if (droppedSets !== null) parts.push(droppedSets)
752
+ const reattributed = reattributedMessage(loaded.reattributed || 0)
753
+ if (reattributed !== null) parts.push(reattributed)
422
754
  if (Array.isArray(loaded.optionFixes) && loaded.optionFixes.length > 0) {
423
755
  parts.push('存储文件中的以下选项值无效,已按规则修正:' + loaded.optionFixes.join(';') + ';下次保存时修正值将写入文件')
424
756
  }
@@ -820,8 +1152,20 @@ export function planInjection(data) {
820
1152
  profiles: [], profileChars: 0, entryChars: 0,
821
1153
  })
822
1154
  if (data === null || typeof data !== 'object' || data.enabled !== true) return blank()
823
- const persona = buildProfilesBlock(data.profiles)
824
- const entryPlan = planEntries(data, budget)
1155
+ /**
1156
+ * The set filter (F13) is applied HERE, at the one place both the rendered
1157
+ * text and the budget decision are produced, so no caller can inject an
1158
+ * inactive set by accident. A store with no `sets` at all is the legacy
1159
+ * single-set shape and is injected whole (see activeScope).
1160
+ */
1161
+ const scope = activeScope(data)
1162
+ const scoped = {
1163
+ ...data,
1164
+ entries: (Array.isArray(data.entries) ? data.entries : []).filter((entry) => inScope(entry, scope)),
1165
+ profiles: (Array.isArray(data.profiles) ? data.profiles : []).filter((profile) => inScope(profile, scope)),
1166
+ }
1167
+ const persona = buildProfilesBlock(scoped.profiles)
1168
+ const entryPlan = planEntries(scoped, budget)
825
1169
  const parts = []
826
1170
  if (persona.text !== '') parts.push(persona.text)
827
1171
  if (entryPlan.text !== '') parts.push(entryPlan.text)
@@ -854,13 +1198,29 @@ export function apply(ctx) {
854
1198
  const systemPrompt = ctx.systemPrompt
855
1199
 
856
1200
  const state = {
857
- data: { version: 1, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [], profiles: [] },
1201
+ data: {
1202
+ version: FORMAT_VERSION,
1203
+ rev: 0,
1204
+ enabled: true,
1205
+ budgetChars: DEFAULT_BUDGET,
1206
+ backupDir: '',
1207
+ activeSetId: DEFAULT_SET_ID,
1208
+ sets: [{ id: DEFAULT_SET_ID, name: DEFAULT_SET_NAME }],
1209
+ entries: [],
1210
+ profiles: [],
1211
+ },
858
1212
  storagePath: null,
859
1213
  storageDisplay: null,
860
1214
  storageError: null,
861
1215
  storageWarning: null,
862
1216
  /** A failed rolling-backup refresh, shown only when no load warning wins. */
863
1217
  backupError: null,
1218
+ /**
1219
+ * A failed history snapshot (F14). Non-fatal by design — the store itself
1220
+ * was written — but shown, because "history is being kept" is a promise the
1221
+ * panel makes on this feature's behalf.
1222
+ */
1223
+ historyError: null,
864
1224
  /** Which branch of the load actually produced the current data (diagnostics). */
865
1225
  loadedFrom: null,
866
1226
  savedAt: null,
@@ -876,6 +1236,8 @@ export function apply(ctx) {
876
1236
  sectionError: null,
877
1237
  /** Whether a store file is actually present on disk (diagnostics + the panel). */
878
1238
  storeExists: false,
1239
+ /** How many history snapshots the current directory holds (diagnostics). */
1240
+ historyCount: 0,
879
1241
  }
880
1242
  let sectionDispose = null
881
1243
 
@@ -1165,7 +1527,47 @@ export function apply(ctx) {
1165
1527
 
1166
1528
  const storePath = resolveStorePath()
1167
1529
 
1168
- const serialize = () => JSON.stringify(state.data, null, 2) + '\n'
1530
+ /**
1531
+ * The exact object written to memory.json — and the one an export carries.
1532
+ *
1533
+ * `$schema` comes first on purpose: opening the file in an editor then offers
1534
+ * validation and field completion against the copy of memory.schema.json the
1535
+ * host keeps next to the store (see ensureSchemaFile). The loader ignores the
1536
+ * key, so it costs nothing to anyone who never opens the file.
1537
+ */
1538
+ const persistableData = () => ({
1539
+ $schema: SCHEMA_REF,
1540
+ version: FORMAT_VERSION,
1541
+ rev: state.data.rev || 0,
1542
+ enabled: state.data.enabled === true,
1543
+ budgetChars: clampBudget(state.data.budgetChars),
1544
+ backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
1545
+ activeSetId: state.data.activeSetId,
1546
+ sets: (Array.isArray(state.data.sets) ? state.data.sets : []).map((set) => ({ ...set })),
1547
+ entries: (Array.isArray(state.data.entries) ? state.data.entries : []).map((entry) => ({ ...entry })),
1548
+ profiles: (Array.isArray(state.data.profiles) ? state.data.profiles : []).map((profile) => ({ ...profile })),
1549
+ })
1550
+
1551
+ const serialize = () => JSON.stringify(persistableData(), null, 2) + '\n'
1552
+
1553
+ /* ---------------- backup location + history snapshots (F14 / F18) ---------------- */
1554
+
1555
+ const backupDirOf = () => (typeof state.data.backupDir === 'string' ? state.data.backupDir : '')
1556
+ /** Where the rolling pre-write copy lives: ALWAYS next to the store (crash recovery). */
1557
+ const localBackupPath = () => storePath + BACKUP_FILE_SUFFIX
1558
+ /** The user-facing copy under a custom backup location, or null (F18). */
1559
+ const customBackupPath = () => (backupDirOf() !== '' ? join(backupDirOf(), basename(storePath) + BACKUP_FILE_SUFFIX) : null)
1560
+ /**
1561
+ * History snapshots live in a `history/` SUBDIRECTORY of the backup location
1562
+ * (default: the store directory). Two reasons for the subdirectory:
1563
+ * 1. the external-change watcher listens on the store directory and must
1564
+ * never see a snapshot as a change to memory.json — a subdirectory is
1565
+ * not reported by a non-recursive watch, and the filename filter drops
1566
+ * anything that is not memory.json anyway;
1567
+ * 2. a custom (e.g. cloud-synced) backup directory stays tidy: one known
1568
+ * folder holds the history, nothing else is touched.
1569
+ */
1570
+ const historyDirPath = () => join(backupDirOf() !== '' ? backupDirOf() : dirname(storePath), HISTORY_DIR_NAME)
1169
1571
 
1170
1572
  /**
1171
1573
  * Cross-process write lock: a `memory.json.lock` marker created exclusively
@@ -1234,6 +1636,151 @@ export function apply(ctx) {
1234
1636
  }
1235
1637
  }
1236
1638
 
1639
+ /* ---------------- custom backup location (F18) ---------------- */
1640
+
1641
+ /**
1642
+ * Can this directory actually hold backups right now? Setting a path is a
1643
+ * promise ("my backups live there"), so it is verified at the moment it is
1644
+ * set — exists, is a directory, and accepts a write — instead of being
1645
+ * discovered to be wrong by the first save that silently failed to copy.
1646
+ *
1647
+ * @returns null when usable, otherwise a human-readable reason.
1648
+ */
1649
+ const probeBackupDir = async (dir) => {
1650
+ try {
1651
+ const info = await stat(dir)
1652
+ if (!info.isDirectory()) return '该路径不是目录'
1653
+ } catch {
1654
+ return '目录不存在或不可访问'
1655
+ }
1656
+ const probe = join(dir, '.dsh-instruction-memory-probe-' + randomUUID())
1657
+ try {
1658
+ await writeFile(probe, 'probe', 'utf8')
1659
+ await unlink(probe).catch(() => {})
1660
+ return null
1661
+ } catch (err) {
1662
+ return '目录不可写:' + describeError(err)
1663
+ }
1664
+ }
1665
+
1666
+ /* ---------------- history snapshots and the schema copy (both best-effort) ---------------- */
1667
+
1668
+ /** This machine's tag inside snapshot filenames; see deviceTag(). */
1669
+ const DEVICE = deviceTag((() => {
1670
+ try {
1671
+ return hostname()
1672
+ } catch {
1673
+ return ''
1674
+ }
1675
+ })())
1676
+
1677
+ /**
1678
+ * The bytes of the most recent snapshot, so two writes that produce the same
1679
+ * file (a no-op-ish save, a repeat restore) do not each mint a "version".
1680
+ */
1681
+ let lastHistoryText = null
1682
+
1683
+ /**
1684
+ * Enforce the per-device retention limit. Grouping by the device tag inside
1685
+ * the filename is deliberate: two machines sharing one backup folder (the
1686
+ * documented F18 setup) each keep MAX_HISTORY of their OWN snapshots, so one
1687
+ * device can never prune the other's history away. Files that do not match
1688
+ * our naming pattern are never touched.
1689
+ */
1690
+ const pruneHistory = async (dir) => {
1691
+ const names = (await readdir(dir)).filter((name) => HISTORY_FILE_PATTERN.test(name))
1692
+ const byDevice = new Map()
1693
+ for (const name of names) {
1694
+ const key = historyDeviceOf(name)
1695
+ const bucket = byDevice.get(key)
1696
+ if (bucket === undefined) byDevice.set(key, [name])
1697
+ else bucket.push(name)
1698
+ }
1699
+ let removed = 0
1700
+ let kept = 0
1701
+ for (const bucket of byDevice.values()) {
1702
+ // Fixed-width UTC stamp first => lexicographic order is chronological.
1703
+ bucket.sort()
1704
+ const excess = bucket.length - MAX_HISTORY
1705
+ for (let index = 0; index < bucket.length; index += 1) {
1706
+ if (index < excess) {
1707
+ await unlink(join(dir, bucket[index])).catch(() => {})
1708
+ removed += 1
1709
+ } else {
1710
+ kept += 1
1711
+ }
1712
+ }
1713
+ }
1714
+ return { removed, kept }
1715
+ }
1716
+
1717
+ /**
1718
+ * Record the just-written store as a new history snapshot (F14).
1719
+ *
1720
+ * Written AFTER the atomic replace, so a snapshot always equals a state that
1721
+ * really was committed; a failure here is disclosed and never fails the save,
1722
+ * because the store itself is already safely on disk.
1723
+ */
1724
+ const writeHistorySnapshot = async (text) => {
1725
+ if (typeof text !== 'string' || text === '' || text === lastHistoryText) return
1726
+ try {
1727
+ const dir = historyDirPath()
1728
+ await mkdir(dir, { recursive: true })
1729
+ const epoch = Date.now()
1730
+ let written = false
1731
+ // `wx` never overwrites: same-millisecond saves (two rapid bulk actions)
1732
+ // bump the stamp by a millisecond instead of losing one of the two.
1733
+ for (let attempt = 0; attempt < 5 && !written; attempt += 1) {
1734
+ try {
1735
+ await writeFile(join(dir, historyFileName(epoch + attempt, state.data.rev || 0, DEVICE)), text, { encoding: 'utf8', flag: 'wx' })
1736
+ written = true
1737
+ } catch (err) {
1738
+ if (err.code !== 'EEXIST') throw err
1739
+ }
1740
+ }
1741
+ if (!written) throw new Error('快照文件名重复')
1742
+ lastHistoryText = text
1743
+ const { kept } = await pruneHistory(dir)
1744
+ state.historyCount = kept
1745
+ state.historyError = null
1746
+ } catch (err) {
1747
+ state.historyError = '版本历史快照未能写入(' + describeError(err) + ');本次保存本身已完成'
1748
+ }
1749
+ }
1750
+
1751
+ /**
1752
+ * Keep a copy of the shipped JSON Schema next to the store, so the `$schema`
1753
+ * reference inside memory.json resolves for a hand editor who has no idea
1754
+ * where the package lives. Content-compared: one small read on a save, a
1755
+ * write only when the shipped schema actually changed. Never throws — the
1756
+ * schema is a convenience, not a precondition for holding memory.
1757
+ */
1758
+ const ensureSchemaFile = async () => {
1759
+ try {
1760
+ const source = await readFile(fileURLToPath(new URL('../' + SCHEMA_FILE_NAME, import.meta.url)), 'utf8')
1761
+ const target = join(dirname(storePath), SCHEMA_FILE_NAME)
1762
+ const existing = await readFile(target, 'utf8').catch(() => null)
1763
+ if (existing !== source) await writeFile(target, source, 'utf8')
1764
+ } catch {
1765
+ // Best effort by design.
1766
+ }
1767
+ }
1768
+
1769
+ /**
1770
+ * Recount the snapshot directory for the panel's diagnostics. Best effort:
1771
+ * an unreadable directory simply leaves the count as it was, because the
1772
+ * history list (the actual UI, which reports its own errors) is what the
1773
+ * user acts on.
1774
+ */
1775
+ const refreshHistoryCount = async () => {
1776
+ try {
1777
+ const names = await readdir(historyDirPath())
1778
+ state.historyCount = names.filter((name) => HISTORY_FILE_PATTERN.test(name)).length
1779
+ } catch {
1780
+ // No directory yet (nothing written) — count stays 0.
1781
+ }
1782
+ }
1783
+
1237
1784
  const writeToDisk = async () => {
1238
1785
  // Atomic replace (write a sibling temp file, then rename) with a rolling
1239
1786
  // one-version backup: a crash mid-write must never leave a truncated
@@ -1249,7 +1796,6 @@ export function apply(ctx) {
1249
1796
  // the primary in place until the atomic replace succeeds, so a failure at
1250
1797
  // any step leaves the previous store readable at its own path.
1251
1798
  const tmpPath = storePath + '.tmp'
1252
- const bakPath = storePath + '.bak'
1253
1799
  let lock = null
1254
1800
  let previousRev = null
1255
1801
  try {
@@ -1264,8 +1810,12 @@ export function apply(ctx) {
1264
1810
  state.data.rev = previousRev + 1
1265
1811
  const text = serialize()
1266
1812
  await writeFile(tmpPath, text, 'utf8')
1813
+ // Both rolling copies stand for the PREVIOUS good version, so both are
1814
+ // taken before the atomic replace, and neither may ever be renamed into
1815
+ // place (see the long note above).
1816
+ let hadPrevious = true
1267
1817
  try {
1268
- await copyFile(storePath, bakPath)
1818
+ await copyFile(storePath, localBackupPath())
1269
1819
  state.backupError = null
1270
1820
  } catch (err) {
1271
1821
  // ENOENT is simply the very first save: there is no previous version
@@ -1273,9 +1823,23 @@ export function apply(ctx) {
1273
1823
  // happen — non-fatal for this save, because the primary is still intact
1274
1824
  // and the replace below is the real gate, but it must be disclosed
1275
1825
  // rather than swallowed.
1826
+ hadPrevious = err.code !== 'ENOENT'
1276
1827
  state.backupError = err.code === 'ENOENT'
1277
1828
  ? null
1278
- : '上一版未能保留为 memory.json.bak:' + describeError(err)
1829
+ : '上一版未能保留为 ' + localBackupPath() + ':' + describeError(err)
1830
+ }
1831
+ // The user-facing copy under a custom backup location (F18). The LOCAL
1832
+ // copy above stays the one crash recovery reads, so a failure here
1833
+ // degrades the extra copy only — never the plugin's own safety net.
1834
+ const customBak = customBackupPath()
1835
+ if (customBak !== null && hadPrevious) {
1836
+ try {
1837
+ await copyFile(storePath, customBak)
1838
+ } catch (err) {
1839
+ if (state.backupError === null) {
1840
+ state.backupError = '备份位置未能保留上一版(' + customBak + '):' + describeError(err)
1841
+ }
1842
+ }
1279
1843
  }
1280
1844
  try {
1281
1845
  await rename(tmpPath, storePath)
@@ -1294,8 +1858,15 @@ export function apply(ctx) {
1294
1858
  // be (re)armed after a first run that had nothing to watch.
1295
1859
  startWatching()
1296
1860
  state.savedAt = Date.now()
1861
+ // Best-effort side files, each guarded inside itself: a failure in either
1862
+ // must never be mistaken for "the save failed" — the save already
1863
+ // committed, and the store is on disk whatever happens next.
1864
+ await writeHistorySnapshot(text)
1865
+ await ensureSchemaFile()
1297
1866
  logDebug('store written: ' + storePath + ' bytes=' + text.length
1298
1867
  + ' entries=' + state.data.entries.length + ' rev=' + state.data.rev
1868
+ + ' sets=' + state.data.sets.length + ' activeSet=' + state.data.activeSetId
1869
+ + ' history=' + state.historyCount
1299
1870
  + (state.backupError === null ? ' backup=refreshed' : ' backup=FAILED'))
1300
1871
  return true
1301
1872
  } catch (err) {
@@ -1335,14 +1906,21 @@ export function apply(ctx) {
1335
1906
  /**
1336
1907
  * A store declaring an unsupported format version: adopt nothing, write
1337
1908
  * nothing, and say why. The one thing that must NEVER happen in this
1338
- * state is a save — it would rewrite the file as v1 and destroy whatever
1339
- * the newer format meant (the mutating commands refuse while
1340
- * versionRejected is set).
1909
+ * state is a save — it would rewrite the file in this build's format and
1910
+ * destroy whatever the newer format meant (the mutating commands refuse
1911
+ * while versionRejected is set).
1912
+ *
1913
+ * This is also the guard that makes a DOWNGRADE safe: a file written by a
1914
+ * newer plugin (e.g. version 2 from an F13-aware build) opened by an older
1915
+ * one is refused here, with the data left untouched, instead of being
1916
+ * half-read and then rewritten without the fields the old build cannot
1917
+ * see.
1341
1918
  */
1342
1919
  const rejectVersion = (version, source) => {
1343
1920
  state.versionRejected = true
1344
1921
  state.rejectedVersion = version
1345
- state.storageError = '存储文件声明为格式版本 ' + version + ',本插件只支持版本 1;已拒绝载入以防数据损坏,原文件已保留,修改操作已暂停。请升级插件,或手动将文件改回版本 1 后点「重新载入」'
1922
+ state.storageError = '存储文件声明为格式版本 ' + version + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
1923
+ + ';已拒绝载入以防数据损坏,原文件已保留,修改操作已暂停。请升级插件,或手动将文件改回受支持的版本后点「重新载入」'
1346
1924
  state.loadedFrom = source + ' (unsupported version ' + version + ')'
1347
1925
  state.storeExists = true
1348
1926
  }
@@ -1360,6 +1938,9 @@ export function apply(ctx) {
1360
1938
  state.storageWarning = loadWarnings(loaded)
1361
1939
  state.loadedFrom = 'memory.json'
1362
1940
  state.storeExists = true
1941
+ // A real file is now in memory, so the "materialise on first need"
1942
+ // flag is spent — see ensureStoreMaterialized.
1943
+ needsMaterialize = false
1363
1944
  }
1364
1945
  state.storageError = null
1365
1946
  return false
@@ -1487,6 +2068,20 @@ export function apply(ctx) {
1487
2068
  */
1488
2069
  const ensureStoreMaterialized = async () => {
1489
2070
  if (!needsMaterialize) return false
2071
+ /**
2072
+ * A store may have APPEARED since boot — a hand-copied memory.json, a
2073
+ * backup restored by another tool, a second process. Materialising the
2074
+ * empty in-memory store over it would destroy exactly what the user just
2075
+ * put there, so the disk is re-checked first and an existing file is
2076
+ * adopted instead. (The gap is wide enough to matter: the panel's first
2077
+ * `state` call can come minutes after boot.)
2078
+ */
2079
+ const appeared = await readStoreFile(storePath).catch(() => null)
2080
+ if (appeared !== null) {
2081
+ const wrote = await readFromDisk()
2082
+ syncSection()
2083
+ return wrote
2084
+ }
1490
2085
  return writeToDisk()
1491
2086
  }
1492
2087
 
@@ -1506,9 +2101,10 @@ export function apply(ctx) {
1506
2101
  .catch((err) => {
1507
2102
  state.storageError = '读取失败:' + describeError(err)
1508
2103
  })
1509
- .then(() => {
2104
+ .then(async () => {
1510
2105
  bootSettled = true
1511
2106
  syncSection()
2107
+ await refreshHistoryCount()
1512
2108
  // The one always-on line: it answers "is memory mounted, where does it
1513
2109
  // live, and how much is being injected?" without any further digging.
1514
2110
  // 措辞很重要:`section=` 说的是"这个段落已就绪、若被装配会产出多少字符",
@@ -1519,6 +2115,7 @@ export function apply(ctx) {
1519
2115
  + ' from=' + String(state.loadedFrom)
1520
2116
  + ' onDisk=' + (state.storeExists ? 'yes' : 'no')
1521
2117
  + ' entries=' + state.data.entries.length
2118
+ + ' sets=' + state.data.sets.length + ' activeSet=' + state.data.activeSetId
1522
2119
  + ' enabled=' + (state.data.enabled === true)
1523
2120
  + ' section=' + (chars > 0 ? chars + ' chars (pending assembly)' : 'nothing to inject')
1524
2121
  + ' pulls=' + sectionPulls
@@ -1545,17 +2142,58 @@ export function apply(ctx) {
1545
2142
 
1546
2143
  /* ---------------- snapshots ---------------- */
1547
2144
 
2145
+ /**
2146
+ * The snapshot the settings page renders (and the SSE frames carry).
2147
+ *
2148
+ * `data.entries`/`data.profiles` publish ONLY the active set (F13): the panel
2149
+ * lists and edits exactly what is being injected, so a 某客户 set can never
2150
+ * appear in the 个人 panel — the isolation guarantee the feature exists for.
2151
+ * The set list, with per-set counts and the active flag, travels in `sets`,
2152
+ * and the raw arrays are in `data.sets`/`data.activeSetId` for the switcher.
2153
+ * Anything needing the WHOLE store (export, source mode) goes through
2154
+ * persistableData(), which is also what the file holds.
2155
+ */
1548
2156
  const snapshot = () => {
1549
2157
  const plan = sectionPlan()
1550
2158
  const preview = plan.text
2159
+ const scope = activeScope(state.data)
2160
+ const belongsTo = (item, id) => {
2161
+ const own = item !== null && typeof item === 'object' && typeof item.setId === 'string' && item.setId !== ''
2162
+ ? item.setId
2163
+ : state.data.activeSetId
2164
+ return own === id
2165
+ }
2166
+ const activeEntries = state.data.entries.filter((entry) => inScope(entry, scope))
2167
+ const activeProfiles = state.data.profiles.filter((profile) => inScope(profile, scope))
1551
2168
  return {
1552
2169
  data: {
1553
- version: 1,
2170
+ version: FORMAT_VERSION,
1554
2171
  rev: state.data.rev || 0,
1555
2172
  enabled: state.data.enabled === true,
1556
2173
  budgetChars: clampBudget(state.data.budgetChars),
1557
- entries: (Array.isArray(state.data.entries) ? state.data.entries : []).map((entry) => ({ ...entry })),
1558
- profiles: (Array.isArray(state.data.profiles) ? state.data.profiles : []).map((profile) => ({ ...profile })),
2174
+ backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
2175
+ activeSetId: state.data.activeSetId,
2176
+ sets: state.data.sets.map((set) => ({ id: set.id, name: set.name })),
2177
+ entries: activeEntries.map((entry) => ({ ...entry })),
2178
+ profiles: activeProfiles.map((profile) => ({ ...profile })),
2179
+ },
2180
+ /**
2181
+ * Instruction sets (F13): the switcher's data source. `total` counts the
2182
+ * WHOLE store (all sets), which is what the 200-entry cap is measured
2183
+ * against — the panel says "共 N 条" per active set but the cap message
2184
+ * must quote the store-wide number.
2185
+ */
2186
+ sets: {
2187
+ activeId: state.data.activeSetId,
2188
+ scoped: scope.scoped,
2189
+ total: { entries: state.data.entries.length, profiles: state.data.profiles.length },
2190
+ list: state.data.sets.map((set) => ({
2191
+ id: set.id,
2192
+ name: set.name,
2193
+ active: set.id === state.data.activeSetId,
2194
+ entries: state.data.entries.filter((entry) => belongsTo(entry, set.id)).length,
2195
+ profiles: state.data.profiles.filter((profile) => belongsTo(profile, set.id)).length,
2196
+ })),
1559
2197
  },
1560
2198
  storage: {
1561
2199
  path: state.storageDisplay !== null ? state.storageDisplay : state.storagePath,
@@ -1565,6 +2203,16 @@ export function apply(ctx) {
1565
2203
  migratedFrom: state.migratedFrom,
1566
2204
  /** false until the store file has actually been written (see readFromDisk step 3). */
1567
2205
  onDisk: state.storeExists,
2206
+ /** Backup location (F18): '' means "next to the store file". */
2207
+ backupDir: backupDirOf(),
2208
+ isDefaultBackupDir: backupDirOf() === '',
2209
+ /** Where the rolling pre-write copy actually sits (diagnostics). */
2210
+ backupPath: localBackupPath(),
2211
+ /** Version history (F14). */
2212
+ historyDir: historyDirPath(),
2213
+ historyCount: state.historyCount,
2214
+ historyError: state.historyError,
2215
+ schemaFile: join(dirname(storePath), SCHEMA_FILE_NAME),
1568
2216
  },
1569
2217
  injection: {
1570
2218
  registered: state.sectionRegistered,
@@ -1611,6 +2259,10 @@ export function apply(ctx) {
1611
2259
  maxProfiles: MAX_PROFILES,
1612
2260
  maxProfileName: MAX_PROFILE_NAME,
1613
2261
  maxProfileContent: MAX_PROFILE_CONTENT,
2262
+ maxSets: MAX_SETS,
2263
+ maxSetName: MAX_SET_NAME,
2264
+ maxHistory: MAX_HISTORY,
2265
+ maxBackupDir: MAX_BACKUP_DIR,
1614
2266
  maxBodyChars: MAX_BODY_CHARS,
1615
2267
  },
1616
2268
  }
@@ -1649,17 +2301,70 @@ export function apply(ctx) {
1649
2301
 
1650
2302
  /**
1651
2303
  * Refusal while the store on disk declares an unsupported format version:
1652
- * a save here would rewrite the file as v1 and destroy whatever the newer
1653
- * format meant. The file stays untouched until the user upgrades the plugin
1654
- * or restores a v1 file and hits 重新载入.
2304
+ * a save here would rewrite it in this build's format and destroy whatever
2305
+ * the newer format meant. The file stays untouched until the user upgrades
2306
+ * the plugin or restores a supported-version file and hits 重新载入.
1655
2307
  */
1656
2308
  const versionRefusal = () => ({
1657
2309
  ok: false,
1658
2310
  saved: false,
1659
- message: '存储文件为不支持的格式版本 ' + state.rejectedVersion + ',修改已暂停:现在保存会用版本 1 覆盖它,可能损坏数据。请先升级插件,或手动将文件改回版本 1 后点「重新载入」',
2311
+ message: '存储文件为不支持的格式版本 ' + state.rejectedVersion + ',修改已暂停:现在保存会用版本 ' + FORMAT_VERSION
2312
+ + ' 覆盖它,可能损坏数据。请先升级插件,或手动将文件改回受支持的版本(' + SUPPORTED_VERSIONS.join(' / ') + ')后点「重新载入」',
1660
2313
  snapshot: snapshot(),
1661
2314
  })
1662
2315
 
2316
+ /**
2317
+ * Adopt a whole parsed store — the shared core of 源码模式 (F16) and the
2318
+ * 整体替换 restore (F14). Both hand the user the SAME safety story as every
2319
+ * other entry point: only a v1/v2 store object is accepted (a stray `{}` or
2320
+ * an unrelated JSON file must never be able to wipe the store), the format
2321
+ * version gate applies, and every correction normalizeData makes is
2322
+ * reported.
2323
+ *
2324
+ * `backupDir` is deliberately NOT adopted: the backup location is
2325
+ * machine-local state, so restoring a file — from another machine, or from
2326
+ * before the path existed — must not silently redirect where backups go.
2327
+ */
2328
+ const adoptParsedStore = (parsed) => {
2329
+ const looksLikeStore = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
2330
+ && ['version', 'rev', 'enabled', 'budgetChars', 'backupDir', 'activeSetId', 'sets', 'entries', 'profiles']
2331
+ .some((key) => key in parsed)
2332
+ if (!looksLikeStore) {
2333
+ return {
2334
+ error: '内容不像本插件的存储文件:顶层需要是对象,且至少包含 entries / profiles / sets / version 之一。'
2335
+ + '若要清空记忆,请使用界面上的删除操作,而不是保存一个空对象',
2336
+ }
2337
+ }
2338
+ const loaded = normalizeData(parsed)
2339
+ if (loaded.rejectedVersion !== undefined) return { rejectedVersion: loaded.rejectedVersion }
2340
+ loaded.data.backupDir = backupDirOf()
2341
+ return { loaded }
2342
+ }
2343
+
2344
+ /** Per-field truncation disclosure for an adopted raw store (mirrors import). */
2345
+ const truncationNotes = (parsed) => {
2346
+ const lists = [
2347
+ [parsed.entries, truncatedFields],
2348
+ [parsed.profiles, truncatedProfileFields],
2349
+ [parsed.sets, truncatedSetFields],
2350
+ ]
2351
+ let items = 0
2352
+ const fields = new Set()
2353
+ for (const [list, fieldsOf] of lists) {
2354
+ for (const raw of (Array.isArray(list) ? list : [])) {
2355
+ const cut = fieldsOf(raw)
2356
+ if (cut.length > 0) {
2357
+ items += 1
2358
+ for (const name of cut) fields.add(name)
2359
+ }
2360
+ }
2361
+ }
2362
+ return items > 0 ? '有 ' + items + ' 项超过长度上限,' + [...fields].join('、') + '已被截断' : null
2363
+ }
2364
+
2365
+ const describeStoreShape = (data) =>
2366
+ data.entries.length + ' 条指令 / ' + data.profiles.length + ' 个用户形象 / ' + data.sets.length + ' 个配置集'
2367
+
1663
2368
  /**
1664
2369
  * Apply an in-memory mutation, persist it, and UNDO it when the write fails.
1665
2370
  *
@@ -1707,11 +2412,33 @@ export function apply(ctx) {
1707
2412
  const entry = sanitizeEntry(raw)
1708
2413
  if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
1709
2414
  entry.updatedAt = Date.now()
2415
+ /**
2416
+ * The active set is authoritative for the interactive save path: the panel
2417
+ * edits what is being injected, and an edit must never be able to move an
2418
+ * item into another set (or drag one in). Moving content between sets is a
2419
+ * deliberate act — create the entry where it belongs, or use 复制配置集.
2420
+ */
2421
+ entry.setId = state.data.activeSetId
2422
+ /**
2423
+ * An id that already belongs to ANOTHER set is not this command's to
2424
+ * replace (entries are addressed by id alone): honouring it would move the
2425
+ * other set's entry into this one and overwrite its text — a cross-set
2426
+ * leak in the one direction the whole feature exists to prevent. Mint a
2427
+ * fresh id instead, and say so, because the caller's id silently changed.
2428
+ */
2429
+ const foreignId = state.data.entries.some((candidate) => candidate.id === entry.id
2430
+ && candidate.setId !== state.data.activeSetId)
2431
+ if (foreignId) entry.id = makeId()
1710
2432
  // Resolved before the mutation so a refusal can return a response instead
1711
2433
  // of being swallowed as an early exit inside it.
1712
2434
  const replacesExisting = state.data.entries.some((candidate) => candidate.id === entry.id)
1713
2435
  if (!replacesExisting && state.data.entries.length >= MAX_ENTRIES) {
1714
- return { ok: false, saved: false, message: '最多保存 ' + MAX_ENTRIES + ' 条指令', snapshot: snapshot() }
2436
+ return {
2437
+ ok: false,
2438
+ saved: false,
2439
+ message: '最多保存 ' + MAX_ENTRIES + ' 条指令(全部配置集合计,当前 ' + state.data.entries.length + ' 条)',
2440
+ snapshot: snapshot(),
2441
+ }
1715
2442
  }
1716
2443
  const cut = truncatedFields(raw)
1717
2444
  const saved = await commit(() => {
@@ -1720,10 +2447,14 @@ export function apply(ctx) {
1720
2447
  if (index >= 0) list[index] = entry
1721
2448
  else list.push(entry)
1722
2449
  })
1723
- const note = cut.length > 0
1724
- ? '已保存;' + cut.join('、') + '超过长度上限(标题 ' + MAX_TITLE + ' / 内容 ' + MAX_CONTENT + ' / 适用场景 ' + MAX_WHEN + ' 字符),超出部分已被截断'
1725
- : null
1726
- return settle(note, saved)
2450
+ const notes = []
2451
+ if (cut.length > 0) {
2452
+ notes.push(cut.join('、') + '超过长度上限(标题 ' + MAX_TITLE + ' / 内容 ' + MAX_CONTENT + ' / 适用场景 ' + MAX_WHEN + ' 字符),超出部分已被截断')
2453
+ }
2454
+ if (foreignId) {
2455
+ notes.push('提交的 id 属于其他配置集,已改用新 id 在当前配置集新建(其他配置集的内容未被改动)')
2456
+ }
2457
+ return settle(notes.length > 0 ? '已保存;' + notes.join(';') : null, saved)
1727
2458
  })
1728
2459
 
1729
2460
  const deleteEntry = (id, baseRev) => enqueue(async () => {
@@ -1733,7 +2464,13 @@ export function apply(ctx) {
1733
2464
  // disk, bump rev, or push an echo frame — otherwise two panels deleting
1734
2465
  // the same entry one after another would manufacture a pointless write
1735
2466
  // (and an external process watching the file would wake for nothing).
1736
- if (!state.data.entries.some((entry) => entry.id === id)) {
2467
+ //
2468
+ // Scoped to the ACTIVE set: the panel only ever lists those ids, so an id
2469
+ // from another set is not "an entry that is not there" by accident — it is
2470
+ // a request this command must not honour (deleting across sets from a
2471
+ // settings page that claims to show one set would be a data-loss bug).
2472
+ const scope = activeScope(state.data)
2473
+ if (!state.data.entries.some((entry) => entry.id === id && inScope(entry, scope))) {
1737
2474
  return { ok: true, saved: false, message: null, snapshot: snapshot() }
1738
2475
  }
1739
2476
  const saved = await commit(() => {
@@ -1745,15 +2482,48 @@ export function apply(ctx) {
1745
2482
  const setOptions = (patch, baseRev) => enqueue(async () => {
1746
2483
  if (state.versionRejected) return versionRefusal()
1747
2484
  if (isStaleBaseRev(baseRev)) return conflictResponse()
2485
+ // The custom backup location (F18) is validated BEFORE anything is queued
2486
+ // into the store: an unusable path must be refused with a reason, not
2487
+ // accepted and then discovered as a failed copy on the next save.
2488
+ if (patch !== null && typeof patch === 'object' && typeof patch.backupDir === 'string') {
2489
+ const wanted = patch.backupDir.trim()
2490
+ if (wanted !== '') {
2491
+ if (wanted.length > MAX_BACKUP_DIR) {
2492
+ return { ok: false, saved: false, message: '备份位置路径过长(上限 ' + MAX_BACKUP_DIR + ' 字符)', snapshot: snapshot() }
2493
+ }
2494
+ if (!isAbsolute(wanted)) {
2495
+ return {
2496
+ ok: false,
2497
+ saved: false,
2498
+ message: '备份位置需要绝对路径(例如 D:\\云盘\\dsh-memory 或 /home/me/cloud/dsh-memory);留空表示与存储文件同目录',
2499
+ snapshot: snapshot(),
2500
+ }
2501
+ }
2502
+ const problem = await probeBackupDir(wanted)
2503
+ if (problem !== null) {
2504
+ return { ok: false, saved: false, message: '备份位置不可用(' + problem + '):' + wanted, snapshot: snapshot() }
2505
+ }
2506
+ }
2507
+ }
1748
2508
  const saved = await commit(() => {
1749
2509
  if (patch !== null && typeof patch === 'object') {
1750
2510
  if (typeof patch.enabled === 'boolean') state.data.enabled = patch.enabled
1751
2511
  if (typeof patch.budgetChars === 'number' && Number.isFinite(patch.budgetChars)) {
1752
2512
  state.data.budgetChars = clampBudget(patch.budgetChars)
1753
2513
  }
2514
+ if (typeof patch.backupDir === 'string') state.data.backupDir = patch.backupDir.trim()
1754
2515
  }
1755
2516
  })
1756
- return settle(null, saved)
2517
+ // A changed backup location moves where history snapshots and manual
2518
+ // exports go, so it is reported rather than left to be discovered.
2519
+ let note = null
2520
+ if (patch !== null && typeof patch === 'object' && typeof patch.backupDir === 'string') {
2521
+ const wanted = patch.backupDir.trim()
2522
+ note = wanted === ''
2523
+ ? '已恢复默认备份位置(版本历史与导出与存储文件同目录)'
2524
+ : '备份位置已更新:' + wanted + '(版本历史与「导出到备份目录」随之改变;存储文件本身不移动)'
2525
+ }
2526
+ return settle(note, saved)
1757
2527
  })
1758
2528
 
1759
2529
  /**
@@ -1787,7 +2557,11 @@ export function apply(ctx) {
1787
2557
  }
1788
2558
 
1789
2559
  const list = state.data.entries
1790
- const targets = list.filter((entry) => uniqueIds.includes(entry.id))
2560
+ // Scoped to the active set, exactly like the single-entry commands: the
2561
+ // panel's selection can only ever contain active-set ids, so an id from
2562
+ // another set is reported as "not found" rather than acted on.
2563
+ const scope = activeScope(state.data)
2564
+ const targets = list.filter((entry) => uniqueIds.includes(entry.id) && inScope(entry, scope))
1791
2565
  const missing = uniqueIds.length - targets.length
1792
2566
  const missingNote = missing > 0
1793
2567
  ? '(另有 ' + missing + ' 条未找到,可能已被其他窗口或进程修改)'
@@ -1882,6 +2656,13 @@ export function apply(ctx) {
1882
2656
  const profile = sanitizeProfile(raw)
1883
2657
  if (profile === null) return { ok: false, saved: false, message: '形象名称与内容不能同时为空', snapshot: snapshot() }
1884
2658
  profile.updatedAt = Date.now()
2659
+ /** Same rule as entries: the interactive path files into the active set. */
2660
+ profile.setId = state.data.activeSetId
2661
+ // ...and an id owned by another set is re-minted rather than replaced (see
2662
+ // the note in saveEntry — same cross-set hazard, same guard).
2663
+ const foreignProfileId = state.data.profiles.some((candidate) => candidate.id === profile.id
2664
+ && candidate.setId !== state.data.activeSetId)
2665
+ if (foreignProfileId) profile.id = makeId()
1885
2666
  const replacesExisting = state.data.profiles.some((candidate) => candidate.id === profile.id)
1886
2667
  if (!replacesExisting && state.data.profiles.length >= MAX_PROFILES) {
1887
2668
  return { ok: false, saved: false, message: '最多保存 ' + MAX_PROFILES + ' 个用户形象', snapshot: snapshot() }
@@ -1902,8 +2683,10 @@ export function apply(ctx) {
1902
2683
  const deleteProfile = (id, baseRev) => enqueue(async () => {
1903
2684
  if (state.versionRejected) return versionRefusal()
1904
2685
  if (isStaleBaseRev(baseRev)) return conflictResponse()
1905
- // Mirror deleteEntry: an absent id changes nothing and must not touch disk.
1906
- if (!state.data.profiles.some((profile) => profile.id === id)) {
2686
+ // Mirror deleteEntry: an absent id changes nothing and must not touch disk,
2687
+ // and an id belonging to a different set is not this panel's to delete.
2688
+ const scope = activeScope(state.data)
2689
+ if (!state.data.profiles.some((profile) => profile.id === id && inScope(profile, scope))) {
1907
2690
  return { ok: true, saved: false, message: null, snapshot: snapshot() }
1908
2691
  }
1909
2692
  const saved = await commit(() => {
@@ -1912,6 +2695,269 @@ export function apply(ctx) {
1912
2695
  return settle(null, saved)
1913
2696
  })
1914
2697
 
2698
+ /* ---------------- instruction sets (F13) ---------------- */
2699
+
2700
+ /**
2701
+ * Create or rename an instruction set.
2702
+ *
2703
+ * A blank `set.id` is a CREATE (the Host mints the id), the same convention
2704
+ * entry edits use, and a supplied id is a RENAME. Creating does NOT switch:
2705
+ * switching is what changes what gets injected, and it stays a separate,
2706
+ * explicit click (the response says so). Nothing in this file ever derives a
2707
+ * set from the process cwd or from an open folder — see the README note on
2708
+ * the incident that rule comes from.
2709
+ */
2710
+ const saveSet = (raw, baseRev) => enqueue(async () => {
2711
+ if (state.versionRejected) return versionRefusal()
2712
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
2713
+ const wanted = sanitizeSet(raw)
2714
+ if (wanted === null) {
2715
+ return { ok: false, saved: false, message: '配置集名称不能为空(单行,最多 ' + MAX_SET_NAME + ' 字符)', snapshot: snapshot() }
2716
+ }
2717
+ const index = state.data.sets.findIndex((set) => set.id === wanted.id)
2718
+ if (state.data.sets.some((set, at) => at !== index && set.name === wanted.name)) {
2719
+ return { ok: false, saved: false, message: '已存在同名配置集「' + wanted.name + '」', snapshot: snapshot() }
2720
+ }
2721
+ if (index < 0) {
2722
+ if (state.data.sets.length >= MAX_SETS) {
2723
+ return { ok: false, saved: false, message: '最多创建 ' + MAX_SETS + ' 个配置集', snapshot: snapshot() }
2724
+ }
2725
+ const cut = truncatedSetFields(raw)
2726
+ const saved = await commit(() => {
2727
+ state.data.sets.push({ id: wanted.id, name: wanted.name })
2728
+ })
2729
+ const note = '已创建配置集「' + wanted.name + '」'
2730
+ + (cut.length > 0 ? ';名称超过 ' + MAX_SET_NAME + ' 字符上限,超出部分已被截断' : '')
2731
+ + ';它是空的,点击「切换」后才会注入它'
2732
+ return settle(note, saved)
2733
+ }
2734
+ if (state.data.sets[index].name === wanted.name) {
2735
+ return { ok: true, saved: false, message: '名称未变化', snapshot: snapshot() }
2736
+ }
2737
+ const cut = truncatedSetFields(raw)
2738
+ const saved = await commit(() => {
2739
+ state.data.sets[index].name = wanted.name
2740
+ })
2741
+ return settle('已重命名为「' + wanted.name + '」' + (cut.length > 0 ? '(名称已按长度上限截断)' : ''), saved)
2742
+ })
2743
+
2744
+ /**
2745
+ * Delete a set together with everything filed under it. Refused for the
2746
+ * ACTIVE set: deleting what is currently injected would change the prompt as
2747
+ * a side effect of a management action, so the user switches first and the
2748
+ * deletion becomes a separate, visible step.
2749
+ */
2750
+ const deleteSet = (id, baseRev) => enqueue(async () => {
2751
+ if (state.versionRejected) return versionRefusal()
2752
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
2753
+ const index = state.data.sets.findIndex((set) => set.id === id)
2754
+ if (index < 0) {
2755
+ return { ok: false, saved: false, message: '没有该配置集(可能已被其他窗口或进程删除)', snapshot: snapshot() }
2756
+ }
2757
+ if (state.data.sets.length <= 1) {
2758
+ return { ok: false, saved: false, message: '至少要保留一个配置集', snapshot: snapshot() }
2759
+ }
2760
+ if (id === state.data.activeSetId) {
2761
+ return { ok: false, saved: false, message: '不能删除当前激活的配置集;请先切换到其他配置集', snapshot: snapshot() }
2762
+ }
2763
+ const doomedEntries = state.data.entries.filter((entry) => entry.setId === id).length
2764
+ const doomedProfiles = state.data.profiles.filter((profile) => profile.setId === id).length
2765
+ const saved = await commit(() => {
2766
+ state.data.sets.splice(index, 1)
2767
+ state.data.entries = state.data.entries.filter((entry) => entry.setId !== id)
2768
+ state.data.profiles = state.data.profiles.filter((profile) => profile.setId !== id)
2769
+ })
2770
+ return settle('已删除配置集,并移除其中的 ' + doomedEntries + ' 条指令、' + doomedProfiles + ' 个用户形象', saved)
2771
+ })
2772
+
2773
+ /**
2774
+ * Make one set the active one — the single command that changes what is
2775
+ * injected. It writes (the choice survives a restart) and therefore bumps
2776
+ * `rev`, so every other open panel follows along.
2777
+ */
2778
+ const setActiveSet = (id, baseRev) => enqueue(async () => {
2779
+ if (state.versionRejected) return versionRefusal()
2780
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
2781
+ const target = state.data.sets.find((set) => set.id === id)
2782
+ if (target === undefined) {
2783
+ return { ok: false, saved: false, message: '没有该配置集(可能已被其他窗口或进程删除)', snapshot: snapshot() }
2784
+ }
2785
+ if (id === state.data.activeSetId) {
2786
+ return { ok: true, saved: false, message: null, snapshot: snapshot() }
2787
+ }
2788
+ const saved = await commit(() => {
2789
+ state.data.activeSetId = id
2790
+ })
2791
+ return settle('已切换到配置集「' + target.name + '」,注入内容已随之更新', saved)
2792
+ })
2793
+
2794
+ /**
2795
+ * Copy a set (its entries and profiles, with fresh ids) into a new one — the
2796
+ * "start from a template" flow for per-client isolation. The copy is NOT
2797
+ * activated: it is a new, untested configuration until the user switches.
2798
+ */
2799
+ const duplicateSet = (id, name, baseRev) => enqueue(async () => {
2800
+ if (state.versionRejected) return versionRefusal()
2801
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
2802
+ const source = state.data.sets.find((set) => set.id === id)
2803
+ if (source === undefined) {
2804
+ return { ok: false, saved: false, message: '没有该配置集(可能已被其他窗口或进程删除)', snapshot: snapshot() }
2805
+ }
2806
+ if (state.data.sets.length >= MAX_SETS) {
2807
+ return { ok: false, saved: false, message: '最多 ' + MAX_SETS + ' 个配置集', snapshot: snapshot() }
2808
+ }
2809
+ const wanted = singleLine(typeof name === 'string' ? name : '', MAX_SET_NAME)
2810
+ || singleLine(source.name + ' 副本', MAX_SET_NAME)
2811
+ if (state.data.sets.some((set) => set.name === wanted)) {
2812
+ return { ok: false, saved: false, message: '已存在同名配置集「' + wanted + '」', snapshot: snapshot() }
2813
+ }
2814
+ const sourceEntries = state.data.entries.filter((entry) => entry.setId === id)
2815
+ if (state.data.entries.length + sourceEntries.length > MAX_ENTRIES) {
2816
+ return {
2817
+ ok: false,
2818
+ saved: false,
2819
+ message: '复制后条目总数会超过 ' + MAX_ENTRIES + ' 条上限(当前 ' + state.data.entries.length
2820
+ + ' 条,需复制 ' + sourceEntries.length + ' 条)',
2821
+ snapshot: snapshot(),
2822
+ }
2823
+ }
2824
+ const sourceProfiles = state.data.profiles.filter((profile) => profile.setId === id)
2825
+ if (state.data.profiles.length + sourceProfiles.length > MAX_PROFILES) {
2826
+ return { ok: false, saved: false, message: '复制后用户形象总数会超过 ' + MAX_PROFILES + ' 个上限', snapshot: snapshot() }
2827
+ }
2828
+ const newId = makeSetId()
2829
+ const stamp = Date.now()
2830
+ const entries = sourceEntries.map((entry) => ({ ...entry, id: makeId(), setId: newId, updatedAt: stamp }))
2831
+ const profiles = sourceProfiles.map((profile) => ({ ...profile, id: makeId(), setId: newId, updatedAt: stamp }))
2832
+ const saved = await commit(() => {
2833
+ state.data.sets.push({ id: newId, name: wanted })
2834
+ state.data.entries.push(...entries)
2835
+ state.data.profiles.push(...profiles)
2836
+ })
2837
+ return settle('已复制为「' + wanted + '」(' + entries.length + ' 条指令、' + profiles.length + ' 个用户形象)'
2838
+ + ';点击「切换」后才会注入它', saved)
2839
+ })
2840
+
2841
+ /* ---------------- version history (F14) ---------------- */
2842
+
2843
+ /**
2844
+ * The snapshot index, newest first. Names carry the timestamp, revision and
2845
+ * device, so nothing here needs to read a file to sort; counts are read from
2846
+ * the small ones only, so a maximal store cannot make listing expensive.
2847
+ */
2848
+ const HISTORY_INDEX_MAX_BYTES = 1048576
2849
+ const historyList = () => enqueue(async () => {
2850
+ const dir = historyDirPath()
2851
+ let names = []
2852
+ try {
2853
+ names = (await readdir(dir)).filter((name) => HISTORY_FILE_PATTERN.test(name))
2854
+ } catch (err) {
2855
+ // A missing directory is not an error: nothing has been written yet.
2856
+ if (err === null || err === undefined || err.code !== 'ENOENT') {
2857
+ return { ok: false, saved: false, message: '无法读取版本历史目录:' + describeError(err), snapshot: snapshot(), history: { dir, items: [] } }
2858
+ }
2859
+ }
2860
+ const items = []
2861
+ for (const name of names) {
2862
+ const full = join(dir, name)
2863
+ let info = null
2864
+ try {
2865
+ info = await stat(full)
2866
+ } catch {
2867
+ continue
2868
+ }
2869
+ const item = {
2870
+ name,
2871
+ epoch: historyEpochOf(name),
2872
+ rev: historyRevOf(name),
2873
+ device: historyDeviceOf(name),
2874
+ foreign: historyDeviceOf(name) !== DEVICE,
2875
+ bytes: info.size,
2876
+ entries: null,
2877
+ profiles: null,
2878
+ sets: null,
2879
+ }
2880
+ if (info.size <= HISTORY_INDEX_MAX_BYTES) {
2881
+ try {
2882
+ const parsed = JSON.parse(await readFile(full, 'utf8'))
2883
+ item.entries = Array.isArray(parsed.entries) ? parsed.entries.length : 0
2884
+ item.profiles = Array.isArray(parsed.profiles) ? parsed.profiles.length : 0
2885
+ item.sets = Array.isArray(parsed.sets) ? parsed.sets.length : 1
2886
+ } catch {
2887
+ // An unreadable snapshot still lists; restore will report the detail.
2888
+ }
2889
+ }
2890
+ items.push(item)
2891
+ }
2892
+ items.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0))
2893
+ state.historyCount = items.length
2894
+ return { ok: true, saved: false, message: null, snapshot: snapshot(), history: { dir, items } }
2895
+ })
2896
+
2897
+ /**
2898
+ * Roll the store back to one snapshot. The snapshot becomes the new CURRENT
2899
+ * state and is written as a new revision (rev only ever moves forward, so a
2900
+ * client holding an older rev is refused rather than silently reverted) —
2901
+ * and, like every other write, it leaves its own snapshot behind, so the
2902
+ * rollback itself can be undone from the history list.
2903
+ */
2904
+ const historyRestore = (name, baseRev) => enqueue(async () => {
2905
+ if (state.versionRejected) return versionRefusal()
2906
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
2907
+ if (typeof name !== 'string' || !HISTORY_FILE_PATTERN.test(name)) {
2908
+ return { ok: false, saved: false, message: '历史快照名称无效', snapshot: snapshot() }
2909
+ }
2910
+ let parsed = null
2911
+ try {
2912
+ parsed = JSON.parse(await readFile(join(historyDirPath(), name), 'utf8'))
2913
+ } catch (err) {
2914
+ return { ok: false, saved: false, message: '无法读取该历史快照:' + describeError(err), snapshot: snapshot() }
2915
+ }
2916
+ const adopted = adoptParsedStore(parsed)
2917
+ if (adopted.error !== undefined) {
2918
+ return { ok: false, saved: false, message: '该历史快照不是有效的存储文件:' + adopted.error, snapshot: snapshot() }
2919
+ }
2920
+ if (adopted.rejectedVersion !== undefined) {
2921
+ return {
2922
+ ok: false,
2923
+ saved: false,
2924
+ message: '该历史快照为格式版本 ' + adopted.rejectedVersion + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
2925
+ + ';已拒绝回滚,当前内容未被修改',
2926
+ snapshot: snapshot(),
2927
+ }
2928
+ }
2929
+ const { loaded } = adopted
2930
+ const notes = []
2931
+ const warnings = loadWarnings(loaded)
2932
+ if (warnings !== null) notes.push(warnings)
2933
+ const saved = await commit(() => {
2934
+ loaded.data.rev = state.data.rev || 0
2935
+ loaded.data.backupDir = backupDirOf()
2936
+ state.data = loaded.data
2937
+ })
2938
+ const when = new Date(historyEpochOf(name)).toLocaleString()
2939
+ const head = '已回滚到 ' + when + ' 的快照(快照 rev ' + historyRevOf(name) + ',本次写为新 rev ' + (state.data.rev || 0) + '):'
2940
+ + describeStoreShape(state.data)
2941
+ return settle([head, ...notes].join(';'), saved)
2942
+ })
2943
+
2944
+ /**
2945
+ * Delete one snapshot. `saved` stays false: the STORE was not written, which
2946
+ * is exactly what the field means everywhere else.
2947
+ */
2948
+ const historyDelete = (name) => enqueue(async () => {
2949
+ if (typeof name !== 'string' || !HISTORY_FILE_PATTERN.test(name)) {
2950
+ return { ok: false, saved: false, message: '历史快照名称无效', snapshot: snapshot() }
2951
+ }
2952
+ try {
2953
+ await unlink(join(historyDirPath(), name))
2954
+ } catch (err) {
2955
+ return { ok: false, saved: false, message: '删除历史快照失败:' + describeError(err), snapshot: snapshot() }
2956
+ }
2957
+ await refreshHistoryCount()
2958
+ return { ok: true, saved: false, message: '已删除该历史快照', snapshot: snapshot() }
2959
+ })
2960
+
1915
2961
  const reload = () => enqueue(async () => {
1916
2962
  // `saved` is only true when the load itself wrote to disk (the
1917
2963
  // backup-promotion or legacy-migration branch); a plain re-read claiming
@@ -1920,6 +2966,18 @@ export function apply(ctx) {
1920
2966
  syncSection()
1921
2967
  return { ok: state.storageError === null, saved: wrote === true, message: state.storageError, snapshot: snapshot() }
1922
2968
  })
2969
+ /**
2970
+ * An export is a BACKUP, so it carries the whole store — every set, with
2971
+ * each item's `setId` — not just the active one (F13). `version` is the
2972
+ * envelope's own version; `data.version` is the store format inside it.
2973
+ */
2974
+ const exportEnvelope = () => ({
2975
+ app: 'dsh-instruction-memory',
2976
+ version: FORMAT_VERSION,
2977
+ exportedAt: new Date().toISOString(),
2978
+ data: persistableData(),
2979
+ })
2980
+
1923
2981
  const exportData = () => enqueue(async () => ({
1924
2982
  ok: true,
1925
2983
  // An export reads the store and never writes it; a truthful `saved:false`
@@ -1928,10 +2986,135 @@ export function apply(ctx) {
1928
2986
  message: null,
1929
2987
  // A self-describing envelope: re-importable by this plugin, and readable
1930
2988
  // as plain JSON by anything else.
1931
- export: { app: 'dsh-instruction-memory', version: 1, exportedAt: new Date().toISOString(), data: snapshot().data },
2989
+ export: exportEnvelope(),
1932
2990
  snapshot: snapshot(),
1933
2991
  }))
1934
2992
 
2993
+ /**
2994
+ * Write an export straight into the backup directory (F18) — the "land the
2995
+ * backup in my cloud-synced folder" button. The default directory is the
2996
+ * store directory, so this works before a custom location is configured too.
2997
+ */
2998
+ const exportToDir = () => enqueue(async () => {
2999
+ const dir = backupDirOf() !== '' ? backupDirOf() : dirname(storePath)
3000
+ const target = join(dir, exportFileName(Date.now()))
3001
+ try {
3002
+ await mkdir(dir, { recursive: true })
3003
+ await writeFile(target, JSON.stringify(exportEnvelope(), null, 2) + '\n', 'utf8')
3004
+ } catch (err) {
3005
+ return { ok: false, saved: false, message: '写入备份目录失败:' + describeError(err), snapshot: snapshot() }
3006
+ }
3007
+ return { ok: true, saved: false, message: '已写入 ' + target, snapshot: snapshot() }
3008
+ })
3009
+
3010
+ /**
3011
+ * The raw store text for 源码模式 (F16): exactly the bytes a save would write.
3012
+ * Editing it is therefore equivalent to editing memory.json — but it goes
3013
+ * through the same loader, version gate, disclosures and rev check as every
3014
+ * other write instead of being trusted because it came from the UI.
3015
+ */
3016
+ const readSource = () => enqueue(async () => {
3017
+ /**
3018
+ * While the file on disk declares an unsupported version, the in-memory
3019
+ * state is a placeholder (nothing was adopted), so handing it out as "the
3020
+ * source" would show an EMPTY store and invite the user to save it over
3021
+ * their real file. The documented way out of that state is to fix the file
3022
+ * (or upgrade the plugin) and hit 重新载入 — so say that instead.
3023
+ */
3024
+ if (state.versionRejected) {
3025
+ return {
3026
+ ok: false,
3027
+ saved: false,
3028
+ message: '存储文件为不支持的格式版本 ' + state.rejectedVersion + ',源码模式已暂停:请先用编辑器修改 '
3029
+ + storePath + '(或升级插件)后点「重新载入」',
3030
+ snapshot: snapshot(),
3031
+ }
3032
+ }
3033
+ // Opening source mode is a deliberate user action, so it is also allowed to
3034
+ // materialise a first-run store (same rule as the `state` method).
3035
+ const materialized = await ensureStoreMaterialized()
3036
+ return { ok: true, saved: materialized === true, message: null, source: serialize(), snapshot: snapshot() }
3037
+ })
3038
+
3039
+ /**
3040
+ * The shared core of 源码保存 (F16) and 整体替换恢复 (F14): adopt a whole
3041
+ * parsed store and commit it in one transaction, reporting every correction
3042
+ * the loader had to make. Callers add their own opening line.
3043
+ */
3044
+ const commitAdoptedStore = async (parsed, head) => {
3045
+ const adopted = adoptParsedStore(parsed)
3046
+ if (adopted.error !== undefined) {
3047
+ return { ok: false, saved: false, message: adopted.error, snapshot: snapshot() }
3048
+ }
3049
+ if (adopted.rejectedVersion !== undefined) {
3050
+ return {
3051
+ ok: false,
3052
+ saved: false,
3053
+ message: '内容声明为格式版本 ' + adopted.rejectedVersion + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
3054
+ + ';已拒绝保存,当前内容未被修改',
3055
+ snapshot: snapshot(),
3056
+ }
3057
+ }
3058
+ const { loaded } = adopted
3059
+ const notes = []
3060
+ const warnings = loadWarnings(loaded)
3061
+ if (warnings !== null) notes.push(warnings)
3062
+ const truncation = truncationNotes(parsed)
3063
+ if (truncation !== null) notes.push(truncation)
3064
+ const saved = await commit(() => {
3065
+ // rev only ever moves forward: a restore is a NEW revision of the store,
3066
+ // so a client holding an older one is refused rather than silently
3067
+ // reverted (and `baseRev` conflict handling stays meaningful).
3068
+ loaded.data.rev = state.data.rev || 0
3069
+ loaded.data.backupDir = backupDirOf()
3070
+ state.data = loaded.data
3071
+ })
3072
+ return settle([head + describeStoreShape(state.data), ...notes].join(';'), saved)
3073
+ }
3074
+
3075
+ const writeSource = (text, baseRev) => enqueue(async () => {
3076
+ if (state.versionRejected) return versionRefusal()
3077
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
3078
+ if (typeof text !== 'string') {
3079
+ return { ok: false, saved: false, message: '源码必须是 JSON 文本', snapshot: snapshot() }
3080
+ }
3081
+ if (text.length > MAX_BODY_CHARS) {
3082
+ return {
3083
+ ok: false,
3084
+ saved: false,
3085
+ message: '源码过长:' + text.length + ' 字符,上限 ' + MAX_BODY_CHARS + ' 字符',
3086
+ snapshot: snapshot(),
3087
+ }
3088
+ }
3089
+ let parsed = null
3090
+ try {
3091
+ parsed = JSON.parse(text)
3092
+ } catch (err) {
3093
+ return { ok: false, saved: false, message: '源码不是有效的 JSON:' + describeError(err), snapshot: snapshot() }
3094
+ }
3095
+ return commitAdoptedStore(parsed, '已按源码保存:')
3096
+ })
3097
+
3098
+ /**
3099
+ * Replace the whole store from a backup file — the "用备份整体替换" door
3100
+ * (F14). Import merges by design and can never restore an older whole-store
3101
+ * state; this is that missing path, and it is explicit: the panel confirms
3102
+ * first, and nothing is unrecoverable (the replaced content stays in
3103
+ * memory.json.bak and, whenever history is writable, in the version list).
3104
+ */
3105
+ const restoreData = (payload, baseRev) => enqueue(async () => {
3106
+ if (state.versionRejected) return versionRefusal()
3107
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
3108
+ if (payload === null || typeof payload !== 'object') {
3109
+ return { ok: false, saved: false, message: '恢复内容不是有效的 JSON 对象', snapshot: snapshot() }
3110
+ }
3111
+ // Accept our own export envelope as well as a bare store object.
3112
+ const inner = payload.data !== null && typeof payload.data === 'object' && !Array.isArray(payload.data)
3113
+ ? payload.data
3114
+ : payload
3115
+ return commitAdoptedStore(inner, '已用备份整体替换当前记忆:')
3116
+ })
3117
+
1935
3118
  const importData = (payload, baseRev) => enqueue(async () => {
1936
3119
  if (state.versionRejected) return versionRefusal()
1937
3120
  if (isStaleBaseRev(baseRev)) return conflictResponse()
@@ -1945,26 +3128,56 @@ export function apply(ctx) {
1945
3128
  : (Array.isArray(inner.entries) ? inner.entries : null)
1946
3129
  const rawProfiles = Array.isArray(payload.profiles) ? payload.profiles
1947
3130
  : (Array.isArray(inner.profiles) ? inner.profiles : null)
3131
+ // Our own export also carries the set list, which is what lets an import
3132
+ // recreate the sets its items belong to instead of dumping everything into
3133
+ // the active one.
3134
+ const rawSets = Array.isArray(payload.sets) ? payload.sets
3135
+ : (Array.isArray(inner.sets) ? inner.sets : null)
1948
3136
  if ((rawEntries === null || rawEntries.length === 0)
1949
3137
  && (rawProfiles === null || rawProfiles.length === 0)) {
1950
3138
  return { ok: false, saved: false, message: '导入文件中没有找到条目或用户形象', snapshot: snapshot() }
1951
3139
  }
3140
+ /**
3141
+ * Set mapping (F13). An imported item is filed under the set it names when
3142
+ * that set already exists here, or when the payload DECLARES it (our own
3143
+ * export carries the sets array) and there is room to create it. Anything
3144
+ * else lands in the active set, and how many did so is disclosed — the
3145
+ * alternative, dropping items whose set is unknown, would be data loss.
3146
+ */
3147
+ const localSetIds = new Set(state.data.sets.map((set) => set.id))
3148
+ const declaredSets = []
3149
+ const declaredIds = new Set()
3150
+ for (const candidate of (Array.isArray(rawSets) ? rawSets : [])) {
3151
+ const set = sanitizeSet(candidate)
3152
+ if (set === null || localSetIds.has(set.id) || declaredIds.has(set.id)) continue
3153
+ declaredIds.add(set.id)
3154
+ declaredSets.push(set)
3155
+ }
3156
+ const activeId = state.data.activeSetId
1952
3157
  // Merge, never replace: importing must not be able to wipe what is here.
1953
- // Duplicates (same id, or same title+content) and invalid items are
1954
- // skipped and counted, not silently dropped.
3158
+ // Duplicates (same id within the same set, or same title+content within the
3159
+ // same set) and invalid items are skipped and counted, not silently
3160
+ // dropped. De-duplicating per set is what makes re-importing idempotent
3161
+ // while still allowing the same instruction to exist in two sets.
1955
3162
  //
1956
3163
  // The whole plan is computed BEFORE the store is touched, so the merge and
1957
3164
  // its counters cannot disagree with what actually gets appended.
1958
3165
  const existing = state.data.entries
1959
- const seenIds = new Set(existing.map((entry) => entry.id))
1960
- const seenBodies = new Set(existing.map((entry) => entry.title + '\u0000' + entry.content))
3166
+ const bodyKey = (setId, title, content) => setId + '\u0000' + title + '\u0000' + content
3167
+ const seenIds = new Set(existing.map((entry) => entry.setId + '\u0000' + entry.id))
3168
+ const seenBodies = new Set(existing.map((entry) => bodyKey(entry.setId, entry.title, entry.content)))
1961
3169
  const accepted = []
1962
3170
  const cutFields = new Set()
1963
3171
  let truncatedCount = 0
1964
3172
  let skipped = 0
1965
3173
  for (const candidate of (rawEntries || [])) {
1966
3174
  const entry = sanitizeEntry(candidate)
1967
- if (entry === null || seenIds.has(entry.id) || seenBodies.has(entry.title + '\u0000' + entry.content)) {
3175
+ if (entry === null) {
3176
+ skipped += 1
3177
+ continue
3178
+ }
3179
+ const wanted = entry.setId !== '' ? entry.setId : activeId
3180
+ if (seenIds.has(wanted + '\u0000' + entry.id) || seenBodies.has(bodyKey(wanted, entry.title, entry.content))) {
1968
3181
  skipped += 1
1969
3182
  continue
1970
3183
  }
@@ -1982,25 +3195,29 @@ export function apply(ctx) {
1982
3195
  for (const field of cut) cutFields.add(field)
1983
3196
  }
1984
3197
  entry.updatedAt = Date.now()
1985
- accepted.push(entry)
1986
- seenIds.add(entry.id)
1987
- seenBodies.add(entry.title + '\u0000' + entry.content)
3198
+ accepted.push({ entry, wanted })
3199
+ seenIds.add(wanted + '\u0000' + entry.id)
3200
+ seenBodies.add(bodyKey(wanted, entry.title, entry.content))
1988
3201
  }
1989
3202
  // Persona profiles merge under the same rules (same id, or same
1990
3203
  // name+content) in the SAME transaction, so a mixed import never lands
1991
3204
  // half-way.
1992
3205
  const existingProfiles = state.data.profiles
1993
- const seenProfileIds = new Set(existingProfiles.map((profile) => profile.id))
1994
- const seenProfileBodies = new Set(existingProfiles.map((profile) => profile.name + '\u0000' + profile.content))
3206
+ const seenProfileIds = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.id))
3207
+ const seenProfileBodies = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.name + '\u0000' + profile.content))
1995
3208
  const acceptedProfiles = []
1996
3209
  const cutProfileFields = new Set()
1997
3210
  let truncatedProfileCount = 0
1998
3211
  let skippedProfiles = 0
1999
3212
  for (const candidate of (rawProfiles || [])) {
2000
3213
  const profile = sanitizeProfile(candidate)
2001
- if (profile === null
2002
- || seenProfileIds.has(profile.id)
2003
- || seenProfileBodies.has(profile.name + '\u0000' + profile.content)) {
3214
+ if (profile === null) {
3215
+ skippedProfiles += 1
3216
+ continue
3217
+ }
3218
+ const wanted = profile.setId !== '' ? profile.setId : activeId
3219
+ if (seenProfileIds.has(wanted + '\u0000' + profile.id)
3220
+ || seenProfileBodies.has(wanted + '\u0000' + profile.name + '\u0000' + profile.content)) {
2004
3221
  skippedProfiles += 1
2005
3222
  continue
2006
3223
  }
@@ -2014,9 +3231,9 @@ export function apply(ctx) {
2014
3231
  for (const field of cut) cutProfileFields.add(field)
2015
3232
  }
2016
3233
  profile.updatedAt = Date.now()
2017
- acceptedProfiles.push(profile)
2018
- seenProfileIds.add(profile.id)
2019
- seenProfileBodies.add(profile.name + '\u0000' + profile.content)
3234
+ acceptedProfiles.push({ profile, wanted })
3235
+ seenProfileIds.add(wanted + '\u0000' + profile.id)
3236
+ seenProfileBodies.add(wanted + '\u0000' + profile.name + '\u0000' + profile.content)
2020
3237
  }
2021
3238
  if (accepted.length === 0 && acceptedProfiles.length === 0) {
2022
3239
  // Nothing was written to disk, so claiming saved:true would be a lie the
@@ -2028,11 +3245,45 @@ export function apply(ctx) {
2028
3245
  snapshot: snapshot(),
2029
3246
  }
2030
3247
  }
3248
+ // Create only the declared sets that the accepted items actually reference,
3249
+ // in declaration order, while there is room.
3250
+ const wantedIds = []
3251
+ for (const item of [...accepted, ...acceptedProfiles]) {
3252
+ if (!localSetIds.has(item.wanted) && !wantedIds.includes(item.wanted)) wantedIds.push(item.wanted)
3253
+ }
3254
+ const createdSets = []
3255
+ const createdIds = new Set()
3256
+ for (const id of wantedIds) {
3257
+ if (state.data.sets.length + createdSets.length >= MAX_SETS) break
3258
+ const declared = declaredSets.find((set) => set.id === id)
3259
+ if (declared === undefined) continue
3260
+ createdSets.push({ id: declared.id, name: declared.name })
3261
+ createdIds.add(declared.id)
3262
+ }
3263
+ let fellBack = 0
3264
+ const finalIdOf = (wanted) => {
3265
+ if (localSetIds.has(wanted) || createdIds.has(wanted)) return wanted
3266
+ fellBack += 1
3267
+ return activeId
3268
+ }
2031
3269
  const saved = await commit(() => {
2032
- state.data.entries.push(...accepted)
2033
- state.data.profiles.push(...acceptedProfiles)
3270
+ state.data.sets.push(...createdSets)
3271
+ for (const item of accepted) {
3272
+ item.entry.setId = finalIdOf(item.wanted)
3273
+ state.data.entries.push(item.entry)
3274
+ }
3275
+ for (const item of acceptedProfiles) {
3276
+ item.profile.setId = finalIdOf(item.wanted)
3277
+ state.data.profiles.push(item.profile)
3278
+ }
2034
3279
  })
2035
3280
  const parts = []
3281
+ if (createdSets.length > 0) {
3282
+ parts.push('新建配置集 ' + createdSets.length + ' 个(' + createdSets.map((set) => '「' + set.name + '」').join('、') + ')')
3283
+ }
3284
+ if (fellBack > 0) {
3285
+ parts.push('有 ' + fellBack + ' 项因原配置集不存在或已达 ' + MAX_SETS + ' 个上限,已归入当前配置集')
3286
+ }
2036
3287
  if (accepted.length > 0) {
2037
3288
  const truncated = truncatedCount > 0
2038
3289
  ? ';其中 ' + truncatedCount + ' 条超过长度上限,' + [...cutFields].join('、') + '已被截断'
@@ -2078,6 +3329,43 @@ export function apply(ctx) {
2078
3329
  args !== null && typeof args === 'object' ? args.baseRev : undefined)
2079
3330
  case 'set-options':
2080
3331
  return setOptions(args, args !== null && typeof args === 'object' ? args.baseRev : undefined)
3332
+ case 'save-set':
3333
+ return saveSet(
3334
+ args !== null && typeof args === 'object' ? args.set : null,
3335
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
3336
+ case 'delete-set':
3337
+ return deleteSet(
3338
+ args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
3339
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
3340
+ case 'set-active-set':
3341
+ return setActiveSet(
3342
+ args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
3343
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
3344
+ case 'duplicate-set':
3345
+ return duplicateSet(
3346
+ args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
3347
+ args !== null && typeof args === 'object' ? args.name : undefined,
3348
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
3349
+ case 'history-list':
3350
+ return historyList()
3351
+ case 'history-restore':
3352
+ return historyRestore(
3353
+ args !== null && typeof args === 'object' && typeof args.name === 'string' ? args.name : '',
3354
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
3355
+ case 'history-delete':
3356
+ return historyDelete(args !== null && typeof args === 'object' && typeof args.name === 'string' ? args.name : '')
3357
+ case 'read-source':
3358
+ return readSource()
3359
+ case 'write-source':
3360
+ return writeSource(
3361
+ args !== null && typeof args === 'object' ? args.text : null,
3362
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
3363
+ case 'restore-data':
3364
+ return restoreData(
3365
+ args !== null && typeof args === 'object' ? args.payload : null,
3366
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
3367
+ case 'export-to-dir':
3368
+ return exportToDir()
2081
3369
  case 'reload':
2082
3370
  return reload()
2083
3371
  case 'export-data':