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/CHANGELOG.md +139 -0
- package/README.md +191 -13
- package/lib/client.js +1082 -5
- package/lib/index.js +1642 -79
- package/memory.schema.json +167 -0
- package/package.json +3 -2
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)
|
|
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
|
-
*
|
|
257
|
-
* untouched
|
|
258
|
-
* adopting a misread would let the next save
|
|
259
|
-
* shape. A MISSING version still loads as v1 —
|
|
260
|
-
* 1 since the first release, so absence is an
|
|
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
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
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 = {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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: {
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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
|
-
: '上一版未能保留为
|
|
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
|
|
1178
|
-
* the newer format meant (the mutating commands refuse
|
|
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 + '
|
|
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:
|
|
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
|
-
|
|
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: {
|
|
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
|
|
1474
|
-
* format meant. The file stays untouched until the user upgrades
|
|
1475
|
-
* or restores a
|
|
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 + ',修改已暂停:现在保存会用版本
|
|
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 {
|
|
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
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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}})
|
|
1728
|
-
// a bare {entries}
|
|
1729
|
-
const
|
|
1730
|
-
|
|
1731
|
-
: (
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
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
|
|
1739
|
-
// skipped and counted, not silently
|
|
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
|
|
1745
|
-
const
|
|
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
|
|
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
|
|
3198
|
+
accepted.push({ entry, wanted })
|
|
3199
|
+
seenIds.add(wanted + '\u0000' + entry.id)
|
|
3200
|
+
seenBodies.add(bodyKey(wanted, entry.title, entry.content))
|
|
1773
3201
|
}
|
|
1774
|
-
|
|
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 {
|
|
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.
|
|
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
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
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
|
}
|