dsh-instruction-memory 1.1.0 → 1.3.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,40 @@ 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
+ /**
88
+ * F7: the longest single pause. Doubles as the setTimeout guard — 7 days is
89
+ * far below the 2^31-1 ms (≈24.8 days) timer ceiling, so the delay never
90
+ * overflows Node's timer range.
91
+ */
92
+ const MAX_MUTE_MS = 7 * 24 * 60 * 60 * 1000
93
+ const HISTORY_DIR_NAME = 'history'
94
+ /** Name of the JSON Schema shipped with the package and copied next to the store. */
95
+ const SCHEMA_FILE_NAME = 'memory.schema.json'
96
+ /** The `$schema` value written into every store file (resolves next to it). */
97
+ const SCHEMA_REF = './' + SCHEMA_FILE_NAME
98
+ /** Upper bound for a user-chosen backup directory path. */
99
+ const MAX_BACKUP_DIR = 512
100
+ const BACKUP_FILE_SUFFIX = '.bak'
51
101
  /**
52
102
  * Placement of the injected instruction block among the system prompt's
53
103
  * ordered sections. Sections are concatenated ascending; equal orders fall
@@ -128,7 +178,33 @@ const BATCH_OPS = ['set-enabled', 'set-priority', 'add-tags', 'remove-tags', 'de
128
178
  * wrapper.
129
179
  */
130
180
  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
181
+ + MAX_PROFILES * (MAX_PROFILE_CONTENT + MAX_PROFILE_NAME + 200)
182
+ + MAX_SETS * (MAX_SET_NAME + 200)) * 2 + 100_000
183
+
184
+ /**
185
+ * Every numeric ceiling in one object, published on the snapshot and used by
186
+ * verify.mjs to pin `memory.schema.json` (the hand-edit JSON Schema) to the
187
+ * code. Two sources of truth that must never drift: a schema advertising a
188
+ * larger limit than the loader enforces would let a hand edit be silently
189
+ * truncated while the IDE reported it as valid.
190
+ */
191
+ export const LIMITS = Object.freeze({
192
+ formatVersion: FORMAT_VERSION,
193
+ maxEntries: MAX_ENTRIES,
194
+ maxTitle: MAX_TITLE,
195
+ maxContent: MAX_CONTENT,
196
+ maxWhen: MAX_WHEN,
197
+ maxTagsPerEntry: MAX_TAGS_PER_ENTRY,
198
+ maxTagLength: MAX_TAG_LENGTH,
199
+ maxProfiles: MAX_PROFILES,
200
+ maxProfileName: MAX_PROFILE_NAME,
201
+ maxProfileContent: MAX_PROFILE_CONTENT,
202
+ maxSets: MAX_SETS,
203
+ maxSetName: MAX_SET_NAME,
204
+ minBudget: MIN_BUDGET,
205
+ maxBudget: MAX_BUDGET,
206
+ maxHistory: MAX_HISTORY,
207
+ })
132
208
 
133
209
  /** How long a crashed holder's `memory.json.lock` may block writes before it is stolen. */
134
210
  const LOCK_STALE_MS = 10_000
@@ -174,6 +250,10 @@ function makeId() {
174
250
  return 'im-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8)
175
251
  }
176
252
 
253
+ function makeSetId() {
254
+ return 'st-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8)
255
+ }
256
+
177
257
  function priorityLabel(value) {
178
258
  if (value === 2) return '高'
179
259
  if (value === 0) return '低'
@@ -222,10 +302,40 @@ export function sanitizeEntry(raw) {
222
302
  priority: raw.priority === 2 || raw.priority === 0 ? raw.priority : 1,
223
303
  enabled: raw.enabled !== false,
224
304
  tags: sanitizeTags(raw.tags),
305
+ /**
306
+ * Which instruction set (F13) this entry belongs to. Preserved here — NOT
307
+ * re-derived — because both the loader and the importer need to honour a
308
+ * declared membership; the interactive save path overwrites it with the
309
+ * active set instead (an edit can never move an entry between sets).
310
+ * `normalizeData` guarantees a valid value on everything this plugin
311
+ * serves, so a blank/absent id on a hand-built object means "unattributed"
312
+ * (see inScope), not "no set".
313
+ */
314
+ setId: typeof raw.setId === 'string' && raw.setId !== '' ? raw.setId.slice(0, 64) : '',
225
315
  updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
226
316
  }
227
317
  }
228
318
 
319
+ /**
320
+ * Normalise one instruction set (F13).
321
+ *
322
+ * Shape: { id, name }. A set carries no content of its own — entries and
323
+ * user profiles point AT it through `setId` — so "switching the active set"
324
+ * never moves a byte of user text, which is what makes the operation safe to
325
+ * perform in one transaction. `id` is stable (it is what every item stores);
326
+ * `name` is a short single-line label. A blank name is rejected (null): unlike
327
+ * an entry, a set has nothing else to derive a label from.
328
+ */
329
+ export function sanitizeSet(raw) {
330
+ if (raw === null || typeof raw !== 'object') return null
331
+ const name = singleLine(raw.name, MAX_SET_NAME)
332
+ if (name === '') return null
333
+ return {
334
+ id: typeof raw.id === 'string' && raw.id !== '' ? raw.id.slice(0, 64) : makeSetId(),
335
+ name,
336
+ }
337
+ }
338
+
229
339
  /**
230
340
  * Normalise one "用户形象" (a fact about the USER, not a role for the model).
231
341
  *
@@ -246,10 +356,124 @@ export function sanitizeProfile(raw) {
246
356
  name: name === '' ? singleLine(content, 20) : name,
247
357
  content,
248
358
  enabled: raw.enabled !== false,
359
+ /** Instruction set membership (F13); see the note in sanitizeEntry. */
360
+ setId: typeof raw.setId === 'string' && raw.setId !== '' ? raw.setId.slice(0, 64) : '',
249
361
  updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
250
362
  }
251
363
  }
252
364
 
365
+ /**
366
+ * The scope decision behind everything that renders or lists items: with a
367
+ * usable `sets` list plus a matching `activeSetId`, only the active set is in
368
+ * scope; without them the store is the legacy single-set shape and everything
369
+ * is in scope.
370
+ *
371
+ * Exported because THREE places must never disagree — the injection planner,
372
+ * the snapshot the settings page renders, and the commands that edit — and a
373
+ * missed filter here is exactly how a 某客户 set would leak into a 个人
374
+ * conversation. One predicate, used three times.
375
+ *
376
+ * The fallback when `activeSetId` names nothing (a hand-edited file): the
377
+ * FIRST set, which is also what normalizeData adopts. That keeps a pure
378
+ * consumer (planInjection called directly, as the tests do) in agreement with
379
+ * the loader instead of inventing a third behaviour.
380
+ */
381
+ export function activeScope(data) {
382
+ if (data === null || typeof data !== 'object') return { scoped: false, activeId: '', ids: [] }
383
+ const sets = (Array.isArray(data.sets) ? data.sets : [])
384
+ .filter((set) => set !== null && typeof set === 'object' && typeof set.id === 'string' && set.id !== '')
385
+ if (sets.length === 0) return { scoped: false, activeId: '', ids: [] }
386
+ const wanted = typeof data.activeSetId === 'string' && data.activeSetId !== '' ? data.activeSetId : ''
387
+ const activeId = sets.some((set) => set.id === wanted) ? wanted : sets[0].id
388
+ return { scoped: true, activeId, ids: sets.map((set) => set.id) }
389
+ }
390
+
391
+ /**
392
+ * Whether one entry/profile takes part under a scope from activeScope().
393
+ * An item with no `setId` counts as belonging to the active set, mirroring
394
+ * the loader's stamping rule — an unattributed item must follow the user's
395
+ * current context rather than disappear from both the list and the prompt.
396
+ * The comparison is exact and case-sensitive: set ids are minted here.
397
+ */
398
+ export function inScope(item, scope) {
399
+ if (scope === null || typeof scope !== 'object' || scope.scoped !== true) return true
400
+ const id = item !== null && typeof item === 'object' && typeof item.setId === 'string' ? item.setId : ''
401
+ return id === '' || id === scope.activeId
402
+ }
403
+
404
+ /* ------------------------------------------------------------------ *
405
+ * History snapshot naming (F14) — pure, so the format is pinnable
406
+ * ------------------------------------------------------------------ */
407
+
408
+ /**
409
+ * A filesystem-safe UTC stamp: `20261015T072355123Z`. Fixed width, so plain
410
+ * lexicographic sorting on a filename is chronological sorting — which is what
411
+ * lets the history index sort without reading a single file.
412
+ */
413
+ export function historyStamp(epoch) {
414
+ const date = new Date(epoch)
415
+ const pad = (value, width) => String(value).padStart(width, '0')
416
+ return pad(date.getUTCFullYear(), 4) + pad(date.getUTCMonth() + 1, 2) + pad(date.getUTCDate(), 2)
417
+ + 'T' + pad(date.getUTCHours(), 2) + pad(date.getUTCMinutes(), 2) + pad(date.getUTCSeconds(), 2)
418
+ + pad(date.getUTCMilliseconds(), 3) + 'Z'
419
+ }
420
+
421
+ /** This machine's short tag, used so two devices sharing one backup folder stay distinguishable. */
422
+ export function deviceTag(rawHostname) {
423
+ const source = typeof rawHostname === 'string' && rawHostname !== '' ? rawHostname : 'unknown'
424
+ const tag = source.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 16)
425
+ return tag === '' ? 'unknown' : tag
426
+ }
427
+
428
+ /**
429
+ * `20261015T072355123Z-r12-laptop.json` — stamp, revision, writing device.
430
+ * The revision is IN the name so a snapshot is identifiable in a plain file
431
+ * listing, and the device tag is sanitised to `[a-z0-9_]` so it can never be
432
+ * confused with the separators.
433
+ */
434
+ export function historyFileName(epoch, rev, device) {
435
+ return historyStamp(epoch) + '-r' + (Number.isFinite(rev) ? rev : 0) + '-' + device + '.json'
436
+ }
437
+
438
+ /**
439
+ * What counts as one of OUR snapshots, and what we are therefore allowed to
440
+ * prune or delete. Anything else in the history directory — a hand-made file,
441
+ * another tool's backup, a cloud client's conflict copy — fails this pattern
442
+ * and is left strictly alone.
443
+ */
444
+ export const HISTORY_FILE_PATTERN = /^\d{8}T\d{9}Z-r\d+-[a-z0-9_]+(?:\.json)$/
445
+
446
+ /** The device tag inside a snapshot name, or '' when the name is not ours. */
447
+ export function historyDeviceOf(name) {
448
+ if (typeof name !== 'string') return ''
449
+ const match = /-r\d+-([a-z0-9_]+)\.json$/.exec(name)
450
+ return match === null ? '' : match[1]
451
+ }
452
+
453
+ /** The revision inside a snapshot name, or 0 when the name is not ours. */
454
+ export function historyRevOf(name) {
455
+ if (typeof name !== 'string') return 0
456
+ const match = /-r(\d+)-[a-z0-9_]+\.json$/.exec(name)
457
+ return match === null ? 0 : Number(match[1])
458
+ }
459
+
460
+ /** The epoch (ms) from a snapshot name's UTC stamp, or 0 when it is not ours. */
461
+ export function historyEpochOf(name) {
462
+ if (typeof name !== 'string') return 0
463
+ const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z-r/.exec(name)
464
+ if (match === null) return 0
465
+ return Date.UTC(
466
+ Number(match[1]), Number(match[2]) - 1, Number(match[3]),
467
+ Number(match[4]), Number(match[5]), Number(match[6]), Number(match[7]),
468
+ )
469
+ }
470
+
471
+ /** Exported for the settings page: the stamp a manual export file carries. */
472
+ export function exportFileName(epoch) {
473
+ const stamp = historyStamp(epoch)
474
+ return 'dsh-instruction-memory-' + stamp.slice(0, 8) + '-' + stamp.slice(9, 15) + 'Z.json'
475
+ }
476
+
253
477
  /**
254
478
  * Which fields of a raw entry had to be cut to fit the stored limits.
255
479
  *
@@ -282,6 +506,15 @@ export function truncatedProfileFields(raw) {
282
506
  return cut
283
507
  }
284
508
 
509
+ /** Same disclosure, for an instruction set's name. */
510
+ export function truncatedSetFields(raw) {
511
+ if (raw === null || typeof raw !== 'object') return []
512
+ const unlimited = Number.MAX_SAFE_INTEGER
513
+ return typeof raw.name === 'string' && singleLine(raw.name, unlimited) !== singleLine(raw.name, MAX_SET_NAME)
514
+ ? ['配置集名称']
515
+ : []
516
+ }
517
+
285
518
  /** JSON.stringify keeps quotes/type visible; the String() tail is for values JSON cannot encode. */
286
519
  const describeValue = (value) => {
287
520
  const text = JSON.stringify(value)
@@ -300,24 +533,43 @@ const describeValue = (value) => {
300
533
  * snapshot (another window, another process) can be refused instead of
301
534
  * silently overwriting it. Missing (pre-rev files) reads as 0.
302
535
  *
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.
536
+ * Format versions: 1 and 2 both load; anything else numeric sets
537
+ * `rejectedVersion` and leaves the data untouched — a future format's fields
538
+ * cannot be best-effort parsed, and adopting a misread would let the next save
539
+ * rewrite the file in the wrong shape. A MISSING version still loads as v1 —
540
+ * the field has been written as 1 since the first release, so absence is an
541
+ * old/hand-made file, not a foreign format.
542
+ *
543
+ * v1 → v2 (F13) is fully automatic and never loses an item: with no usable
544
+ * `sets` list the store gets one 默认 set, and every entry/profile is stamped
545
+ * with its id. Items already naming a set that does not exist are re-attributed
546
+ * to the active set and COUNTED (`reattributed`), because that is a hand edit
547
+ * this loader had to overrule and the user must be told.
309
548
  *
310
549
  * `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.
550
+ * (a non-boolean `enabled`, an out-of-range or non-numeric `budgetChars`, a
551
+ * non-string/over-long `backupDir`, an `activeSetId` naming no set). Each is a
552
+ * human-readable "field 原值 → 修正值 (原因)" string, surfaced the same way as
553
+ * `dropped`: the next save persists the corrected values, so the user must be
554
+ * told their hand edit did not survive verbatim.
315
555
  */
316
556
  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 }
557
+ const data = {
558
+ version: FORMAT_VERSION,
559
+ rev: 0,
560
+ enabled: true,
561
+ disabledUntil: 0,
562
+ budgetChars: DEFAULT_BUDGET,
563
+ backupDir: '',
564
+ activeSetId: DEFAULT_SET_ID,
565
+ sets: [],
566
+ entries: [],
567
+ profiles: [],
568
+ }
569
+ const loaded = { data, dropped: 0, droppedProfiles: 0, droppedSets: 0, reattributed: 0, optionFixes: [], formatNote: null }
570
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return loaded
571
+ if (typeof raw.version === 'number' && !SUPPORTED_VERSIONS.includes(raw.version)) {
572
+ return { data, dropped: 0, droppedProfiles: 0, droppedSets: 0, reattributed: 0, optionFixes: [], rejectedVersion: raw.version }
321
573
  }
322
574
  if (typeof raw.rev === 'number' && Number.isFinite(raw.rev) && raw.rev >= 0) {
323
575
  data.rev = Math.round(raw.rev)
@@ -325,7 +577,7 @@ function normalizeData(raw) {
325
577
  // Hand-edited options the loader has to correct are disclosed, not silently
326
578
  // adopted: the next save would otherwise rewrite the file with the fixed
327
579
  // values and the user would never learn their edit had been ignored.
328
- const optionFixes = []
580
+ const optionFixes = loaded.optionFixes
329
581
  if (typeof raw.enabled === 'boolean') {
330
582
  data.enabled = raw.enabled
331
583
  } else if ('enabled' in raw) {
@@ -340,21 +592,99 @@ function normalizeData(raw) {
340
592
  }
341
593
  data.budgetChars = clamped
342
594
  }
595
+ // Custom backup location (F18). A path is machine-local data, so anything
596
+ // unusable is dropped rather than guessed at — with a disclosure, because a
597
+ // silently ignored path would look like "backups point there" while they do
598
+ // not.
599
+ if (typeof raw.backupDir === 'string') {
600
+ const wanted = raw.backupDir.trim()
601
+ if (wanted.length > MAX_BACKUP_DIR) {
602
+ optionFixes.push('backupDir 超过 ' + MAX_BACKUP_DIR + ' 字符上限 → 已清空')
603
+ } else {
604
+ data.backupDir = wanted
605
+ }
606
+ } else if ('backupDir' in raw && raw.backupDir !== null && raw.backupDir !== '') {
607
+ optionFixes.push('backupDir ' + describeValue(raw.backupDir) + ' → ""(需要字符串路径)')
608
+ }
609
+ // F7 temporary mute. The field is OPTIONAL (the format stays v2): missing
610
+ // means "not paused" and is adopted silently, exactly like a v1 file without
611
+ // sets. Present but not a usable instant is reset to 0 with a disclosure,
612
+ // like every other hand-edited option.
613
+ if ('disabledUntil' in raw) {
614
+ const muteValue = raw.disabledUntil
615
+ if (typeof muteValue === 'number' && Number.isFinite(muteValue) && muteValue >= 0) {
616
+ data.disabledUntil = Math.round(muteValue)
617
+ } else {
618
+ optionFixes.push('disabledUntil ' + describeValue(muteValue) + ' → 0(需要 ≥ 0 的 epoch 毫秒数,0 表示未暂停)')
619
+ }
620
+ }
621
+
622
+ // Instruction sets (F13). A missing/blank list means a v1 file (or a hand
623
+ // edit that removed it): synthesise the single 默认 set and record the fact,
624
+ // so the panel can say what happened instead of appearing to have invented a
625
+ // set out of nowhere.
626
+ const rawSets = Array.isArray(raw.sets) ? raw.sets : []
627
+ const seenSetIds = new Set()
628
+ let synthesized = false
629
+ for (const candidate of rawSets) {
630
+ const set = sanitizeSet(candidate)
631
+ if (set === null || data.sets.length >= MAX_SETS) {
632
+ loaded.droppedSets += 1
633
+ continue
634
+ }
635
+ if (seenSetIds.has(set.id)) set.id = makeSetId()
636
+ seenSetIds.add(set.id)
637
+ data.sets.push(set)
638
+ }
639
+ if (data.sets.length === 0) {
640
+ data.sets.push({ id: DEFAULT_SET_ID, name: DEFAULT_SET_NAME })
641
+ synthesized = true
642
+ }
643
+ const rawActive = typeof raw.activeSetId === 'string' ? raw.activeSetId.trim() : ''
644
+ if (rawActive !== '' && data.sets.some((set) => set.id === rawActive)) {
645
+ data.activeSetId = rawActive
646
+ } else {
647
+ data.activeSetId = data.sets[0].id
648
+ if (rawActive !== '') {
649
+ optionFixes.push('activeSetId ' + describeValue(raw.activeSetId) + ' → ' + data.activeSetId + '(没有该配置集)')
650
+ } else if ('activeSetId' in raw && raw.activeSetId !== null) {
651
+ optionFixes.push('activeSetId ' + describeValue(raw.activeSetId) + ' → ' + data.activeSetId + '(需要配置集 id 字符串)')
652
+ }
653
+ }
654
+ const knownSet = (id) => data.sets.some((set) => set.id === id)
655
+ // A silent load is the normal path on a current-format file; everything else
656
+ // is stated outright, because both branches change what the NEXT save writes.
657
+ const declaredVersion = typeof raw.version === 'number' ? raw.version : null
658
+ if (synthesized) {
659
+ loaded.formatNote = '存储文件未包含配置集信息(' + (declaredVersion !== null ? '格式版本 ' + declaredVersion : '未声明格式版本')
660
+ + '),其中的条目与形象已归入「' + DEFAULT_SET_NAME + '」配置集;下次保存时文件将写为格式版本 ' + FORMAT_VERSION
661
+ } else if (declaredVersion !== FORMAT_VERSION) {
662
+ loaded.formatNote = declaredVersion === null
663
+ ? '存储文件未声明格式版本,已按格式版本 ' + FORMAT_VERSION + ' 载入;下次保存时文件将补写版本号'
664
+ : '存储文件为旧版格式(版本 ' + declaredVersion + '),已按格式版本 ' + FORMAT_VERSION + ' 载入;下次保存时文件将写为版本 ' + FORMAT_VERSION
665
+ }
666
+
343
667
  const list = Array.isArray(raw.entries) ? raw.entries : []
344
668
  const seen = new Set()
345
- let dropped = 0
346
669
  for (const candidate of list) {
347
670
  const entry = sanitizeEntry(candidate)
348
671
  if (entry === null) {
349
- dropped += 1
672
+ loaded.dropped += 1
350
673
  continue
351
674
  }
352
675
  if (data.entries.length >= MAX_ENTRIES) {
353
- dropped += 1
676
+ loaded.dropped += 1
354
677
  continue
355
678
  }
356
679
  if (seen.has(entry.id)) entry.id = makeId()
357
680
  seen.add(entry.id)
681
+ if (entry.setId === '' || !knownSet(entry.setId)) {
682
+ // A blank setId is simply an unfiled item (v1 file, hand-added entry) and
683
+ // files silently; a NON-blank id naming a set that does not exist is a
684
+ // broken reference the loader overrules, and that is counted.
685
+ if (entry.setId !== '') loaded.reattributed += 1
686
+ entry.setId = data.activeSetId
687
+ }
358
688
  data.entries.push(entry)
359
689
  }
360
690
  // User profiles ("用户形象") follow the same tolerant rules: a missing
@@ -363,18 +693,21 @@ function normalizeData(raw) {
363
693
  // silently (the next save rewrites the file without them).
364
694
  const profileList = Array.isArray(raw.profiles) ? raw.profiles : []
365
695
  const seenProfileIds = new Set()
366
- let droppedProfiles = 0
367
696
  for (const candidate of profileList) {
368
697
  const profile = sanitizeProfile(candidate)
369
698
  if (profile === null || data.profiles.length >= MAX_PROFILES) {
370
- droppedProfiles += 1
699
+ loaded.droppedProfiles += 1
371
700
  continue
372
701
  }
373
702
  if (seenProfileIds.has(profile.id)) profile.id = makeId()
374
703
  seenProfileIds.add(profile.id)
704
+ if (profile.setId === '' || !knownSet(profile.setId)) {
705
+ if (profile.setId !== '') loaded.reattributed += 1
706
+ profile.setId = data.activeSetId
707
+ }
375
708
  data.profiles.push(profile)
376
709
  }
377
- return { data, dropped, droppedProfiles, optionFixes }
710
+ return loaded
378
711
  }
379
712
 
380
713
  /**
@@ -388,10 +721,16 @@ function normalizeData(raw) {
388
721
  */
389
722
  function cloneData(data) {
390
723
  return {
391
- version: 1,
724
+ version: FORMAT_VERSION,
392
725
  rev: data.rev || 0,
393
726
  enabled: data.enabled === true,
727
+ disabledUntil: typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil) && data.disabledUntil > 0
728
+ ? data.disabledUntil
729
+ : 0,
394
730
  budgetChars: data.budgetChars,
731
+ backupDir: typeof data.backupDir === 'string' ? data.backupDir : '',
732
+ activeSetId: data.activeSetId,
733
+ sets: (Array.isArray(data.sets) ? data.sets : []).map((set) => ({ ...set })),
395
734
  entries: (Array.isArray(data.entries) ? data.entries : []).map((entry) => ({ ...entry })),
396
735
  profiles: (Array.isArray(data.profiles) ? data.profiles : []).map((profile) => ({ ...profile })),
397
736
  }
@@ -407,6 +746,16 @@ const droppedProfilesMessage = (count) =>
407
746
  ? '存储文件中有 ' + count + ' 个用户形象未能载入(名称与内容同时为空或超过 ' + MAX_PROFILES + ' 个上限);下次保存时它们将从文件中移除'
408
747
  : null
409
748
 
749
+ const droppedSetsMessage = (count) =>
750
+ count > 0
751
+ ? '存储文件中有 ' + count + ' 个配置集未能载入(名称为空或超过 ' + MAX_SETS + ' 个上限);下次保存时它们将从文件中移除,其条目会归入当前配置集'
752
+ : null
753
+
754
+ const reattributedMessage = (count) =>
755
+ count > 0
756
+ ? '存储文件中有 ' + count + ' 项(条目或形象)指向不存在的配置集,已归入当前配置集'
757
+ : null
758
+
410
759
  /**
411
760
  * The load-time disclosures for one normalizeData result: dropped entries and
412
761
  * corrected hand-edited options, joined with a full-width semicolon.
@@ -415,10 +764,15 @@ const droppedProfilesMessage = (count) =>
415
764
  */
416
765
  const loadWarnings = (loaded) => {
417
766
  const parts = []
767
+ if (typeof loaded.formatNote === 'string' && loaded.formatNote !== '') parts.push(loaded.formatNote)
418
768
  const dropped = droppedEntriesMessage(loaded.dropped)
419
769
  if (dropped !== null) parts.push(dropped)
420
770
  const droppedProfiles = droppedProfilesMessage(loaded.droppedProfiles || 0)
421
771
  if (droppedProfiles !== null) parts.push(droppedProfiles)
772
+ const droppedSets = droppedSetsMessage(loaded.droppedSets || 0)
773
+ if (droppedSets !== null) parts.push(droppedSets)
774
+ const reattributed = reattributedMessage(loaded.reattributed || 0)
775
+ if (reattributed !== null) parts.push(reattributed)
422
776
  if (Array.isArray(loaded.optionFixes) && loaded.optionFixes.length > 0) {
423
777
  parts.push('存储文件中的以下选项值无效,已按规则修正:' + loaded.optionFixes.join(';') + ';下次保存时修正值将写入文件')
424
778
  }
@@ -813,15 +1167,58 @@ function planEntries(data, budget) {
813
1167
  * - a profile-only store (no instructions) still injects, and the master
814
1168
  * `enabled` switch suppresses both halves as before.
815
1169
  */
816
- export function planInjection(data) {
1170
+ /**
1171
+ * F7 temporary mute: a store-level pause with an expiry. `disabledUntil` is an
1172
+ * epoch-ms instant; while it lies in the future (and the master switch is on)
1173
+ * the store is muted. The wall clock is only the DEFAULT: both this and the
1174
+ * render entry points take `now`, so a fixed clock reproduces the same bytes
1175
+ * and tests pin the behaviour without waiting.
1176
+ */
1177
+ export function isMuted(data, now = Date.now()) {
1178
+ return data !== null && typeof data === 'object' && data.enabled === true
1179
+ && typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil)
1180
+ && data.disabledUntil > now
1181
+ }
1182
+
1183
+ /**
1184
+ * The lapsed-mute decision the host timer acts on: a mute whose instant has
1185
+ * passed needs the section re-registered (and a snapshot pushed), but writes
1186
+ * NOTHING — expiry is a state change, not a save; the store file and its rev
1187
+ * stay untouched, and the stale value is simply ignored until the next real
1188
+ * write rewrites the file without it.
1189
+ */
1190
+ export function muteExpired(data, now = Date.now()) {
1191
+ return data !== null && typeof data === 'object' && data.enabled === true
1192
+ && typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil)
1193
+ && data.disabledUntil > 0 && data.disabledUntil <= now
1194
+ }
1195
+
1196
+ export function planInjection(data, now = Date.now()) {
817
1197
  const budget = clampBudget(data !== null && typeof data === 'object' ? data.budgetChars : undefined)
818
1198
  const blank = () => ({
819
1199
  text: '', budget, activeCount: 0, included: [], omitted: [],
820
1200
  profiles: [], profileChars: 0, entryChars: 0,
821
1201
  })
822
1202
  if (data === null || typeof data !== 'object' || data.enabled !== true) return blank()
823
- const persona = buildProfilesBlock(data.profiles)
824
- const entryPlan = planEntries(data, budget)
1203
+ // F7 temporary mute: while the pause is live the plan is EXACTLY the
1204
+ // enabled:false plan — same fields, same empty text — so the prompt section
1205
+ // disappears and the prompt is byte-identical to a deployment without this
1206
+ // plugin.
1207
+ if (isMuted(data, now)) return blank()
1208
+ /**
1209
+ * The set filter (F13) is applied HERE, at the one place both the rendered
1210
+ * text and the budget decision are produced, so no caller can inject an
1211
+ * inactive set by accident. A store with no `sets` at all is the legacy
1212
+ * single-set shape and is injected whole (see activeScope).
1213
+ */
1214
+ const scope = activeScope(data)
1215
+ const scoped = {
1216
+ ...data,
1217
+ entries: (Array.isArray(data.entries) ? data.entries : []).filter((entry) => inScope(entry, scope)),
1218
+ profiles: (Array.isArray(data.profiles) ? data.profiles : []).filter((profile) => inScope(profile, scope)),
1219
+ }
1220
+ const persona = buildProfilesBlock(scoped.profiles)
1221
+ const entryPlan = planEntries(scoped, budget)
825
1222
  const parts = []
826
1223
  if (persona.text !== '') parts.push(persona.text)
827
1224
  if (entryPlan.text !== '') parts.push(entryPlan.text)
@@ -842,8 +1239,8 @@ export function planInjection(data) {
842
1239
  * decision process lives in one place, and callers that only need the text
843
1240
  * (the prompt section) keep this byte-stable entry point.
844
1241
  */
845
- export function buildBlock(data) {
846
- return planInjection(data).text
1242
+ export function buildBlock(data, now = Date.now()) {
1243
+ return planInjection(data, now).text
847
1244
  }
848
1245
 
849
1246
  /* ------------------------------------------------------------------ *
@@ -854,13 +1251,29 @@ export function apply(ctx) {
854
1251
  const systemPrompt = ctx.systemPrompt
855
1252
 
856
1253
  const state = {
857
- data: { version: 1, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [], profiles: [] },
1254
+ data: {
1255
+ version: FORMAT_VERSION,
1256
+ rev: 0,
1257
+ enabled: true,
1258
+ budgetChars: DEFAULT_BUDGET,
1259
+ backupDir: '',
1260
+ activeSetId: DEFAULT_SET_ID,
1261
+ sets: [{ id: DEFAULT_SET_ID, name: DEFAULT_SET_NAME }],
1262
+ entries: [],
1263
+ profiles: [],
1264
+ },
858
1265
  storagePath: null,
859
1266
  storageDisplay: null,
860
1267
  storageError: null,
861
1268
  storageWarning: null,
862
1269
  /** A failed rolling-backup refresh, shown only when no load warning wins. */
863
1270
  backupError: null,
1271
+ /**
1272
+ * A failed history snapshot (F14). Non-fatal by design — the store itself
1273
+ * was written — but shown, because "history is being kept" is a promise the
1274
+ * panel makes on this feature's behalf.
1275
+ */
1276
+ historyError: null,
864
1277
  /** Which branch of the load actually produced the current data (diagnostics). */
865
1278
  loadedFrom: null,
866
1279
  savedAt: null,
@@ -876,8 +1289,30 @@ export function apply(ctx) {
876
1289
  sectionError: null,
877
1290
  /** Whether a store file is actually present on disk (diagnostics + the panel). */
878
1291
  storeExists: false,
1292
+ /** How many history snapshots the current directory holds (diagnostics). */
1293
+ historyCount: 0,
1294
+ /**
1295
+ * The address the settings route is actually bound to (F12 security panel).
1296
+ * `''` until a web server publishes; the panel turns it into a warning when
1297
+ * the bind is not loopback.
1298
+ */
1299
+ boundHost: '',
879
1300
  }
880
1301
  let sectionDispose = null
1302
+ /**
1303
+ * Which Host values this route accepts. Set when the route is registered,
1304
+ * from the address the web server is actually bound to (see the long note on
1305
+ * requestOriginVerdict). Until then the route does not exist at all, so the
1306
+ * default only matters if something registers it out of band. Declared next
1307
+ * to `state` so the snapshot can publish it at any time without a
1308
+ * temporal-dead-zone hazard (F12 publishes it as `security.hostGuarded`).
1309
+ */
1310
+ let guardHost = true
1311
+ /**
1312
+ * F7: the pending mute-expiry timer, or null. Re-armed by scheduleMuteTimer
1313
+ * (from syncSection) and cleared in the effect cleanup.
1314
+ */
1315
+ let muteTimer = null
881
1316
 
882
1317
  /**
883
1318
  * A first run deliberately writes NOTHING (see readFromDisk step 3). This flag
@@ -958,7 +1393,7 @@ export function apply(ctx) {
958
1393
 
959
1394
  const sectionText = () => {
960
1395
  try {
961
- return buildBlock(state.data)
1396
+ return buildBlock(state.data, Date.now())
962
1397
  } catch (err) {
963
1398
  console.error('[instruction-memory] render failed:', err)
964
1399
  return ''
@@ -974,7 +1409,7 @@ export function apply(ctx) {
974
1409
  */
975
1410
  const sectionPlan = () => {
976
1411
  try {
977
- return planInjection(state.data)
1412
+ return planInjection(state.data, Date.now())
978
1413
  } catch (err) {
979
1414
  console.error('[instruction-memory] render failed:', err)
980
1415
  return {
@@ -999,6 +1434,37 @@ export function apply(ctx) {
999
1434
  return sectionText()
1000
1435
  }
1001
1436
 
1437
+ /**
1438
+ * F7: keep one host-side timer pointed at the mute's expiry. Expiry must be
1439
+ * automatic — no user action, no write: when the instant passes, the section
1440
+ * re-registers (or stays dropped) and open panels get a fresh snapshot,
1441
+ * while the store file and its rev stay untouched. syncSection re-arms it
1442
+ * on every state change (boot, commit, external reload), and re-arming
1443
+ * clears the previous timer first, so there is never more than one.
1444
+ */
1445
+ const scheduleMuteTimer = () => {
1446
+ if (muteTimer !== null) {
1447
+ clearTimeout(muteTimer)
1448
+ muteTimer = null
1449
+ }
1450
+ if (!isMuted(state.data)) return
1451
+ const delay = Math.min(state.data.disabledUntil - Date.now(), MAX_MUTE_MS)
1452
+ muteTimer = setTimeout(() => {
1453
+ muteTimer = null
1454
+ // Clock skew or an early fire: if the mute is still live, re-arm instead
1455
+ // of acting; the sync below happens only on a real expiry.
1456
+ if (isMuted(state.data)) {
1457
+ scheduleMuteTimer()
1458
+ return
1459
+ }
1460
+ syncSection()
1461
+ pushSnapshot()
1462
+ logDebug('mute expired: section re-synced without a write')
1463
+ }, Math.max(1, delay))
1464
+ // A 7-day timer must not hold a shutting-down process open.
1465
+ if (typeof muteTimer.unref === 'function') muteTimer.unref()
1466
+ }
1467
+
1002
1468
  /**
1003
1469
  * Keep the registered section exactly in step with the stored data: present
1004
1470
  * when something must be injected, absent otherwise. An empty memory must
@@ -1018,9 +1484,7 @@ export function apply(ctx) {
1018
1484
  state.sectionError = '注入注册失败:' + describeError(err)
1019
1485
  console.error('[instruction-memory] section registration failed:', err)
1020
1486
  }
1021
- return
1022
- }
1023
- if (!wanted) {
1487
+ } else if (!wanted) {
1024
1488
  if (sectionDispose !== null) {
1025
1489
  const dispose = sectionDispose
1026
1490
  sectionDispose = null
@@ -1034,6 +1498,9 @@ export function apply(ctx) {
1034
1498
  state.sectionRegistered = false
1035
1499
  state.sectionError = null
1036
1500
  }
1501
+ // F7: whatever this sync just published, the mute timer must point at
1502
+ // exactly this state (and re-arming clears the previous timer first).
1503
+ scheduleMuteTimer()
1037
1504
  }
1038
1505
 
1039
1506
  ctx.effect(() => () => {
@@ -1046,6 +1513,10 @@ export function apply(ctx) {
1046
1513
  console.error('[instruction-memory] cleanup failed:', err)
1047
1514
  }
1048
1515
  }
1516
+ if (muteTimer !== null) {
1517
+ clearTimeout(muteTimer)
1518
+ muteTimer = null
1519
+ }
1049
1520
  stopWatching()
1050
1521
  disposeStreams()
1051
1522
  })
@@ -1165,7 +1636,50 @@ export function apply(ctx) {
1165
1636
 
1166
1637
  const storePath = resolveStorePath()
1167
1638
 
1168
- const serialize = () => JSON.stringify(state.data, null, 2) + '\n'
1639
+ /**
1640
+ * The exact object written to memory.json — and the one an export carries.
1641
+ *
1642
+ * `$schema` comes first on purpose: opening the file in an editor then offers
1643
+ * validation and field completion against the copy of memory.schema.json the
1644
+ * host keeps next to the store (see ensureSchemaFile). The loader ignores the
1645
+ * key, so it costs nothing to anyone who never opens the file.
1646
+ */
1647
+ const persistableData = () => ({
1648
+ $schema: SCHEMA_REF,
1649
+ version: FORMAT_VERSION,
1650
+ rev: state.data.rev || 0,
1651
+ enabled: state.data.enabled === true,
1652
+ disabledUntil: typeof state.data.disabledUntil === 'number' && Number.isFinite(state.data.disabledUntil) && state.data.disabledUntil > 0
1653
+ ? state.data.disabledUntil
1654
+ : 0,
1655
+ budgetChars: clampBudget(state.data.budgetChars),
1656
+ backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
1657
+ activeSetId: state.data.activeSetId,
1658
+ sets: (Array.isArray(state.data.sets) ? state.data.sets : []).map((set) => ({ ...set })),
1659
+ entries: (Array.isArray(state.data.entries) ? state.data.entries : []).map((entry) => ({ ...entry })),
1660
+ profiles: (Array.isArray(state.data.profiles) ? state.data.profiles : []).map((profile) => ({ ...profile })),
1661
+ })
1662
+
1663
+ const serialize = () => JSON.stringify(persistableData(), null, 2) + '\n'
1664
+
1665
+ /* ---------------- backup location + history snapshots (F14 / F18) ---------------- */
1666
+
1667
+ const backupDirOf = () => (typeof state.data.backupDir === 'string' ? state.data.backupDir : '')
1668
+ /** Where the rolling pre-write copy lives: ALWAYS next to the store (crash recovery). */
1669
+ const localBackupPath = () => storePath + BACKUP_FILE_SUFFIX
1670
+ /** The user-facing copy under a custom backup location, or null (F18). */
1671
+ const customBackupPath = () => (backupDirOf() !== '' ? join(backupDirOf(), basename(storePath) + BACKUP_FILE_SUFFIX) : null)
1672
+ /**
1673
+ * History snapshots live in a `history/` SUBDIRECTORY of the backup location
1674
+ * (default: the store directory). Two reasons for the subdirectory:
1675
+ * 1. the external-change watcher listens on the store directory and must
1676
+ * never see a snapshot as a change to memory.json — a subdirectory is
1677
+ * not reported by a non-recursive watch, and the filename filter drops
1678
+ * anything that is not memory.json anyway;
1679
+ * 2. a custom (e.g. cloud-synced) backup directory stays tidy: one known
1680
+ * folder holds the history, nothing else is touched.
1681
+ */
1682
+ const historyDirPath = () => join(backupDirOf() !== '' ? backupDirOf() : dirname(storePath), HISTORY_DIR_NAME)
1169
1683
 
1170
1684
  /**
1171
1685
  * Cross-process write lock: a `memory.json.lock` marker created exclusively
@@ -1234,6 +1748,151 @@ export function apply(ctx) {
1234
1748
  }
1235
1749
  }
1236
1750
 
1751
+ /* ---------------- custom backup location (F18) ---------------- */
1752
+
1753
+ /**
1754
+ * Can this directory actually hold backups right now? Setting a path is a
1755
+ * promise ("my backups live there"), so it is verified at the moment it is
1756
+ * set — exists, is a directory, and accepts a write — instead of being
1757
+ * discovered to be wrong by the first save that silently failed to copy.
1758
+ *
1759
+ * @returns null when usable, otherwise a human-readable reason.
1760
+ */
1761
+ const probeBackupDir = async (dir) => {
1762
+ try {
1763
+ const info = await stat(dir)
1764
+ if (!info.isDirectory()) return '该路径不是目录'
1765
+ } catch {
1766
+ return '目录不存在或不可访问'
1767
+ }
1768
+ const probe = join(dir, '.dsh-instruction-memory-probe-' + randomUUID())
1769
+ try {
1770
+ await writeFile(probe, 'probe', 'utf8')
1771
+ await unlink(probe).catch(() => {})
1772
+ return null
1773
+ } catch (err) {
1774
+ return '目录不可写:' + describeError(err)
1775
+ }
1776
+ }
1777
+
1778
+ /* ---------------- history snapshots and the schema copy (both best-effort) ---------------- */
1779
+
1780
+ /** This machine's tag inside snapshot filenames; see deviceTag(). */
1781
+ const DEVICE = deviceTag((() => {
1782
+ try {
1783
+ return hostname()
1784
+ } catch {
1785
+ return ''
1786
+ }
1787
+ })())
1788
+
1789
+ /**
1790
+ * The bytes of the most recent snapshot, so two writes that produce the same
1791
+ * file (a no-op-ish save, a repeat restore) do not each mint a "version".
1792
+ */
1793
+ let lastHistoryText = null
1794
+
1795
+ /**
1796
+ * Enforce the per-device retention limit. Grouping by the device tag inside
1797
+ * the filename is deliberate: two machines sharing one backup folder (the
1798
+ * documented F18 setup) each keep MAX_HISTORY of their OWN snapshots, so one
1799
+ * device can never prune the other's history away. Files that do not match
1800
+ * our naming pattern are never touched.
1801
+ */
1802
+ const pruneHistory = async (dir) => {
1803
+ const names = (await readdir(dir)).filter((name) => HISTORY_FILE_PATTERN.test(name))
1804
+ const byDevice = new Map()
1805
+ for (const name of names) {
1806
+ const key = historyDeviceOf(name)
1807
+ const bucket = byDevice.get(key)
1808
+ if (bucket === undefined) byDevice.set(key, [name])
1809
+ else bucket.push(name)
1810
+ }
1811
+ let removed = 0
1812
+ let kept = 0
1813
+ for (const bucket of byDevice.values()) {
1814
+ // Fixed-width UTC stamp first => lexicographic order is chronological.
1815
+ bucket.sort()
1816
+ const excess = bucket.length - MAX_HISTORY
1817
+ for (let index = 0; index < bucket.length; index += 1) {
1818
+ if (index < excess) {
1819
+ await unlink(join(dir, bucket[index])).catch(() => {})
1820
+ removed += 1
1821
+ } else {
1822
+ kept += 1
1823
+ }
1824
+ }
1825
+ }
1826
+ return { removed, kept }
1827
+ }
1828
+
1829
+ /**
1830
+ * Record the just-written store as a new history snapshot (F14).
1831
+ *
1832
+ * Written AFTER the atomic replace, so a snapshot always equals a state that
1833
+ * really was committed; a failure here is disclosed and never fails the save,
1834
+ * because the store itself is already safely on disk.
1835
+ */
1836
+ const writeHistorySnapshot = async (text) => {
1837
+ if (typeof text !== 'string' || text === '' || text === lastHistoryText) return
1838
+ try {
1839
+ const dir = historyDirPath()
1840
+ await mkdir(dir, { recursive: true })
1841
+ const epoch = Date.now()
1842
+ let written = false
1843
+ // `wx` never overwrites: same-millisecond saves (two rapid bulk actions)
1844
+ // bump the stamp by a millisecond instead of losing one of the two.
1845
+ for (let attempt = 0; attempt < 5 && !written; attempt += 1) {
1846
+ try {
1847
+ await writeFile(join(dir, historyFileName(epoch + attempt, state.data.rev || 0, DEVICE)), text, { encoding: 'utf8', flag: 'wx' })
1848
+ written = true
1849
+ } catch (err) {
1850
+ if (err.code !== 'EEXIST') throw err
1851
+ }
1852
+ }
1853
+ if (!written) throw new Error('快照文件名重复')
1854
+ lastHistoryText = text
1855
+ const { kept } = await pruneHistory(dir)
1856
+ state.historyCount = kept
1857
+ state.historyError = null
1858
+ } catch (err) {
1859
+ state.historyError = '版本历史快照未能写入(' + describeError(err) + ');本次保存本身已完成'
1860
+ }
1861
+ }
1862
+
1863
+ /**
1864
+ * Keep a copy of the shipped JSON Schema next to the store, so the `$schema`
1865
+ * reference inside memory.json resolves for a hand editor who has no idea
1866
+ * where the package lives. Content-compared: one small read on a save, a
1867
+ * write only when the shipped schema actually changed. Never throws — the
1868
+ * schema is a convenience, not a precondition for holding memory.
1869
+ */
1870
+ const ensureSchemaFile = async () => {
1871
+ try {
1872
+ const source = await readFile(fileURLToPath(new URL('../' + SCHEMA_FILE_NAME, import.meta.url)), 'utf8')
1873
+ const target = join(dirname(storePath), SCHEMA_FILE_NAME)
1874
+ const existing = await readFile(target, 'utf8').catch(() => null)
1875
+ if (existing !== source) await writeFile(target, source, 'utf8')
1876
+ } catch {
1877
+ // Best effort by design.
1878
+ }
1879
+ }
1880
+
1881
+ /**
1882
+ * Recount the snapshot directory for the panel's diagnostics. Best effort:
1883
+ * an unreadable directory simply leaves the count as it was, because the
1884
+ * history list (the actual UI, which reports its own errors) is what the
1885
+ * user acts on.
1886
+ */
1887
+ const refreshHistoryCount = async () => {
1888
+ try {
1889
+ const names = await readdir(historyDirPath())
1890
+ state.historyCount = names.filter((name) => HISTORY_FILE_PATTERN.test(name)).length
1891
+ } catch {
1892
+ // No directory yet (nothing written) — count stays 0.
1893
+ }
1894
+ }
1895
+
1237
1896
  const writeToDisk = async () => {
1238
1897
  // Atomic replace (write a sibling temp file, then rename) with a rolling
1239
1898
  // one-version backup: a crash mid-write must never leave a truncated
@@ -1249,7 +1908,6 @@ export function apply(ctx) {
1249
1908
  // the primary in place until the atomic replace succeeds, so a failure at
1250
1909
  // any step leaves the previous store readable at its own path.
1251
1910
  const tmpPath = storePath + '.tmp'
1252
- const bakPath = storePath + '.bak'
1253
1911
  let lock = null
1254
1912
  let previousRev = null
1255
1913
  try {
@@ -1264,8 +1922,12 @@ export function apply(ctx) {
1264
1922
  state.data.rev = previousRev + 1
1265
1923
  const text = serialize()
1266
1924
  await writeFile(tmpPath, text, 'utf8')
1925
+ // Both rolling copies stand for the PREVIOUS good version, so both are
1926
+ // taken before the atomic replace, and neither may ever be renamed into
1927
+ // place (see the long note above).
1928
+ let hadPrevious = true
1267
1929
  try {
1268
- await copyFile(storePath, bakPath)
1930
+ await copyFile(storePath, localBackupPath())
1269
1931
  state.backupError = null
1270
1932
  } catch (err) {
1271
1933
  // ENOENT is simply the very first save: there is no previous version
@@ -1273,9 +1935,23 @@ export function apply(ctx) {
1273
1935
  // happen — non-fatal for this save, because the primary is still intact
1274
1936
  // and the replace below is the real gate, but it must be disclosed
1275
1937
  // rather than swallowed.
1938
+ hadPrevious = err.code !== 'ENOENT'
1276
1939
  state.backupError = err.code === 'ENOENT'
1277
1940
  ? null
1278
- : '上一版未能保留为 memory.json.bak:' + describeError(err)
1941
+ : '上一版未能保留为 ' + localBackupPath() + ':' + describeError(err)
1942
+ }
1943
+ // The user-facing copy under a custom backup location (F18). The LOCAL
1944
+ // copy above stays the one crash recovery reads, so a failure here
1945
+ // degrades the extra copy only — never the plugin's own safety net.
1946
+ const customBak = customBackupPath()
1947
+ if (customBak !== null && hadPrevious) {
1948
+ try {
1949
+ await copyFile(storePath, customBak)
1950
+ } catch (err) {
1951
+ if (state.backupError === null) {
1952
+ state.backupError = '备份位置未能保留上一版(' + customBak + '):' + describeError(err)
1953
+ }
1954
+ }
1279
1955
  }
1280
1956
  try {
1281
1957
  await rename(tmpPath, storePath)
@@ -1294,8 +1970,15 @@ export function apply(ctx) {
1294
1970
  // be (re)armed after a first run that had nothing to watch.
1295
1971
  startWatching()
1296
1972
  state.savedAt = Date.now()
1973
+ // Best-effort side files, each guarded inside itself: a failure in either
1974
+ // must never be mistaken for "the save failed" — the save already
1975
+ // committed, and the store is on disk whatever happens next.
1976
+ await writeHistorySnapshot(text)
1977
+ await ensureSchemaFile()
1297
1978
  logDebug('store written: ' + storePath + ' bytes=' + text.length
1298
1979
  + ' entries=' + state.data.entries.length + ' rev=' + state.data.rev
1980
+ + ' sets=' + state.data.sets.length + ' activeSet=' + state.data.activeSetId
1981
+ + ' history=' + state.historyCount
1299
1982
  + (state.backupError === null ? ' backup=refreshed' : ' backup=FAILED'))
1300
1983
  return true
1301
1984
  } catch (err) {
@@ -1335,14 +2018,21 @@ export function apply(ctx) {
1335
2018
  /**
1336
2019
  * A store declaring an unsupported format version: adopt nothing, write
1337
2020
  * 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).
2021
+ * state is a save — it would rewrite the file in this build's format and
2022
+ * destroy whatever the newer format meant (the mutating commands refuse
2023
+ * while versionRejected is set).
2024
+ *
2025
+ * This is also the guard that makes a DOWNGRADE safe: a file written by a
2026
+ * newer plugin (e.g. version 2 from an F13-aware build) opened by an older
2027
+ * one is refused here, with the data left untouched, instead of being
2028
+ * half-read and then rewritten without the fields the old build cannot
2029
+ * see.
1341
2030
  */
1342
2031
  const rejectVersion = (version, source) => {
1343
2032
  state.versionRejected = true
1344
2033
  state.rejectedVersion = version
1345
- state.storageError = '存储文件声明为格式版本 ' + version + ',本插件只支持版本 1;已拒绝载入以防数据损坏,原文件已保留,修改操作已暂停。请升级插件,或手动将文件改回版本 1 后点「重新载入」'
2034
+ state.storageError = '存储文件声明为格式版本 ' + version + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
2035
+ + ';已拒绝载入以防数据损坏,原文件已保留,修改操作已暂停。请升级插件,或手动将文件改回受支持的版本后点「重新载入」'
1346
2036
  state.loadedFrom = source + ' (unsupported version ' + version + ')'
1347
2037
  state.storeExists = true
1348
2038
  }
@@ -1360,6 +2050,9 @@ export function apply(ctx) {
1360
2050
  state.storageWarning = loadWarnings(loaded)
1361
2051
  state.loadedFrom = 'memory.json'
1362
2052
  state.storeExists = true
2053
+ // A real file is now in memory, so the "materialise on first need"
2054
+ // flag is spent — see ensureStoreMaterialized.
2055
+ needsMaterialize = false
1363
2056
  }
1364
2057
  state.storageError = null
1365
2058
  return false
@@ -1487,6 +2180,35 @@ export function apply(ctx) {
1487
2180
  */
1488
2181
  const ensureStoreMaterialized = async () => {
1489
2182
  if (!needsMaterialize) return false
2183
+ /**
2184
+ * A store may have APPEARED since boot — a hand-copied memory.json, a
2185
+ * backup restored by another tool, a second process. Three cases, and the
2186
+ * difference between the last two is the whole point:
2187
+ * - nothing there: materialise the empty store (the documented first run);
2188
+ * - READABLE: adopt it instead of overwriting whatever the user just put
2189
+ * there (the gap is wide — the panel's first `state` call can come
2190
+ * minutes after boot);
2191
+ * - present but UNREADABLE: write nothing at all and let the loader's own
2192
+ * disclosure say why. Overwriting would destroy the very file a user
2193
+ * could still repair by hand, and it would report `ok:true` while doing
2194
+ * it — the silent data loss this plugin must never commit.
2195
+ */
2196
+ let appeared = null
2197
+ let unreadable = false
2198
+ try {
2199
+ appeared = await readStoreFile(storePath)
2200
+ } catch (err) {
2201
+ // ENOENT is the FIRST RUN itself — nothing to adopt, so materialise.
2202
+ // Anything else (EACCES, EISDIR, a parse failure …) means a file IS
2203
+ // there and could not be adopted; readFromDisk() below reports exactly
2204
+ // what it was, and nothing is written.
2205
+ unreadable = !(err !== null && typeof err === 'object' && err.code === 'ENOENT')
2206
+ }
2207
+ if (unreadable || appeared !== null) {
2208
+ const wrote = await readFromDisk()
2209
+ syncSection()
2210
+ return wrote
2211
+ }
1490
2212
  return writeToDisk()
1491
2213
  }
1492
2214
 
@@ -1506,9 +2228,10 @@ export function apply(ctx) {
1506
2228
  .catch((err) => {
1507
2229
  state.storageError = '读取失败:' + describeError(err)
1508
2230
  })
1509
- .then(() => {
2231
+ .then(async () => {
1510
2232
  bootSettled = true
1511
2233
  syncSection()
2234
+ await refreshHistoryCount()
1512
2235
  // The one always-on line: it answers "is memory mounted, where does it
1513
2236
  // live, and how much is being injected?" without any further digging.
1514
2237
  // 措辞很重要:`section=` 说的是"这个段落已就绪、若被装配会产出多少字符",
@@ -1519,6 +2242,7 @@ export function apply(ctx) {
1519
2242
  + ' from=' + String(state.loadedFrom)
1520
2243
  + ' onDisk=' + (state.storeExists ? 'yes' : 'no')
1521
2244
  + ' entries=' + state.data.entries.length
2245
+ + ' sets=' + state.data.sets.length + ' activeSet=' + state.data.activeSetId
1522
2246
  + ' enabled=' + (state.data.enabled === true)
1523
2247
  + ' section=' + (chars > 0 ? chars + ' chars (pending assembly)' : 'nothing to inject')
1524
2248
  + ' pulls=' + sectionPulls
@@ -1545,17 +2269,83 @@ export function apply(ctx) {
1545
2269
 
1546
2270
  /* ---------------- snapshots ---------------- */
1547
2271
 
2272
+ /**
2273
+ * The snapshot the settings page renders (and the SSE frames carry).
2274
+ *
2275
+ * `data.entries`/`data.profiles` publish ONLY the active set (F13): the panel
2276
+ * lists and edits exactly what is being injected, so a 某客户 set can never
2277
+ * appear in the 个人 panel — the isolation guarantee the feature exists for.
2278
+ * The set list, with per-set counts and the active flag, travels in `sets`,
2279
+ * and the raw arrays are in `data.sets`/`data.activeSetId` for the switcher.
2280
+ * Anything needing the WHOLE store (export, source mode) goes through
2281
+ * persistableData(), which is also what the file holds.
2282
+ */
1548
2283
  const snapshot = () => {
1549
2284
  const plan = sectionPlan()
1550
2285
  const preview = plan.text
2286
+ const scope = activeScope(state.data)
2287
+ const belongsTo = (item, id) => {
2288
+ const own = item !== null && typeof item === 'object' && typeof item.setId === 'string' && item.setId !== ''
2289
+ ? item.setId
2290
+ : state.data.activeSetId
2291
+ return own === id
2292
+ }
2293
+ const activeEntries = state.data.entries.filter((entry) => inScope(entry, scope))
2294
+ const activeProfiles = state.data.profiles.filter((profile) => inScope(profile, scope))
1551
2295
  return {
1552
2296
  data: {
1553
- version: 1,
2297
+ version: FORMAT_VERSION,
1554
2298
  rev: state.data.rev || 0,
1555
2299
  enabled: state.data.enabled === true,
2300
+ disabledUntil: typeof state.data.disabledUntil === 'number' && Number.isFinite(state.data.disabledUntil) && state.data.disabledUntil > 0
2301
+ ? state.data.disabledUntil
2302
+ : 0,
1556
2303
  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 })),
2304
+ backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
2305
+ activeSetId: state.data.activeSetId,
2306
+ sets: state.data.sets.map((set) => ({ id: set.id, name: set.name })),
2307
+ entries: activeEntries.map((entry) => ({ ...entry })),
2308
+ profiles: activeProfiles.map((profile) => ({ ...profile })),
2309
+ },
2310
+ /**
2311
+ * Instruction sets (F13): the switcher's data source. `total` counts the
2312
+ * WHOLE store (all sets), which is what the 200-entry cap is measured
2313
+ * against — the panel says "共 N 条" per active set but the cap message
2314
+ * must quote the store-wide number.
2315
+ */
2316
+ sets: {
2317
+ activeId: state.data.activeSetId,
2318
+ scoped: scope.scoped,
2319
+ total: { entries: state.data.entries.length, profiles: state.data.profiles.length },
2320
+ list: state.data.sets.map((set) => ({
2321
+ id: set.id,
2322
+ name: set.name,
2323
+ active: set.id === state.data.activeSetId,
2324
+ entries: state.data.entries.filter((entry) => belongsTo(entry, set.id)).length,
2325
+ profiles: state.data.profiles.filter((profile) => belongsTo(profile, set.id)).length,
2326
+ })),
2327
+ },
2328
+ /**
2329
+ * F12: the one security signal the panel was missing. `hostGuarded` is
2330
+ * the SAME flag the route enforces on every request — false means the
2331
+ * Host allowlist is OFF because the server is bound beyond loopback,
2332
+ * i.e. the page is reachable without authentication from the local
2333
+ * network. `boundHost` says which address produced that decision so the
2334
+ * warning can name it instead of guessing.
2335
+ */
2336
+ security: {
2337
+ hostGuarded: guardHost === true,
2338
+ boundHost: state.boundHost,
2339
+ },
2340
+ /**
2341
+ * F12: load-side diagnostics that used to be log-only. `loadedFrom` says
2342
+ * which branch of the boot load produced the current data (fresh store,
2343
+ * adopted file, recovered .bak, …); `rev` mirrors data.rev so a support
2344
+ * dump pairs it with the storage fields without another round trip.
2345
+ */
2346
+ diagnostics: {
2347
+ loadedFrom: state.loadedFrom,
2348
+ rev: state.data.rev || 0,
1559
2349
  },
1560
2350
  storage: {
1561
2351
  path: state.storageDisplay !== null ? state.storageDisplay : state.storagePath,
@@ -1565,11 +2355,27 @@ export function apply(ctx) {
1565
2355
  migratedFrom: state.migratedFrom,
1566
2356
  /** false until the store file has actually been written (see readFromDisk step 3). */
1567
2357
  onDisk: state.storeExists,
2358
+ /** Backup location (F18): '' means "next to the store file". */
2359
+ backupDir: backupDirOf(),
2360
+ isDefaultBackupDir: backupDirOf() === '',
2361
+ /** Where the rolling pre-write copy actually sits (diagnostics). */
2362
+ backupPath: localBackupPath(),
2363
+ /** Version history (F14). */
2364
+ historyDir: historyDirPath(),
2365
+ historyCount: state.historyCount,
2366
+ historyError: state.historyError,
2367
+ schemaFile: join(dirname(storePath), SCHEMA_FILE_NAME),
1568
2368
  },
1569
2369
  injection: {
1570
2370
  registered: state.sectionRegistered,
1571
2371
  available: true,
1572
2372
  error: state.sectionError,
2373
+ /**
2374
+ * F7: the pause is decided by the HOST clock (isMuted on the live
2375
+ * store) and published as a derived flag; the client only renders a
2376
+ * countdown from data.disabledUntil and never decides by itself.
2377
+ */
2378
+ muted: isMuted(state.data, Date.now()),
1573
2379
  chars: preview.length,
1574
2380
  /**
1575
2381
  * The budget decision behind `preview`, for the settings page's
@@ -1611,6 +2417,10 @@ export function apply(ctx) {
1611
2417
  maxProfiles: MAX_PROFILES,
1612
2418
  maxProfileName: MAX_PROFILE_NAME,
1613
2419
  maxProfileContent: MAX_PROFILE_CONTENT,
2420
+ maxSets: MAX_SETS,
2421
+ maxSetName: MAX_SET_NAME,
2422
+ maxHistory: MAX_HISTORY,
2423
+ maxBackupDir: MAX_BACKUP_DIR,
1614
2424
  maxBodyChars: MAX_BODY_CHARS,
1615
2425
  },
1616
2426
  }
@@ -1649,17 +2459,70 @@ export function apply(ctx) {
1649
2459
 
1650
2460
  /**
1651
2461
  * 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 重新载入.
2462
+ * a save here would rewrite it in this build's format and destroy whatever
2463
+ * the newer format meant. The file stays untouched until the user upgrades
2464
+ * the plugin or restores a supported-version file and hits 重新载入.
1655
2465
  */
1656
2466
  const versionRefusal = () => ({
1657
2467
  ok: false,
1658
2468
  saved: false,
1659
- message: '存储文件为不支持的格式版本 ' + state.rejectedVersion + ',修改已暂停:现在保存会用版本 1 覆盖它,可能损坏数据。请先升级插件,或手动将文件改回版本 1 后点「重新载入」',
2469
+ message: '存储文件为不支持的格式版本 ' + state.rejectedVersion + ',修改已暂停:现在保存会用版本 ' + FORMAT_VERSION
2470
+ + ' 覆盖它,可能损坏数据。请先升级插件,或手动将文件改回受支持的版本(' + SUPPORTED_VERSIONS.join(' / ') + ')后点「重新载入」',
1660
2471
  snapshot: snapshot(),
1661
2472
  })
1662
2473
 
2474
+ /**
2475
+ * Adopt a whole parsed store — the shared core of 源码模式 (F16) and the
2476
+ * 整体替换 restore (F14). Both hand the user the SAME safety story as every
2477
+ * other entry point: only a v1/v2 store object is accepted (a stray `{}` or
2478
+ * an unrelated JSON file must never be able to wipe the store), the format
2479
+ * version gate applies, and every correction normalizeData makes is
2480
+ * reported.
2481
+ *
2482
+ * `backupDir` is deliberately NOT adopted: the backup location is
2483
+ * machine-local state, so restoring a file — from another machine, or from
2484
+ * before the path existed — must not silently redirect where backups go.
2485
+ */
2486
+ const adoptParsedStore = (parsed) => {
2487
+ const looksLikeStore = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
2488
+ && ['version', 'rev', 'enabled', 'budgetChars', 'backupDir', 'activeSetId', 'sets', 'entries', 'profiles']
2489
+ .some((key) => key in parsed)
2490
+ if (!looksLikeStore) {
2491
+ return {
2492
+ error: '内容不像本插件的存储文件:顶层需要是对象,且至少包含 entries / profiles / sets / version 之一。'
2493
+ + '若要清空记忆,请使用界面上的删除操作,而不是保存一个空对象',
2494
+ }
2495
+ }
2496
+ const loaded = normalizeData(parsed)
2497
+ if (loaded.rejectedVersion !== undefined) return { rejectedVersion: loaded.rejectedVersion }
2498
+ loaded.data.backupDir = backupDirOf()
2499
+ return { loaded }
2500
+ }
2501
+
2502
+ /** Per-field truncation disclosure for an adopted raw store (mirrors import). */
2503
+ const truncationNotes = (parsed) => {
2504
+ const lists = [
2505
+ [parsed.entries, truncatedFields],
2506
+ [parsed.profiles, truncatedProfileFields],
2507
+ [parsed.sets, truncatedSetFields],
2508
+ ]
2509
+ let items = 0
2510
+ const fields = new Set()
2511
+ for (const [list, fieldsOf] of lists) {
2512
+ for (const raw of (Array.isArray(list) ? list : [])) {
2513
+ const cut = fieldsOf(raw)
2514
+ if (cut.length > 0) {
2515
+ items += 1
2516
+ for (const name of cut) fields.add(name)
2517
+ }
2518
+ }
2519
+ }
2520
+ return items > 0 ? '有 ' + items + ' 项超过长度上限,' + [...fields].join('、') + '已被截断' : null
2521
+ }
2522
+
2523
+ const describeStoreShape = (data) =>
2524
+ data.entries.length + ' 条指令 / ' + data.profiles.length + ' 个用户形象 / ' + data.sets.length + ' 个配置集'
2525
+
1663
2526
  /**
1664
2527
  * Apply an in-memory mutation, persist it, and UNDO it when the write fails.
1665
2528
  *
@@ -1707,11 +2570,33 @@ export function apply(ctx) {
1707
2570
  const entry = sanitizeEntry(raw)
1708
2571
  if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
1709
2572
  entry.updatedAt = Date.now()
2573
+ /**
2574
+ * The active set is authoritative for the interactive save path: the panel
2575
+ * edits what is being injected, and an edit must never be able to move an
2576
+ * item into another set (or drag one in). Moving content between sets is a
2577
+ * deliberate act — create the entry where it belongs, or use 复制配置集.
2578
+ */
2579
+ entry.setId = state.data.activeSetId
2580
+ /**
2581
+ * An id that already belongs to ANOTHER set is not this command's to
2582
+ * replace (entries are addressed by id alone): honouring it would move the
2583
+ * other set's entry into this one and overwrite its text — a cross-set
2584
+ * leak in the one direction the whole feature exists to prevent. Mint a
2585
+ * fresh id instead, and say so, because the caller's id silently changed.
2586
+ */
2587
+ const foreignId = state.data.entries.some((candidate) => candidate.id === entry.id
2588
+ && candidate.setId !== state.data.activeSetId)
2589
+ if (foreignId) entry.id = makeId()
1710
2590
  // Resolved before the mutation so a refusal can return a response instead
1711
2591
  // of being swallowed as an early exit inside it.
1712
2592
  const replacesExisting = state.data.entries.some((candidate) => candidate.id === entry.id)
1713
2593
  if (!replacesExisting && state.data.entries.length >= MAX_ENTRIES) {
1714
- return { ok: false, saved: false, message: '最多保存 ' + MAX_ENTRIES + ' 条指令', snapshot: snapshot() }
2594
+ return {
2595
+ ok: false,
2596
+ saved: false,
2597
+ message: '最多保存 ' + MAX_ENTRIES + ' 条指令(全部配置集合计,当前 ' + state.data.entries.length + ' 条)',
2598
+ snapshot: snapshot(),
2599
+ }
1715
2600
  }
1716
2601
  const cut = truncatedFields(raw)
1717
2602
  const saved = await commit(() => {
@@ -1720,10 +2605,14 @@ export function apply(ctx) {
1720
2605
  if (index >= 0) list[index] = entry
1721
2606
  else list.push(entry)
1722
2607
  })
1723
- const note = cut.length > 0
1724
- ? '已保存;' + cut.join('、') + '超过长度上限(标题 ' + MAX_TITLE + ' / 内容 ' + MAX_CONTENT + ' / 适用场景 ' + MAX_WHEN + ' 字符),超出部分已被截断'
1725
- : null
1726
- return settle(note, saved)
2608
+ const notes = []
2609
+ if (cut.length > 0) {
2610
+ notes.push(cut.join('、') + '超过长度上限(标题 ' + MAX_TITLE + ' / 内容 ' + MAX_CONTENT + ' / 适用场景 ' + MAX_WHEN + ' 字符),超出部分已被截断')
2611
+ }
2612
+ if (foreignId) {
2613
+ notes.push('提交的 id 属于其他配置集,已改用新 id 在当前配置集新建(其他配置集的内容未被改动)')
2614
+ }
2615
+ return settle(notes.length > 0 ? '已保存;' + notes.join(';') : null, saved)
1727
2616
  })
1728
2617
 
1729
2618
  const deleteEntry = (id, baseRev) => enqueue(async () => {
@@ -1733,7 +2622,13 @@ export function apply(ctx) {
1733
2622
  // disk, bump rev, or push an echo frame — otherwise two panels deleting
1734
2623
  // the same entry one after another would manufacture a pointless write
1735
2624
  // (and an external process watching the file would wake for nothing).
1736
- if (!state.data.entries.some((entry) => entry.id === id)) {
2625
+ //
2626
+ // Scoped to the ACTIVE set: the panel only ever lists those ids, so an id
2627
+ // from another set is not "an entry that is not there" by accident — it is
2628
+ // a request this command must not honour (deleting across sets from a
2629
+ // settings page that claims to show one set would be a data-loss bug).
2630
+ const scope = activeScope(state.data)
2631
+ if (!state.data.entries.some((entry) => entry.id === id && inScope(entry, scope))) {
1737
2632
  return { ok: true, saved: false, message: null, snapshot: snapshot() }
1738
2633
  }
1739
2634
  const saved = await commit(() => {
@@ -1745,15 +2640,95 @@ export function apply(ctx) {
1745
2640
  const setOptions = (patch, baseRev) => enqueue(async () => {
1746
2641
  if (state.versionRejected) return versionRefusal()
1747
2642
  if (isStaleBaseRev(baseRev)) return conflictResponse()
2643
+ // The custom backup location (F18) is validated BEFORE anything is queued
2644
+ // into the store: an unusable path must be refused with a reason, not
2645
+ // accepted and then discovered as a failed copy on the next save.
2646
+ if (patch !== null && typeof patch === 'object' && typeof patch.backupDir === 'string') {
2647
+ const wanted = patch.backupDir.trim()
2648
+ if (wanted !== '') {
2649
+ if (wanted.length > MAX_BACKUP_DIR) {
2650
+ return { ok: false, saved: false, message: '备份位置路径过长(上限 ' + MAX_BACKUP_DIR + ' 字符)', snapshot: snapshot() }
2651
+ }
2652
+ if (!isAbsolute(wanted)) {
2653
+ return {
2654
+ ok: false,
2655
+ saved: false,
2656
+ message: '备份位置需要绝对路径(例如 D:\\云盘\\dsh-memory 或 /home/me/cloud/dsh-memory);留空表示与存储文件同目录',
2657
+ snapshot: snapshot(),
2658
+ }
2659
+ }
2660
+ const problem = await probeBackupDir(wanted)
2661
+ if (problem !== null) {
2662
+ return { ok: false, saved: false, message: '备份位置不可用(' + problem + '):' + wanted, snapshot: snapshot() }
2663
+ }
2664
+ }
2665
+ }
2666
+ // F7 temporary mute: validated BEFORE the transaction so a bad value is
2667
+ // refused with a reason instead of being silently normalized. 0 means
2668
+ // "resume now"; a past instant means the same thing; anything beyond the
2669
+ // 7-day ceiling is clamped (and disclosed) rather than refused — a pause
2670
+ // is a de-escalation, refusing it would be user-hostile.
2671
+ let muteTarget = null
2672
+ let muteClamped = false
2673
+ if (patch !== null && typeof patch === 'object' && 'disabledUntil' in patch) {
2674
+ const rawMute = patch.disabledUntil
2675
+ if (typeof rawMute !== 'number' || !Number.isFinite(rawMute) || rawMute < 0) {
2676
+ return {
2677
+ ok: false,
2678
+ saved: false,
2679
+ message: 'disabledUntil 需要 0(立即恢复)或未来的 epoch 毫秒数',
2680
+ snapshot: snapshot(),
2681
+ }
2682
+ }
2683
+ let wanted = Math.round(rawMute)
2684
+ if (wanted <= Date.now()) {
2685
+ wanted = 0
2686
+ } else if (wanted > Date.now() + MAX_MUTE_MS) {
2687
+ wanted = Date.now() + MAX_MUTE_MS
2688
+ muteClamped = true
2689
+ }
2690
+ muteTarget = wanted
2691
+ }
2692
+ const muteWasActive = isMuted(state.data)
1748
2693
  const saved = await commit(() => {
1749
2694
  if (patch !== null && typeof patch === 'object') {
1750
- if (typeof patch.enabled === 'boolean') state.data.enabled = patch.enabled
2695
+ if (typeof patch.enabled === 'boolean') {
2696
+ state.data.enabled = patch.enabled
2697
+ // An explicit switch outranks the timer: flipping the master switch
2698
+ // ends any pause immediately.
2699
+ state.data.disabledUntil = 0
2700
+ }
1751
2701
  if (typeof patch.budgetChars === 'number' && Number.isFinite(patch.budgetChars)) {
1752
2702
  state.data.budgetChars = clampBudget(patch.budgetChars)
1753
2703
  }
2704
+ if (typeof patch.backupDir === 'string') state.data.backupDir = patch.backupDir.trim()
2705
+ if (muteTarget !== null && typeof patch.enabled !== 'boolean') {
2706
+ state.data.disabledUntil = muteTarget
2707
+ }
1754
2708
  }
1755
2709
  })
1756
- return settle(null, saved)
2710
+ // A changed backup location moves where history snapshots and manual
2711
+ // exports go, so it is reported rather than left to be discovered.
2712
+ const notes = []
2713
+ if (patch !== null && typeof patch === 'object' && typeof patch.backupDir === 'string') {
2714
+ const wanted = patch.backupDir.trim()
2715
+ notes.push(wanted === ''
2716
+ ? '已恢复默认备份位置(版本历史与导出与存储文件同目录)'
2717
+ : '备份位置已更新:' + wanted + '(版本历史与「导出到备份目录」随之改变;存储文件本身不移动)')
2718
+ }
2719
+ if (muteTarget !== null && !(patch !== null && typeof patch === 'object' && typeof patch.enabled === 'boolean')) {
2720
+ if (muteTarget === 0) {
2721
+ if (muteWasActive) notes.push('已恢复注入。')
2722
+ } else {
2723
+ notes.push('已暂停注入至 ' + new Date(muteTarget).toLocaleString()
2724
+ + '(到期自动恢复;暂停期间不注入任何指令或用户形象)'
2725
+ + (muteClamped ? ';暂停时长上限为 7 天,已按上限记录' : ''))
2726
+ }
2727
+ }
2728
+ if (patch !== null && typeof patch === 'object' && typeof patch.enabled === 'boolean' && muteWasActive) {
2729
+ notes.push('总开关已切换,暂停注入状态已一并清除')
2730
+ }
2731
+ return settle(notes.length > 0 ? notes.join(';') : null, saved)
1757
2732
  })
1758
2733
 
1759
2734
  /**
@@ -1787,7 +2762,11 @@ export function apply(ctx) {
1787
2762
  }
1788
2763
 
1789
2764
  const list = state.data.entries
1790
- const targets = list.filter((entry) => uniqueIds.includes(entry.id))
2765
+ // Scoped to the active set, exactly like the single-entry commands: the
2766
+ // panel's selection can only ever contain active-set ids, so an id from
2767
+ // another set is reported as "not found" rather than acted on.
2768
+ const scope = activeScope(state.data)
2769
+ const targets = list.filter((entry) => uniqueIds.includes(entry.id) && inScope(entry, scope))
1791
2770
  const missing = uniqueIds.length - targets.length
1792
2771
  const missingNote = missing > 0
1793
2772
  ? '(另有 ' + missing + ' 条未找到,可能已被其他窗口或进程修改)'
@@ -1882,6 +2861,13 @@ export function apply(ctx) {
1882
2861
  const profile = sanitizeProfile(raw)
1883
2862
  if (profile === null) return { ok: false, saved: false, message: '形象名称与内容不能同时为空', snapshot: snapshot() }
1884
2863
  profile.updatedAt = Date.now()
2864
+ /** Same rule as entries: the interactive path files into the active set. */
2865
+ profile.setId = state.data.activeSetId
2866
+ // ...and an id owned by another set is re-minted rather than replaced (see
2867
+ // the note in saveEntry — same cross-set hazard, same guard).
2868
+ const foreignProfileId = state.data.profiles.some((candidate) => candidate.id === profile.id
2869
+ && candidate.setId !== state.data.activeSetId)
2870
+ if (foreignProfileId) profile.id = makeId()
1885
2871
  const replacesExisting = state.data.profiles.some((candidate) => candidate.id === profile.id)
1886
2872
  if (!replacesExisting && state.data.profiles.length >= MAX_PROFILES) {
1887
2873
  return { ok: false, saved: false, message: '最多保存 ' + MAX_PROFILES + ' 个用户形象', snapshot: snapshot() }
@@ -1902,8 +2888,10 @@ export function apply(ctx) {
1902
2888
  const deleteProfile = (id, baseRev) => enqueue(async () => {
1903
2889
  if (state.versionRejected) return versionRefusal()
1904
2890
  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)) {
2891
+ // Mirror deleteEntry: an absent id changes nothing and must not touch disk,
2892
+ // and an id belonging to a different set is not this panel's to delete.
2893
+ const scope = activeScope(state.data)
2894
+ if (!state.data.profiles.some((profile) => profile.id === id && inScope(profile, scope))) {
1907
2895
  return { ok: true, saved: false, message: null, snapshot: snapshot() }
1908
2896
  }
1909
2897
  const saved = await commit(() => {
@@ -1912,6 +2900,269 @@ export function apply(ctx) {
1912
2900
  return settle(null, saved)
1913
2901
  })
1914
2902
 
2903
+ /* ---------------- instruction sets (F13) ---------------- */
2904
+
2905
+ /**
2906
+ * Create or rename an instruction set.
2907
+ *
2908
+ * A blank `set.id` is a CREATE (the Host mints the id), the same convention
2909
+ * entry edits use, and a supplied id is a RENAME. Creating does NOT switch:
2910
+ * switching is what changes what gets injected, and it stays a separate,
2911
+ * explicit click (the response says so). Nothing in this file ever derives a
2912
+ * set from the process cwd or from an open folder — see the README note on
2913
+ * the incident that rule comes from.
2914
+ */
2915
+ const saveSet = (raw, baseRev) => enqueue(async () => {
2916
+ if (state.versionRejected) return versionRefusal()
2917
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
2918
+ const wanted = sanitizeSet(raw)
2919
+ if (wanted === null) {
2920
+ return { ok: false, saved: false, message: '配置集名称不能为空(单行,最多 ' + MAX_SET_NAME + ' 字符)', snapshot: snapshot() }
2921
+ }
2922
+ const index = state.data.sets.findIndex((set) => set.id === wanted.id)
2923
+ if (state.data.sets.some((set, at) => at !== index && set.name === wanted.name)) {
2924
+ return { ok: false, saved: false, message: '已存在同名配置集「' + wanted.name + '」', snapshot: snapshot() }
2925
+ }
2926
+ if (index < 0) {
2927
+ if (state.data.sets.length >= MAX_SETS) {
2928
+ return { ok: false, saved: false, message: '最多创建 ' + MAX_SETS + ' 个配置集', snapshot: snapshot() }
2929
+ }
2930
+ const cut = truncatedSetFields(raw)
2931
+ const saved = await commit(() => {
2932
+ state.data.sets.push({ id: wanted.id, name: wanted.name })
2933
+ })
2934
+ const note = '已创建配置集「' + wanted.name + '」'
2935
+ + (cut.length > 0 ? ';名称超过 ' + MAX_SET_NAME + ' 字符上限,超出部分已被截断' : '')
2936
+ + ';它是空的,点击「切换」后才会注入它'
2937
+ return settle(note, saved)
2938
+ }
2939
+ if (state.data.sets[index].name === wanted.name) {
2940
+ return { ok: true, saved: false, message: '名称未变化', snapshot: snapshot() }
2941
+ }
2942
+ const cut = truncatedSetFields(raw)
2943
+ const saved = await commit(() => {
2944
+ state.data.sets[index].name = wanted.name
2945
+ })
2946
+ return settle('已重命名为「' + wanted.name + '」' + (cut.length > 0 ? '(名称已按长度上限截断)' : ''), saved)
2947
+ })
2948
+
2949
+ /**
2950
+ * Delete a set together with everything filed under it. Refused for the
2951
+ * ACTIVE set: deleting what is currently injected would change the prompt as
2952
+ * a side effect of a management action, so the user switches first and the
2953
+ * deletion becomes a separate, visible step.
2954
+ */
2955
+ const deleteSet = (id, baseRev) => enqueue(async () => {
2956
+ if (state.versionRejected) return versionRefusal()
2957
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
2958
+ const index = state.data.sets.findIndex((set) => set.id === id)
2959
+ if (index < 0) {
2960
+ return { ok: false, saved: false, message: '没有该配置集(可能已被其他窗口或进程删除)', snapshot: snapshot() }
2961
+ }
2962
+ if (state.data.sets.length <= 1) {
2963
+ return { ok: false, saved: false, message: '至少要保留一个配置集', snapshot: snapshot() }
2964
+ }
2965
+ if (id === state.data.activeSetId) {
2966
+ return { ok: false, saved: false, message: '不能删除当前激活的配置集;请先切换到其他配置集', snapshot: snapshot() }
2967
+ }
2968
+ const doomedEntries = state.data.entries.filter((entry) => entry.setId === id).length
2969
+ const doomedProfiles = state.data.profiles.filter((profile) => profile.setId === id).length
2970
+ const saved = await commit(() => {
2971
+ state.data.sets.splice(index, 1)
2972
+ state.data.entries = state.data.entries.filter((entry) => entry.setId !== id)
2973
+ state.data.profiles = state.data.profiles.filter((profile) => profile.setId !== id)
2974
+ })
2975
+ return settle('已删除配置集,并移除其中的 ' + doomedEntries + ' 条指令、' + doomedProfiles + ' 个用户形象', saved)
2976
+ })
2977
+
2978
+ /**
2979
+ * Make one set the active one — the single command that changes what is
2980
+ * injected. It writes (the choice survives a restart) and therefore bumps
2981
+ * `rev`, so every other open panel follows along.
2982
+ */
2983
+ const setActiveSet = (id, baseRev) => enqueue(async () => {
2984
+ if (state.versionRejected) return versionRefusal()
2985
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
2986
+ const target = state.data.sets.find((set) => set.id === id)
2987
+ if (target === undefined) {
2988
+ return { ok: false, saved: false, message: '没有该配置集(可能已被其他窗口或进程删除)', snapshot: snapshot() }
2989
+ }
2990
+ if (id === state.data.activeSetId) {
2991
+ return { ok: true, saved: false, message: null, snapshot: snapshot() }
2992
+ }
2993
+ const saved = await commit(() => {
2994
+ state.data.activeSetId = id
2995
+ })
2996
+ return settle('已切换到配置集「' + target.name + '」,注入内容已随之更新', saved)
2997
+ })
2998
+
2999
+ /**
3000
+ * Copy a set (its entries and profiles, with fresh ids) into a new one — the
3001
+ * "start from a template" flow for per-client isolation. The copy is NOT
3002
+ * activated: it is a new, untested configuration until the user switches.
3003
+ */
3004
+ const duplicateSet = (id, name, baseRev) => enqueue(async () => {
3005
+ if (state.versionRejected) return versionRefusal()
3006
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
3007
+ const source = state.data.sets.find((set) => set.id === id)
3008
+ if (source === undefined) {
3009
+ return { ok: false, saved: false, message: '没有该配置集(可能已被其他窗口或进程删除)', snapshot: snapshot() }
3010
+ }
3011
+ if (state.data.sets.length >= MAX_SETS) {
3012
+ return { ok: false, saved: false, message: '最多 ' + MAX_SETS + ' 个配置集', snapshot: snapshot() }
3013
+ }
3014
+ const wanted = singleLine(typeof name === 'string' ? name : '', MAX_SET_NAME)
3015
+ || singleLine(source.name + ' 副本', MAX_SET_NAME)
3016
+ if (state.data.sets.some((set) => set.name === wanted)) {
3017
+ return { ok: false, saved: false, message: '已存在同名配置集「' + wanted + '」', snapshot: snapshot() }
3018
+ }
3019
+ const sourceEntries = state.data.entries.filter((entry) => entry.setId === id)
3020
+ if (state.data.entries.length + sourceEntries.length > MAX_ENTRIES) {
3021
+ return {
3022
+ ok: false,
3023
+ saved: false,
3024
+ message: '复制后条目总数会超过 ' + MAX_ENTRIES + ' 条上限(当前 ' + state.data.entries.length
3025
+ + ' 条,需复制 ' + sourceEntries.length + ' 条)',
3026
+ snapshot: snapshot(),
3027
+ }
3028
+ }
3029
+ const sourceProfiles = state.data.profiles.filter((profile) => profile.setId === id)
3030
+ if (state.data.profiles.length + sourceProfiles.length > MAX_PROFILES) {
3031
+ return { ok: false, saved: false, message: '复制后用户形象总数会超过 ' + MAX_PROFILES + ' 个上限', snapshot: snapshot() }
3032
+ }
3033
+ const newId = makeSetId()
3034
+ const stamp = Date.now()
3035
+ const entries = sourceEntries.map((entry) => ({ ...entry, id: makeId(), setId: newId, updatedAt: stamp }))
3036
+ const profiles = sourceProfiles.map((profile) => ({ ...profile, id: makeId(), setId: newId, updatedAt: stamp }))
3037
+ const saved = await commit(() => {
3038
+ state.data.sets.push({ id: newId, name: wanted })
3039
+ state.data.entries.push(...entries)
3040
+ state.data.profiles.push(...profiles)
3041
+ })
3042
+ return settle('已复制为「' + wanted + '」(' + entries.length + ' 条指令、' + profiles.length + ' 个用户形象)'
3043
+ + ';点击「切换」后才会注入它', saved)
3044
+ })
3045
+
3046
+ /* ---------------- version history (F14) ---------------- */
3047
+
3048
+ /**
3049
+ * The snapshot index, newest first. Names carry the timestamp, revision and
3050
+ * device, so nothing here needs to read a file to sort; counts are read from
3051
+ * the small ones only, so a maximal store cannot make listing expensive.
3052
+ */
3053
+ const HISTORY_INDEX_MAX_BYTES = 1048576
3054
+ const historyList = () => enqueue(async () => {
3055
+ const dir = historyDirPath()
3056
+ let names = []
3057
+ try {
3058
+ names = (await readdir(dir)).filter((name) => HISTORY_FILE_PATTERN.test(name))
3059
+ } catch (err) {
3060
+ // A missing directory is not an error: nothing has been written yet.
3061
+ if (err === null || err === undefined || err.code !== 'ENOENT') {
3062
+ return { ok: false, saved: false, message: '无法读取版本历史目录:' + describeError(err), snapshot: snapshot(), history: { dir, items: [] } }
3063
+ }
3064
+ }
3065
+ const items = []
3066
+ for (const name of names) {
3067
+ const full = join(dir, name)
3068
+ let info = null
3069
+ try {
3070
+ info = await stat(full)
3071
+ } catch {
3072
+ continue
3073
+ }
3074
+ const item = {
3075
+ name,
3076
+ epoch: historyEpochOf(name),
3077
+ rev: historyRevOf(name),
3078
+ device: historyDeviceOf(name),
3079
+ foreign: historyDeviceOf(name) !== DEVICE,
3080
+ bytes: info.size,
3081
+ entries: null,
3082
+ profiles: null,
3083
+ sets: null,
3084
+ }
3085
+ if (info.size <= HISTORY_INDEX_MAX_BYTES) {
3086
+ try {
3087
+ const parsed = JSON.parse(await readFile(full, 'utf8'))
3088
+ item.entries = Array.isArray(parsed.entries) ? parsed.entries.length : 0
3089
+ item.profiles = Array.isArray(parsed.profiles) ? parsed.profiles.length : 0
3090
+ item.sets = Array.isArray(parsed.sets) ? parsed.sets.length : 1
3091
+ } catch {
3092
+ // An unreadable snapshot still lists; restore will report the detail.
3093
+ }
3094
+ }
3095
+ items.push(item)
3096
+ }
3097
+ items.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0))
3098
+ state.historyCount = items.length
3099
+ return { ok: true, saved: false, message: null, snapshot: snapshot(), history: { dir, items } }
3100
+ })
3101
+
3102
+ /**
3103
+ * Roll the store back to one snapshot. The snapshot becomes the new CURRENT
3104
+ * state and is written as a new revision (rev only ever moves forward, so a
3105
+ * client holding an older rev is refused rather than silently reverted) —
3106
+ * and, like every other write, it leaves its own snapshot behind, so the
3107
+ * rollback itself can be undone from the history list.
3108
+ */
3109
+ const historyRestore = (name, baseRev) => enqueue(async () => {
3110
+ if (state.versionRejected) return versionRefusal()
3111
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
3112
+ if (typeof name !== 'string' || !HISTORY_FILE_PATTERN.test(name)) {
3113
+ return { ok: false, saved: false, message: '历史快照名称无效', snapshot: snapshot() }
3114
+ }
3115
+ let parsed = null
3116
+ try {
3117
+ parsed = JSON.parse(await readFile(join(historyDirPath(), name), 'utf8'))
3118
+ } catch (err) {
3119
+ return { ok: false, saved: false, message: '无法读取该历史快照:' + describeError(err), snapshot: snapshot() }
3120
+ }
3121
+ const adopted = adoptParsedStore(parsed)
3122
+ if (adopted.error !== undefined) {
3123
+ return { ok: false, saved: false, message: '该历史快照不是有效的存储文件:' + adopted.error, snapshot: snapshot() }
3124
+ }
3125
+ if (adopted.rejectedVersion !== undefined) {
3126
+ return {
3127
+ ok: false,
3128
+ saved: false,
3129
+ message: '该历史快照为格式版本 ' + adopted.rejectedVersion + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
3130
+ + ';已拒绝回滚,当前内容未被修改',
3131
+ snapshot: snapshot(),
3132
+ }
3133
+ }
3134
+ const { loaded } = adopted
3135
+ const notes = []
3136
+ const warnings = loadWarnings(loaded)
3137
+ if (warnings !== null) notes.push(warnings)
3138
+ const saved = await commit(() => {
3139
+ loaded.data.rev = state.data.rev || 0
3140
+ loaded.data.backupDir = backupDirOf()
3141
+ state.data = loaded.data
3142
+ })
3143
+ const when = new Date(historyEpochOf(name)).toLocaleString()
3144
+ const head = '已回滚到 ' + when + ' 的快照(快照 rev ' + historyRevOf(name) + ',本次写为新 rev ' + (state.data.rev || 0) + '):'
3145
+ + describeStoreShape(state.data)
3146
+ return settle([head, ...notes].join(';'), saved)
3147
+ })
3148
+
3149
+ /**
3150
+ * Delete one snapshot. `saved` stays false: the STORE was not written, which
3151
+ * is exactly what the field means everywhere else.
3152
+ */
3153
+ const historyDelete = (name) => enqueue(async () => {
3154
+ if (typeof name !== 'string' || !HISTORY_FILE_PATTERN.test(name)) {
3155
+ return { ok: false, saved: false, message: '历史快照名称无效', snapshot: snapshot() }
3156
+ }
3157
+ try {
3158
+ await unlink(join(historyDirPath(), name))
3159
+ } catch (err) {
3160
+ return { ok: false, saved: false, message: '删除历史快照失败:' + describeError(err), snapshot: snapshot() }
3161
+ }
3162
+ await refreshHistoryCount()
3163
+ return { ok: true, saved: false, message: '已删除该历史快照', snapshot: snapshot() }
3164
+ })
3165
+
1915
3166
  const reload = () => enqueue(async () => {
1916
3167
  // `saved` is only true when the load itself wrote to disk (the
1917
3168
  // backup-promotion or legacy-migration branch); a plain re-read claiming
@@ -1920,6 +3171,18 @@ export function apply(ctx) {
1920
3171
  syncSection()
1921
3172
  return { ok: state.storageError === null, saved: wrote === true, message: state.storageError, snapshot: snapshot() }
1922
3173
  })
3174
+ /**
3175
+ * An export is a BACKUP, so it carries the whole store — every set, with
3176
+ * each item's `setId` — not just the active one (F13). `version` is the
3177
+ * envelope's own version; `data.version` is the store format inside it.
3178
+ */
3179
+ const exportEnvelope = () => ({
3180
+ app: 'dsh-instruction-memory',
3181
+ version: FORMAT_VERSION,
3182
+ exportedAt: new Date().toISOString(),
3183
+ data: persistableData(),
3184
+ })
3185
+
1923
3186
  const exportData = () => enqueue(async () => ({
1924
3187
  ok: true,
1925
3188
  // An export reads the store and never writes it; a truthful `saved:false`
@@ -1928,10 +3191,135 @@ export function apply(ctx) {
1928
3191
  message: null,
1929
3192
  // A self-describing envelope: re-importable by this plugin, and readable
1930
3193
  // as plain JSON by anything else.
1931
- export: { app: 'dsh-instruction-memory', version: 1, exportedAt: new Date().toISOString(), data: snapshot().data },
3194
+ export: exportEnvelope(),
1932
3195
  snapshot: snapshot(),
1933
3196
  }))
1934
3197
 
3198
+ /**
3199
+ * Write an export straight into the backup directory (F18) — the "land the
3200
+ * backup in my cloud-synced folder" button. The default directory is the
3201
+ * store directory, so this works before a custom location is configured too.
3202
+ */
3203
+ const exportToDir = () => enqueue(async () => {
3204
+ const dir = backupDirOf() !== '' ? backupDirOf() : dirname(storePath)
3205
+ const target = join(dir, exportFileName(Date.now()))
3206
+ try {
3207
+ await mkdir(dir, { recursive: true })
3208
+ await writeFile(target, JSON.stringify(exportEnvelope(), null, 2) + '\n', 'utf8')
3209
+ } catch (err) {
3210
+ return { ok: false, saved: false, message: '写入备份目录失败:' + describeError(err), snapshot: snapshot() }
3211
+ }
3212
+ return { ok: true, saved: false, message: '已写入 ' + target, snapshot: snapshot() }
3213
+ })
3214
+
3215
+ /**
3216
+ * The raw store text for 源码模式 (F16): exactly the bytes a save would write.
3217
+ * Editing it is therefore equivalent to editing memory.json — but it goes
3218
+ * through the same loader, version gate, disclosures and rev check as every
3219
+ * other write instead of being trusted because it came from the UI.
3220
+ */
3221
+ const readSource = () => enqueue(async () => {
3222
+ /**
3223
+ * While the file on disk declares an unsupported version, the in-memory
3224
+ * state is a placeholder (nothing was adopted), so handing it out as "the
3225
+ * source" would show an EMPTY store and invite the user to save it over
3226
+ * their real file. The documented way out of that state is to fix the file
3227
+ * (or upgrade the plugin) and hit 重新载入 — so say that instead.
3228
+ */
3229
+ if (state.versionRejected) {
3230
+ return {
3231
+ ok: false,
3232
+ saved: false,
3233
+ message: '存储文件为不支持的格式版本 ' + state.rejectedVersion + ',源码模式已暂停:请先用编辑器修改 '
3234
+ + storePath + '(或升级插件)后点「重新载入」',
3235
+ snapshot: snapshot(),
3236
+ }
3237
+ }
3238
+ // Opening source mode is a deliberate user action, so it is also allowed to
3239
+ // materialise a first-run store (same rule as the `state` method).
3240
+ const materialized = await ensureStoreMaterialized()
3241
+ return { ok: true, saved: materialized === true, message: null, source: serialize(), snapshot: snapshot() }
3242
+ })
3243
+
3244
+ /**
3245
+ * The shared core of 源码保存 (F16) and 整体替换恢复 (F14): adopt a whole
3246
+ * parsed store and commit it in one transaction, reporting every correction
3247
+ * the loader had to make. Callers add their own opening line.
3248
+ */
3249
+ const commitAdoptedStore = async (parsed, head) => {
3250
+ const adopted = adoptParsedStore(parsed)
3251
+ if (adopted.error !== undefined) {
3252
+ return { ok: false, saved: false, message: adopted.error, snapshot: snapshot() }
3253
+ }
3254
+ if (adopted.rejectedVersion !== undefined) {
3255
+ return {
3256
+ ok: false,
3257
+ saved: false,
3258
+ message: '内容声明为格式版本 ' + adopted.rejectedVersion + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
3259
+ + ';已拒绝保存,当前内容未被修改',
3260
+ snapshot: snapshot(),
3261
+ }
3262
+ }
3263
+ const { loaded } = adopted
3264
+ const notes = []
3265
+ const warnings = loadWarnings(loaded)
3266
+ if (warnings !== null) notes.push(warnings)
3267
+ const truncation = truncationNotes(parsed)
3268
+ if (truncation !== null) notes.push(truncation)
3269
+ const saved = await commit(() => {
3270
+ // rev only ever moves forward: a restore is a NEW revision of the store,
3271
+ // so a client holding an older one is refused rather than silently
3272
+ // reverted (and `baseRev` conflict handling stays meaningful).
3273
+ loaded.data.rev = state.data.rev || 0
3274
+ loaded.data.backupDir = backupDirOf()
3275
+ state.data = loaded.data
3276
+ })
3277
+ return settle([head + describeStoreShape(state.data), ...notes].join(';'), saved)
3278
+ }
3279
+
3280
+ const writeSource = (text, baseRev) => enqueue(async () => {
3281
+ if (state.versionRejected) return versionRefusal()
3282
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
3283
+ if (typeof text !== 'string') {
3284
+ return { ok: false, saved: false, message: '源码必须是 JSON 文本', snapshot: snapshot() }
3285
+ }
3286
+ if (text.length > MAX_BODY_CHARS) {
3287
+ return {
3288
+ ok: false,
3289
+ saved: false,
3290
+ message: '源码过长:' + text.length + ' 字符,上限 ' + MAX_BODY_CHARS + ' 字符',
3291
+ snapshot: snapshot(),
3292
+ }
3293
+ }
3294
+ let parsed = null
3295
+ try {
3296
+ parsed = JSON.parse(text)
3297
+ } catch (err) {
3298
+ return { ok: false, saved: false, message: '源码不是有效的 JSON:' + describeError(err), snapshot: snapshot() }
3299
+ }
3300
+ return commitAdoptedStore(parsed, '已按源码保存:')
3301
+ })
3302
+
3303
+ /**
3304
+ * Replace the whole store from a backup file — the "用备份整体替换" door
3305
+ * (F14). Import merges by design and can never restore an older whole-store
3306
+ * state; this is that missing path, and it is explicit: the panel confirms
3307
+ * first, and nothing is unrecoverable (the replaced content stays in
3308
+ * memory.json.bak and, whenever history is writable, in the version list).
3309
+ */
3310
+ const restoreData = (payload, baseRev) => enqueue(async () => {
3311
+ if (state.versionRejected) return versionRefusal()
3312
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
3313
+ if (payload === null || typeof payload !== 'object') {
3314
+ return { ok: false, saved: false, message: '恢复内容不是有效的 JSON 对象', snapshot: snapshot() }
3315
+ }
3316
+ // Accept our own export envelope as well as a bare store object.
3317
+ const inner = payload.data !== null && typeof payload.data === 'object' && !Array.isArray(payload.data)
3318
+ ? payload.data
3319
+ : payload
3320
+ return commitAdoptedStore(inner, '已用备份整体替换当前记忆:')
3321
+ })
3322
+
1935
3323
  const importData = (payload, baseRev) => enqueue(async () => {
1936
3324
  if (state.versionRejected) return versionRefusal()
1937
3325
  if (isStaleBaseRev(baseRev)) return conflictResponse()
@@ -1945,26 +3333,56 @@ export function apply(ctx) {
1945
3333
  : (Array.isArray(inner.entries) ? inner.entries : null)
1946
3334
  const rawProfiles = Array.isArray(payload.profiles) ? payload.profiles
1947
3335
  : (Array.isArray(inner.profiles) ? inner.profiles : null)
3336
+ // Our own export also carries the set list, which is what lets an import
3337
+ // recreate the sets its items belong to instead of dumping everything into
3338
+ // the active one.
3339
+ const rawSets = Array.isArray(payload.sets) ? payload.sets
3340
+ : (Array.isArray(inner.sets) ? inner.sets : null)
1948
3341
  if ((rawEntries === null || rawEntries.length === 0)
1949
3342
  && (rawProfiles === null || rawProfiles.length === 0)) {
1950
3343
  return { ok: false, saved: false, message: '导入文件中没有找到条目或用户形象', snapshot: snapshot() }
1951
3344
  }
3345
+ /**
3346
+ * Set mapping (F13). An imported item is filed under the set it names when
3347
+ * that set already exists here, or when the payload DECLARES it (our own
3348
+ * export carries the sets array) and there is room to create it. Anything
3349
+ * else lands in the active set, and how many did so is disclosed — the
3350
+ * alternative, dropping items whose set is unknown, would be data loss.
3351
+ */
3352
+ const localSetIds = new Set(state.data.sets.map((set) => set.id))
3353
+ const declaredSets = []
3354
+ const declaredIds = new Set()
3355
+ for (const candidate of (Array.isArray(rawSets) ? rawSets : [])) {
3356
+ const set = sanitizeSet(candidate)
3357
+ if (set === null || localSetIds.has(set.id) || declaredIds.has(set.id)) continue
3358
+ declaredIds.add(set.id)
3359
+ declaredSets.push(set)
3360
+ }
3361
+ const activeId = state.data.activeSetId
1952
3362
  // 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.
3363
+ // Duplicates (same id within the same set, or same title+content within the
3364
+ // same set) and invalid items are skipped and counted, not silently
3365
+ // dropped. De-duplicating per set is what makes re-importing idempotent
3366
+ // while still allowing the same instruction to exist in two sets.
1955
3367
  //
1956
3368
  // The whole plan is computed BEFORE the store is touched, so the merge and
1957
3369
  // its counters cannot disagree with what actually gets appended.
1958
3370
  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))
3371
+ const bodyKey = (setId, title, content) => setId + '\u0000' + title + '\u0000' + content
3372
+ const seenIds = new Set(existing.map((entry) => entry.setId + '\u0000' + entry.id))
3373
+ const seenBodies = new Set(existing.map((entry) => bodyKey(entry.setId, entry.title, entry.content)))
1961
3374
  const accepted = []
1962
3375
  const cutFields = new Set()
1963
3376
  let truncatedCount = 0
1964
3377
  let skipped = 0
1965
3378
  for (const candidate of (rawEntries || [])) {
1966
3379
  const entry = sanitizeEntry(candidate)
1967
- if (entry === null || seenIds.has(entry.id) || seenBodies.has(entry.title + '\u0000' + entry.content)) {
3380
+ if (entry === null) {
3381
+ skipped += 1
3382
+ continue
3383
+ }
3384
+ const wanted = entry.setId !== '' ? entry.setId : activeId
3385
+ if (seenIds.has(wanted + '\u0000' + entry.id) || seenBodies.has(bodyKey(wanted, entry.title, entry.content))) {
1968
3386
  skipped += 1
1969
3387
  continue
1970
3388
  }
@@ -1982,25 +3400,29 @@ export function apply(ctx) {
1982
3400
  for (const field of cut) cutFields.add(field)
1983
3401
  }
1984
3402
  entry.updatedAt = Date.now()
1985
- accepted.push(entry)
1986
- seenIds.add(entry.id)
1987
- seenBodies.add(entry.title + '\u0000' + entry.content)
3403
+ accepted.push({ entry, wanted })
3404
+ seenIds.add(wanted + '\u0000' + entry.id)
3405
+ seenBodies.add(bodyKey(wanted, entry.title, entry.content))
1988
3406
  }
1989
3407
  // Persona profiles merge under the same rules (same id, or same
1990
3408
  // name+content) in the SAME transaction, so a mixed import never lands
1991
3409
  // half-way.
1992
3410
  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))
3411
+ const seenProfileIds = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.id))
3412
+ const seenProfileBodies = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.name + '\u0000' + profile.content))
1995
3413
  const acceptedProfiles = []
1996
3414
  const cutProfileFields = new Set()
1997
3415
  let truncatedProfileCount = 0
1998
3416
  let skippedProfiles = 0
1999
3417
  for (const candidate of (rawProfiles || [])) {
2000
3418
  const profile = sanitizeProfile(candidate)
2001
- if (profile === null
2002
- || seenProfileIds.has(profile.id)
2003
- || seenProfileBodies.has(profile.name + '\u0000' + profile.content)) {
3419
+ if (profile === null) {
3420
+ skippedProfiles += 1
3421
+ continue
3422
+ }
3423
+ const wanted = profile.setId !== '' ? profile.setId : activeId
3424
+ if (seenProfileIds.has(wanted + '\u0000' + profile.id)
3425
+ || seenProfileBodies.has(wanted + '\u0000' + profile.name + '\u0000' + profile.content)) {
2004
3426
  skippedProfiles += 1
2005
3427
  continue
2006
3428
  }
@@ -2014,25 +3436,121 @@ export function apply(ctx) {
2014
3436
  for (const field of cut) cutProfileFields.add(field)
2015
3437
  }
2016
3438
  profile.updatedAt = Date.now()
2017
- acceptedProfiles.push(profile)
2018
- seenProfileIds.add(profile.id)
2019
- seenProfileBodies.add(profile.name + '\u0000' + profile.content)
3439
+ acceptedProfiles.push({ profile, wanted })
3440
+ seenProfileIds.add(wanted + '\u0000' + profile.id)
3441
+ seenProfileBodies.add(wanted + '\u0000' + profile.name + '\u0000' + profile.content)
3442
+ }
3443
+ // Create only the declared sets that the accepted items actually reference,
3444
+ // in declaration order, while there is room.
3445
+ const wantedIds = []
3446
+ for (const item of [...accepted, ...acceptedProfiles]) {
3447
+ if (!localSetIds.has(item.wanted) && !wantedIds.includes(item.wanted)) wantedIds.push(item.wanted)
3448
+ }
3449
+ const createdSets = []
3450
+ const createdIds = new Set()
3451
+ for (const id of wantedIds) {
3452
+ if (state.data.sets.length + createdSets.length >= MAX_SETS) break
3453
+ const declared = declaredSets.find((set) => set.id === id)
3454
+ if (declared === undefined) continue
3455
+ createdSets.push({ id: declared.id, name: declared.name })
3456
+ createdIds.add(declared.id)
2020
3457
  }
2021
- if (accepted.length === 0 && acceptedProfiles.length === 0) {
3458
+ let fellBack = 0
3459
+ const finalIdOf = (wanted) => (localSetIds.has(wanted) || createdIds.has(wanted) ? wanted : activeId)
3460
+ /**
3461
+ * Fallback de-duplication (review fix). The plan phase de-duplicated
3462
+ * against the set each item DECLARED, but finalIdOf() may retarget an item
3463
+ * into the active set (its set is unknown here, or the MAX_SETS cap blocked
3464
+ * creating it). Every retargeted item is therefore re-checked against the
3465
+ * set it REALLY lands in — pre-existing content, same-batch items already
3466
+ * staged there, and same-batch items that reached it through the normal
3467
+ * path — before anything is appended. The normal path (set exists or was
3468
+ * created) keeps the plan-phase decision untouched, which is what keeps
3469
+ * re-importing the same export file a no-op.
3470
+ */
3471
+ const stagedEntries = []
3472
+ const stagedProfiles = []
3473
+ let dedupedOnFallback = 0
3474
+ {
3475
+ const finalSeenIds = new Set(existing.map((entry) => entry.setId + '\u0000' + entry.id))
3476
+ const finalSeenBodies = new Set(existing.map((entry) => bodyKey(entry.setId, entry.title, entry.content)))
3477
+ // Items the plan phase accepted under their declared set occupy that
3478
+ // set's id/body space even when the item itself later falls back (a
3479
+ // fallback item's declared set is neither local nor created, so the keys
3480
+ // never collide with these — they only guard the normal path).
3481
+ for (const item of accepted) {
3482
+ finalSeenIds.add(item.wanted + '\u0000' + item.entry.id)
3483
+ finalSeenBodies.add(bodyKey(item.wanted, item.entry.title, item.entry.content))
3484
+ }
3485
+ for (const item of accepted) {
3486
+ const finalId = finalIdOf(item.wanted)
3487
+ if (finalId !== item.wanted) {
3488
+ if (finalSeenBodies.has(bodyKey(finalId, item.entry.title, item.entry.content))) {
3489
+ dedupedOnFallback += 1
3490
+ continue
3491
+ }
3492
+ if (finalSeenIds.has(finalId + '\u0000' + item.entry.id)) {
3493
+ // The body is new to the fallback target but the id is taken
3494
+ // there: re-mint instead of writing a duplicate id into one set.
3495
+ item.entry.id = makeId()
3496
+ }
3497
+ fellBack += 1
3498
+ finalSeenIds.add(finalId + '\u0000' + item.entry.id)
3499
+ finalSeenBodies.add(bodyKey(finalId, item.entry.title, item.entry.content))
3500
+ }
3501
+ item.entry.setId = finalId
3502
+ stagedEntries.push(item.entry)
3503
+ }
3504
+ const finalSeenProfileIds = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.id))
3505
+ const finalSeenProfileBodies = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.name + '\u0000' + profile.content))
3506
+ for (const item of acceptedProfiles) {
3507
+ finalSeenProfileIds.add(item.wanted + '\u0000' + item.profile.id)
3508
+ finalSeenProfileBodies.add(item.wanted + '\u0000' + item.profile.name + '\u0000' + item.profile.content)
3509
+ }
3510
+ for (const item of acceptedProfiles) {
3511
+ const finalId = finalIdOf(item.wanted)
3512
+ if (finalId !== item.wanted) {
3513
+ if (finalSeenProfileBodies.has(finalId + '\u0000' + item.profile.name + '\u0000' + item.profile.content)) {
3514
+ dedupedOnFallback += 1
3515
+ continue
3516
+ }
3517
+ if (finalSeenProfileIds.has(finalId + '\u0000' + item.profile.id)) {
3518
+ item.profile.id = makeId()
3519
+ }
3520
+ fellBack += 1
3521
+ finalSeenProfileIds.add(finalId + '\u0000' + item.profile.id)
3522
+ finalSeenProfileBodies.add(finalId + '\u0000' + item.profile.name + '\u0000' + item.profile.content)
3523
+ }
3524
+ item.profile.setId = finalId
3525
+ stagedProfiles.push(item.profile)
3526
+ }
3527
+ }
3528
+ if (stagedEntries.length === 0 && stagedProfiles.length === 0) {
2022
3529
  // Nothing was written to disk, so claiming saved:true would be a lie the
2023
3530
  // panel cannot distinguish from a real save.
2024
3531
  return {
2025
3532
  ok: true,
2026
3533
  saved: false,
2027
- message: '没有新增内容:' + skipped + ' 条条目、' + skippedProfiles + ' 个用户形象已存在或无效',
3534
+ message: '没有新增内容:' + skipped + ' 条条目、' + skippedProfiles + ' 个用户形象已存在或无效'
3535
+ + (dedupedOnFallback > 0 ? ';另有 ' + dedupedOnFallback + ' 项因回退到当前配置集后与已有内容重复被跳过' : ''),
2028
3536
  snapshot: snapshot(),
2029
3537
  }
2030
3538
  }
2031
3539
  const saved = await commit(() => {
2032
- state.data.entries.push(...accepted)
2033
- state.data.profiles.push(...acceptedProfiles)
3540
+ state.data.sets.push(...createdSets)
3541
+ for (const entry of stagedEntries) state.data.entries.push(entry)
3542
+ for (const profile of stagedProfiles) state.data.profiles.push(profile)
2034
3543
  })
2035
3544
  const parts = []
3545
+ if (createdSets.length > 0) {
3546
+ parts.push('新建配置集 ' + createdSets.length + ' 个(' + createdSets.map((set) => '「' + set.name + '」').join('、') + ')')
3547
+ }
3548
+ if (fellBack > 0) {
3549
+ parts.push('有 ' + fellBack + ' 项因原配置集不存在或已达 ' + MAX_SETS + ' 个上限,已归入当前配置集')
3550
+ }
3551
+ if (dedupedOnFallback > 0) {
3552
+ parts.push('另有 ' + dedupedOnFallback + ' 项因回退到当前配置集后与已有内容重复被跳过')
3553
+ }
2036
3554
  if (accepted.length > 0) {
2037
3555
  const truncated = truncatedCount > 0
2038
3556
  ? ';其中 ' + truncatedCount + ' 条超过长度上限,' + [...cutFields].join('、') + '已被截断'
@@ -2078,6 +3596,43 @@ export function apply(ctx) {
2078
3596
  args !== null && typeof args === 'object' ? args.baseRev : undefined)
2079
3597
  case 'set-options':
2080
3598
  return setOptions(args, args !== null && typeof args === 'object' ? args.baseRev : undefined)
3599
+ case 'save-set':
3600
+ return saveSet(
3601
+ args !== null && typeof args === 'object' ? args.set : null,
3602
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
3603
+ case 'delete-set':
3604
+ return deleteSet(
3605
+ args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
3606
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
3607
+ case 'set-active-set':
3608
+ return setActiveSet(
3609
+ args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
3610
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
3611
+ case 'duplicate-set':
3612
+ return duplicateSet(
3613
+ args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
3614
+ args !== null && typeof args === 'object' ? args.name : undefined,
3615
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
3616
+ case 'history-list':
3617
+ return historyList()
3618
+ case 'history-restore':
3619
+ return historyRestore(
3620
+ args !== null && typeof args === 'object' && typeof args.name === 'string' ? args.name : '',
3621
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
3622
+ case 'history-delete':
3623
+ return historyDelete(args !== null && typeof args === 'object' && typeof args.name === 'string' ? args.name : '')
3624
+ case 'read-source':
3625
+ return readSource()
3626
+ case 'write-source':
3627
+ return writeSource(
3628
+ args !== null && typeof args === 'object' ? args.text : null,
3629
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
3630
+ case 'restore-data':
3631
+ return restoreData(
3632
+ args !== null && typeof args === 'object' ? args.payload : null,
3633
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
3634
+ case 'export-to-dir':
3635
+ return exportToDir()
2081
3636
  case 'reload':
2082
3637
  return reload()
2083
3638
  case 'export-data':
@@ -2107,11 +3662,8 @@ export function apply(ctx) {
2107
3662
 
2108
3663
  /* ---------------- same-origin JSON route for the Client half ---------------- */
2109
3664
 
2110
- // Which Host values this route accepts. Set when the route is registered,
2111
- // from the address the web server is actually bound to (see the long note on
2112
- // requestOriginVerdict). Until then the route does not exist at all, so the
2113
- // default only matters if something registers it out of band.
2114
- let guardHost = true
3665
+ // `guardHost` is declared next to `state` (F12 publishes it as
3666
+ // `security.hostGuarded`); only `routeDispose` lives here.
2115
3667
 
2116
3668
  let routeDispose = null
2117
3669
 
@@ -2286,6 +3838,9 @@ export function apply(ctx) {
2286
3838
  // the README.
2287
3839
  const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : ''
2288
3840
  guardHost = boundHost === '' || isLoopbackName(boundHost)
3841
+ // F12: the settings page can only warn about a non-loopback bind if the
3842
+ // snapshot carries what the route actually saw at registration time.
3843
+ state.boundHost = boundHost
2289
3844
  try {
2290
3845
  routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
2291
3846
  logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')