dsh-instruction-memory 1.0.18 → 1.2.0

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