dsh-instruction-memory 1.0.17 → 1.1.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 +60 -0
- package/README.md +35 -3
- package/lib/client.js +670 -47
- package/lib/index.js +465 -26
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -92,6 +92,27 @@ const MAX_ENTRIES = 200
|
|
|
92
92
|
const MAX_TITLE = 120
|
|
93
93
|
const MAX_CONTENT = 6000
|
|
94
94
|
const MAX_WHEN = 200
|
|
95
|
+
/** Tags are short grouping labels for the settings page only; they never enter the injected block. */
|
|
96
|
+
const MAX_TAGS_PER_ENTRY = 8
|
|
97
|
+
const MAX_TAG_LENGTH = 24
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* User-persona profiles ("用户形象"): facts about the USER (background,
|
|
101
|
+
* preferences, personality, goals) used to tilt how answers are framed. They
|
|
102
|
+
* are NOT roles the model is asked to play, and they are injected ahead of the
|
|
103
|
+
* instruction block under a header that demotes them to "tilt only, never
|
|
104
|
+
* override correctness or the question itself". They do not take part in the
|
|
105
|
+
* per-entry budget trimming: an identity must never be silently dropped.
|
|
106
|
+
*/
|
|
107
|
+
const MAX_PROFILES = 50
|
|
108
|
+
const MAX_PROFILE_NAME = 40
|
|
109
|
+
const MAX_PROFILE_CONTENT = 4000
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The batch operations the `batch` route accepts. Kept here so the command and
|
|
113
|
+
* the route's dispatch share one list.
|
|
114
|
+
*/
|
|
115
|
+
const BATCH_OPS = ['set-enabled', 'set-priority', 'add-tags', 'remove-tags', 'delete']
|
|
95
116
|
|
|
96
117
|
/**
|
|
97
118
|
* Request body ceiling for the settings-page route; anything larger is refused
|
|
@@ -106,7 +127,8 @@ const MAX_WHEN = 200
|
|
|
106
127
|
* the flat slack covers the `{method,args:{payload:{app,…,data:{entries:[…]}}}}`
|
|
107
128
|
* wrapper.
|
|
108
129
|
*/
|
|
109
|
-
export const MAX_BODY_CHARS = MAX_ENTRIES * (MAX_CONTENT + MAX_TITLE + MAX_WHEN +
|
|
130
|
+
export const MAX_BODY_CHARS = (MAX_ENTRIES * (MAX_CONTENT + MAX_TITLE + MAX_WHEN + MAX_TAGS_PER_ENTRY * MAX_TAG_LENGTH + 200)
|
|
131
|
+
+ MAX_PROFILES * (MAX_PROFILE_CONTENT + MAX_PROFILE_NAME + 200)) * 2 + 100_000
|
|
110
132
|
|
|
111
133
|
/** How long a crashed holder's `memory.json.lock` may block writes before it is stolen. */
|
|
112
134
|
const LOCK_STALE_MS = 10_000
|
|
@@ -158,6 +180,34 @@ function priorityLabel(value) {
|
|
|
158
180
|
return '普通'
|
|
159
181
|
}
|
|
160
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Normalise an entry's `tags`: a de-duplicated, single-line, capped string
|
|
185
|
+
* array used ONLY by the settings page for grouping/filtering. Tags never
|
|
186
|
+
* appear in the injected block, so they do not touch buildBlock. Exported for
|
|
187
|
+
* tests and reused by the batch "add-tags" path so both merge through one
|
|
188
|
+
* rule. Non-array input (an old/hand-made entry without tags) is simply no
|
|
189
|
+
* tags, not an error; non-string/blank items are dropped; anything past
|
|
190
|
+
* MAX_TAGS_PER_ENTRY is ignored after de-duplication.
|
|
191
|
+
*/
|
|
192
|
+
export function sanitizeTags(raw) {
|
|
193
|
+
if (!Array.isArray(raw)) return []
|
|
194
|
+
const out = []
|
|
195
|
+
const seen = new Set()
|
|
196
|
+
for (const item of raw) {
|
|
197
|
+
if (typeof item !== 'string') continue
|
|
198
|
+
// Collapse ALL whitespace runs (newlines and repeated spaces) so the same
|
|
199
|
+
// typed label normalises identically whether it came from the editor's
|
|
200
|
+
// tag box, an import, or a hand edit — otherwise "a b" and "a b" would
|
|
201
|
+
// both survive and fail to de-duplicate.
|
|
202
|
+
const tag = singleLine(item, MAX_TAG_LENGTH).replace(/\s+/g, ' ').trim()
|
|
203
|
+
if (tag === '' || seen.has(tag)) continue
|
|
204
|
+
seen.add(tag)
|
|
205
|
+
if (out.length >= MAX_TAGS_PER_ENTRY) break
|
|
206
|
+
out.push(tag)
|
|
207
|
+
}
|
|
208
|
+
return out
|
|
209
|
+
}
|
|
210
|
+
|
|
161
211
|
export function sanitizeEntry(raw) {
|
|
162
212
|
if (raw === null || typeof raw !== 'object') return null
|
|
163
213
|
const title = singleLine(raw.title, MAX_TITLE)
|
|
@@ -171,6 +221,31 @@ export function sanitizeEntry(raw) {
|
|
|
171
221
|
when: singleLine(raw.when, MAX_WHEN),
|
|
172
222
|
priority: raw.priority === 2 || raw.priority === 0 ? raw.priority : 1,
|
|
173
223
|
enabled: raw.enabled !== false,
|
|
224
|
+
tags: sanitizeTags(raw.tags),
|
|
225
|
+
updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Normalise one "用户形象" (a fact about the USER, not a role for the model).
|
|
231
|
+
*
|
|
232
|
+
* Shape: { id, name, content, enabled, updatedAt }. `name` is a short single-
|
|
233
|
+
* line topic label ("职业背景"、"沟通偏好"、"性格"、"长期追求"); `content` is
|
|
234
|
+
* the multi-line description. Both blank means the profile is discarded
|
|
235
|
+
* (returns null) the same way a blank entry is. A blank name falls back to the
|
|
236
|
+
* first 20 characters of the content so an unnamed profile is still listable.
|
|
237
|
+
* Exported for tests.
|
|
238
|
+
*/
|
|
239
|
+
export function sanitizeProfile(raw) {
|
|
240
|
+
if (raw === null || typeof raw !== 'object') return null
|
|
241
|
+
const name = singleLine(raw.name, MAX_PROFILE_NAME)
|
|
242
|
+
const content = cleanText(raw.content, MAX_PROFILE_CONTENT)
|
|
243
|
+
if (name === '' && content === '') return null
|
|
244
|
+
return {
|
|
245
|
+
id: typeof raw.id === 'string' && raw.id !== '' ? raw.id.slice(0, 64) : makeId(),
|
|
246
|
+
name: name === '' ? singleLine(content, 20) : name,
|
|
247
|
+
content,
|
|
248
|
+
enabled: raw.enabled !== false,
|
|
174
249
|
updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
|
|
175
250
|
}
|
|
176
251
|
}
|
|
@@ -197,6 +272,16 @@ export function truncatedFields(raw) {
|
|
|
197
272
|
return cut
|
|
198
273
|
}
|
|
199
274
|
|
|
275
|
+
/** Same disclosure as truncatedFields, but for a profile's name/content. */
|
|
276
|
+
export function truncatedProfileFields(raw) {
|
|
277
|
+
if (raw === null || typeof raw !== 'object') return []
|
|
278
|
+
const cut = []
|
|
279
|
+
const unlimited = Number.MAX_SAFE_INTEGER
|
|
280
|
+
if (typeof raw.name === 'string' && singleLine(raw.name, unlimited) !== singleLine(raw.name, MAX_PROFILE_NAME)) cut.push('形象名称')
|
|
281
|
+
if (typeof raw.content === 'string' && cleanText(raw.content, unlimited) !== cleanText(raw.content, MAX_PROFILE_CONTENT)) cut.push('形象内容')
|
|
282
|
+
return cut
|
|
283
|
+
}
|
|
284
|
+
|
|
200
285
|
/** JSON.stringify keeps quotes/type visible; the String() tail is for values JSON cannot encode. */
|
|
201
286
|
const describeValue = (value) => {
|
|
202
287
|
const text = JSON.stringify(value)
|
|
@@ -229,7 +314,7 @@ const describeValue = (value) => {
|
|
|
229
314
|
* user must be told their hand edit did not survive verbatim.
|
|
230
315
|
*/
|
|
231
316
|
function normalizeData(raw) {
|
|
232
|
-
const data = { version: 1, rev: 0, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] }
|
|
317
|
+
const data = { version: 1, rev: 0, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [], profiles: [] }
|
|
233
318
|
if (raw === null || typeof raw !== 'object') return { data, dropped: 0, optionFixes: [] }
|
|
234
319
|
if (typeof raw.version === 'number' && raw.version !== 1) {
|
|
235
320
|
return { data, dropped: 0, optionFixes: [], rejectedVersion: raw.version }
|
|
@@ -272,7 +357,24 @@ function normalizeData(raw) {
|
|
|
272
357
|
seen.add(entry.id)
|
|
273
358
|
data.entries.push(entry)
|
|
274
359
|
}
|
|
275
|
-
|
|
360
|
+
// User profiles ("用户形象") follow the same tolerant rules: a missing
|
|
361
|
+
// field on an old/hand-made file is just an empty list; blank or
|
|
362
|
+
// over-cap profiles are dropped and counted for disclosure, never adopted
|
|
363
|
+
// silently (the next save rewrites the file without them).
|
|
364
|
+
const profileList = Array.isArray(raw.profiles) ? raw.profiles : []
|
|
365
|
+
const seenProfileIds = new Set()
|
|
366
|
+
let droppedProfiles = 0
|
|
367
|
+
for (const candidate of profileList) {
|
|
368
|
+
const profile = sanitizeProfile(candidate)
|
|
369
|
+
if (profile === null || data.profiles.length >= MAX_PROFILES) {
|
|
370
|
+
droppedProfiles += 1
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
if (seenProfileIds.has(profile.id)) profile.id = makeId()
|
|
374
|
+
seenProfileIds.add(profile.id)
|
|
375
|
+
data.profiles.push(profile)
|
|
376
|
+
}
|
|
377
|
+
return { data, dropped, droppedProfiles, optionFixes }
|
|
276
378
|
}
|
|
277
379
|
|
|
278
380
|
/**
|
|
@@ -291,6 +393,7 @@ function cloneData(data) {
|
|
|
291
393
|
enabled: data.enabled === true,
|
|
292
394
|
budgetChars: data.budgetChars,
|
|
293
395
|
entries: (Array.isArray(data.entries) ? data.entries : []).map((entry) => ({ ...entry })),
|
|
396
|
+
profiles: (Array.isArray(data.profiles) ? data.profiles : []).map((profile) => ({ ...profile })),
|
|
294
397
|
}
|
|
295
398
|
}
|
|
296
399
|
|
|
@@ -299,6 +402,11 @@ const droppedEntriesMessage = (count) =>
|
|
|
299
402
|
? '存储文件中有 ' + count + ' 条条目未能载入(内容为空或超过 ' + MAX_ENTRIES + ' 条上限);下次保存时它们将从文件中移除'
|
|
300
403
|
: null
|
|
301
404
|
|
|
405
|
+
const droppedProfilesMessage = (count) =>
|
|
406
|
+
count > 0
|
|
407
|
+
? '存储文件中有 ' + count + ' 个用户形象未能载入(名称与内容同时为空或超过 ' + MAX_PROFILES + ' 个上限);下次保存时它们将从文件中移除'
|
|
408
|
+
: null
|
|
409
|
+
|
|
302
410
|
/**
|
|
303
411
|
* The load-time disclosures for one normalizeData result: dropped entries and
|
|
304
412
|
* corrected hand-edited options, joined with a full-width semicolon.
|
|
@@ -309,6 +417,8 @@ const loadWarnings = (loaded) => {
|
|
|
309
417
|
const parts = []
|
|
310
418
|
const dropped = droppedEntriesMessage(loaded.dropped)
|
|
311
419
|
if (dropped !== null) parts.push(dropped)
|
|
420
|
+
const droppedProfiles = droppedProfilesMessage(loaded.droppedProfiles || 0)
|
|
421
|
+
if (droppedProfiles !== null) parts.push(droppedProfiles)
|
|
312
422
|
if (Array.isArray(loaded.optionFixes) && loaded.optionFixes.length > 0) {
|
|
313
423
|
parts.push('存储文件中的以下选项值无效,已按规则修正:' + loaded.optionFixes.join(';') + ';下次保存时修正值将写入文件')
|
|
314
424
|
}
|
|
@@ -544,9 +654,53 @@ function formatEntry(entry, tag) {
|
|
|
544
654
|
* competing for the budget at all. When nothing renders, `text` is '' and
|
|
545
655
|
* `included` is [] even though `budget` still reports the effective ceiling.
|
|
546
656
|
*/
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
657
|
+
/**
|
|
658
|
+
* The persona ("用户形象") preamble. One fixed header whether one or several
|
|
659
|
+
* profiles are active: each is a SIDE of the same user (background, tastes,
|
|
660
|
+
* personality, goals), so there is no "play multiple roles" framing. The
|
|
661
|
+
* wording deliberately demotes the whole section to a tilt on framing —
|
|
662
|
+
* correctness, completeness and the question itself always win.
|
|
663
|
+
*/
|
|
664
|
+
const PERSONA_HEADER = [
|
|
665
|
+
'## 关于用户(用户形象)',
|
|
666
|
+
'以下是用户本人提供的个人形象信息,描述其身份背景、喜好、性格与追求,不是要求你扮演的角色。请在保证回答正确、完整、客观且紧扣问题本身的前提下,结合这些信息调整解释的深浅、详略、举例与建议的侧重;不得为了迎合这些信息而改变事实、省略必要的风险提示或偏离问题核心;与当前问题无关时直接忽略。',
|
|
667
|
+
'',
|
|
668
|
+
].join('\n')
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Build the persona section from enabled profiles with non-empty content, in
|
|
672
|
+
* stored order. Returns the rendered text plus one { id, name, chars } item
|
|
673
|
+
* per rendered profile, in the same shape the snapshot exposes. Deterministic:
|
|
674
|
+
* no timestamps, no counts, identical profiles in produce byte-identical
|
|
675
|
+
* text. An empty/disabled set renders to '' so callers can join unconditionally.
|
|
676
|
+
*/
|
|
677
|
+
function buildProfilesBlock(profiles) {
|
|
678
|
+
const active = (Array.isArray(profiles) ? profiles : [])
|
|
679
|
+
.filter((profile) => profile !== null && typeof profile === 'object'
|
|
680
|
+
&& profile.enabled === true
|
|
681
|
+
&& typeof profile.content === 'string' && profile.content !== '')
|
|
682
|
+
if (active.length === 0) return { text: '', profiles: [], chars: 0 }
|
|
683
|
+
const blocks = []
|
|
684
|
+
const items = []
|
|
685
|
+
for (const profile of active) {
|
|
686
|
+
const name = typeof profile.name === 'string' && profile.name !== ''
|
|
687
|
+
? profile.name
|
|
688
|
+
: singleLine(profile.content, 20)
|
|
689
|
+
const block = '### ' + name + '\n' + profile.content
|
|
690
|
+
blocks.push(block)
|
|
691
|
+
items.push({ id: typeof profile.id === 'string' ? profile.id : '', name, chars: block.length })
|
|
692
|
+
}
|
|
693
|
+
const text = PERSONA_HEADER + '\n' + blocks.join('\n\n')
|
|
694
|
+
return { text, profiles: items, chars: text.length }
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* The entry half of the plan: the existing instruction block with its budget
|
|
699
|
+
* trimming. Kept as the inner function so planInjection can prepend the
|
|
700
|
+
* persona section without touching (and risking) the trimming logic.
|
|
701
|
+
*/
|
|
702
|
+
function planEntries(data, budget) {
|
|
703
|
+
const blank = () => ({ text: '', activeCount: 0, included: [], omitted: [] })
|
|
550
704
|
if (data === null || typeof data !== 'object' || data.enabled !== true) return blank()
|
|
551
705
|
const active = (Array.isArray(data.entries) ? data.entries : [])
|
|
552
706
|
.filter((entry) => entry.enabled === true && entry.content !== '')
|
|
@@ -641,6 +795,48 @@ export function planInjection(data) {
|
|
|
641
795
|
}
|
|
642
796
|
}
|
|
643
797
|
|
|
798
|
+
/**
|
|
799
|
+
* Compose the full injected section: the persona block ("关于用户", when at
|
|
800
|
+
* least one profile is enabled) followed by the existing instruction block.
|
|
801
|
+
*
|
|
802
|
+
* Returned shape extends the entry plan with:
|
|
803
|
+
* profiles: [{ id, name, chars }] — rendered persona profiles, in order
|
|
804
|
+
* profileChars: total characters of the persona section (0 when none)
|
|
805
|
+
* entryChars: length of the entry half alone
|
|
806
|
+
*
|
|
807
|
+
* Guarantees:
|
|
808
|
+
* - zero enabled profiles => the result is byte-identical to the old
|
|
809
|
+
* entries-only plan (existing tests pin this);
|
|
810
|
+
* - personas are NOT subject to the entry budget — the budget/included/
|
|
811
|
+
* omitted fields still describe instructions only, and an identity is
|
|
812
|
+
* never silently trimmed;
|
|
813
|
+
* - a profile-only store (no instructions) still injects, and the master
|
|
814
|
+
* `enabled` switch suppresses both halves as before.
|
|
815
|
+
*/
|
|
816
|
+
export function planInjection(data) {
|
|
817
|
+
const budget = clampBudget(data !== null && typeof data === 'object' ? data.budgetChars : undefined)
|
|
818
|
+
const blank = () => ({
|
|
819
|
+
text: '', budget, activeCount: 0, included: [], omitted: [],
|
|
820
|
+
profiles: [], profileChars: 0, entryChars: 0,
|
|
821
|
+
})
|
|
822
|
+
if (data === null || typeof data !== 'object' || data.enabled !== true) return blank()
|
|
823
|
+
const persona = buildProfilesBlock(data.profiles)
|
|
824
|
+
const entryPlan = planEntries(data, budget)
|
|
825
|
+
const parts = []
|
|
826
|
+
if (persona.text !== '') parts.push(persona.text)
|
|
827
|
+
if (entryPlan.text !== '') parts.push(entryPlan.text)
|
|
828
|
+
return {
|
|
829
|
+
text: parts.join('\n\n'),
|
|
830
|
+
budget,
|
|
831
|
+
activeCount: entryPlan.activeCount,
|
|
832
|
+
included: entryPlan.included,
|
|
833
|
+
omitted: entryPlan.omitted,
|
|
834
|
+
entryChars: entryPlan.text.length,
|
|
835
|
+
profiles: persona.profiles,
|
|
836
|
+
profileChars: persona.chars,
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
644
840
|
/**
|
|
645
841
|
* Render the injected block text. A thin wrapper over planInjection: the
|
|
646
842
|
* decision process lives in one place, and callers that only need the text
|
|
@@ -658,7 +854,7 @@ export function apply(ctx) {
|
|
|
658
854
|
const systemPrompt = ctx.systemPrompt
|
|
659
855
|
|
|
660
856
|
const state = {
|
|
661
|
-
data: { version: 1, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] },
|
|
857
|
+
data: { version: 1, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [], profiles: [] },
|
|
662
858
|
storagePath: null,
|
|
663
859
|
storageDisplay: null,
|
|
664
860
|
storageError: null,
|
|
@@ -781,7 +977,10 @@ export function apply(ctx) {
|
|
|
781
977
|
return planInjection(state.data)
|
|
782
978
|
} catch (err) {
|
|
783
979
|
console.error('[instruction-memory] render failed:', err)
|
|
784
|
-
return {
|
|
980
|
+
return {
|
|
981
|
+
text: '', budget: clampBudget(state.data.budgetChars), activeCount: 0, included: [], omitted: [],
|
|
982
|
+
profiles: [], profileChars: 0, entryChars: 0,
|
|
983
|
+
}
|
|
785
984
|
}
|
|
786
985
|
}
|
|
787
986
|
|
|
@@ -1356,6 +1555,7 @@ export function apply(ctx) {
|
|
|
1356
1555
|
enabled: state.data.enabled === true,
|
|
1357
1556
|
budgetChars: clampBudget(state.data.budgetChars),
|
|
1358
1557
|
entries: (Array.isArray(state.data.entries) ? state.data.entries : []).map((entry) => ({ ...entry })),
|
|
1558
|
+
profiles: (Array.isArray(state.data.profiles) ? state.data.profiles : []).map((profile) => ({ ...profile })),
|
|
1359
1559
|
},
|
|
1360
1560
|
storage: {
|
|
1361
1561
|
path: state.storageDisplay !== null ? state.storageDisplay : state.storagePath,
|
|
@@ -1381,6 +1581,15 @@ export function apply(ctx) {
|
|
|
1381
1581
|
activeCount: plan.activeCount,
|
|
1382
1582
|
included: plan.included.map((item) => ({ id: item.id, chars: item.chars, truncated: item.truncated === true })),
|
|
1383
1583
|
omitted: plan.omitted.map((item) => ({ id: item.id, chars: item.chars })),
|
|
1584
|
+
/**
|
|
1585
|
+
* Persona section detail: rendered profiles (id/name/chars, in order)
|
|
1586
|
+
* and their total size. `entryChars` is the instruction half alone,
|
|
1587
|
+
* which is what the budget meter measures; `chars` above stays the
|
|
1588
|
+
* whole section (personas + instructions) for diagnostics.
|
|
1589
|
+
*/
|
|
1590
|
+
profiles: plan.profiles.map((item) => ({ id: item.id, name: item.name, chars: item.chars })),
|
|
1591
|
+
profileChars: plan.profileChars,
|
|
1592
|
+
entryChars: plan.entryChars,
|
|
1384
1593
|
/**
|
|
1385
1594
|
* How many times the harness has pulled this section into a prompt since
|
|
1386
1595
|
* boot. 0 means "prepared, never assembled" — the distinction the old
|
|
@@ -1395,7 +1604,15 @@ export function apply(ctx) {
|
|
|
1395
1604
|
* into memory. It is a character budget against byte sizes, so the
|
|
1396
1605
|
* Client applies a generous multiplier — see importSizeVerdict there.
|
|
1397
1606
|
*/
|
|
1398
|
-
limits: {
|
|
1607
|
+
limits: {
|
|
1608
|
+
minBudget: MIN_BUDGET,
|
|
1609
|
+
maxBudget: MAX_BUDGET,
|
|
1610
|
+
maxEntries: MAX_ENTRIES,
|
|
1611
|
+
maxProfiles: MAX_PROFILES,
|
|
1612
|
+
maxProfileName: MAX_PROFILE_NAME,
|
|
1613
|
+
maxProfileContent: MAX_PROFILE_CONTENT,
|
|
1614
|
+
maxBodyChars: MAX_BODY_CHARS,
|
|
1615
|
+
},
|
|
1399
1616
|
}
|
|
1400
1617
|
}
|
|
1401
1618
|
|
|
@@ -1539,6 +1756,162 @@ export function apply(ctx) {
|
|
|
1539
1756
|
return settle(null, saved)
|
|
1540
1757
|
})
|
|
1541
1758
|
|
|
1759
|
+
/**
|
|
1760
|
+
* Apply ONE operation to many entries in a single transaction.
|
|
1761
|
+
*
|
|
1762
|
+
* The settings page's multi-select must not loop save-entry/delete-entry:
|
|
1763
|
+
* that would bump `rev`, write the file and broadcast an SSE frame once per
|
|
1764
|
+
* id. Instead the whole batch is planned first (only entries that actually
|
|
1765
|
+
* change are touched — an all-no-op batch, like deleting ids that are not
|
|
1766
|
+
* there, writes nothing and keeps `rev` still), then committed through the
|
|
1767
|
+
* same commit()/writeToDisk() path as every other mutation, so the lock, rev
|
|
1768
|
+
* guard, rollback-on-failed-write and broadcast all apply once.
|
|
1769
|
+
*
|
|
1770
|
+
* `value` semantics per op: set-enabled -> boolean; set-priority -> 0|1|2;
|
|
1771
|
+
* add-tags/remove-tags -> string[] (run through sanitizeTags); delete ->
|
|
1772
|
+
* unused. Batch edits deliberately do NOT re-stamp updatedAt: enabling a
|
|
1773
|
+
* group or tagging it must not reshuffle the within-priority injection
|
|
1774
|
+
* order, which is tied to updatedAt.
|
|
1775
|
+
*/
|
|
1776
|
+
const batchEntries = (op, ids, value, baseRev) => enqueue(async () => {
|
|
1777
|
+
if (state.versionRejected) return versionRefusal()
|
|
1778
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
1779
|
+
if (typeof op !== 'string' || !BATCH_OPS.includes(op)) {
|
|
1780
|
+
return { ok: false, saved: false, message: '未知的批量操作:' + String(op), snapshot: snapshot() }
|
|
1781
|
+
}
|
|
1782
|
+
const uniqueIds = Array.isArray(ids)
|
|
1783
|
+
? [...new Set(ids.filter((id) => typeof id === 'string' && id !== ''))]
|
|
1784
|
+
: []
|
|
1785
|
+
if (uniqueIds.length === 0) {
|
|
1786
|
+
return { ok: false, saved: false, message: '没有选择任何条目', snapshot: snapshot() }
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
const list = state.data.entries
|
|
1790
|
+
const targets = list.filter((entry) => uniqueIds.includes(entry.id))
|
|
1791
|
+
const missing = uniqueIds.length - targets.length
|
|
1792
|
+
const missingNote = missing > 0
|
|
1793
|
+
? '(另有 ' + missing + ' 条未找到,可能已被其他窗口或进程修改)'
|
|
1794
|
+
: ''
|
|
1795
|
+
|
|
1796
|
+
if (op === 'delete') {
|
|
1797
|
+
// Deleting ids that are all absent changes nothing — mirror single
|
|
1798
|
+
// deleteEntry's no-write behaviour rather than manufacturing a save.
|
|
1799
|
+
if (targets.length === 0) {
|
|
1800
|
+
return { ok: true, saved: false, message: null, snapshot: snapshot() }
|
|
1801
|
+
}
|
|
1802
|
+
const gone = new Set(targets.map((entry) => entry.id))
|
|
1803
|
+
const affected = targets.length
|
|
1804
|
+
const saved = await commit(() => {
|
|
1805
|
+
state.data.entries = state.data.entries.filter((entry) => !gone.has(entry.id))
|
|
1806
|
+
})
|
|
1807
|
+
return settle('已删除 ' + affected + ' 条指令' + missingNote, saved)
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// Pure planners: return the patch to apply, or null when this entry is
|
|
1811
|
+
// already at the target state. Nothing is mutated here.
|
|
1812
|
+
let planner
|
|
1813
|
+
if (op === 'set-enabled') {
|
|
1814
|
+
if (typeof value !== 'boolean') {
|
|
1815
|
+
return { ok: false, saved: false, message: '批量启停需要一个布尔值', snapshot: snapshot() }
|
|
1816
|
+
}
|
|
1817
|
+
planner = (entry) => (entry.enabled === value ? null : { enabled: value })
|
|
1818
|
+
} else if (op === 'set-priority') {
|
|
1819
|
+
if (value !== 0 && value !== 1 && value !== 2) {
|
|
1820
|
+
return { ok: false, saved: false, message: '批量优先级只能是 0、1 或 2', snapshot: snapshot() }
|
|
1821
|
+
}
|
|
1822
|
+
planner = (entry) => (entry.priority === value ? null : { priority: value })
|
|
1823
|
+
} else if (op === 'add-tags') {
|
|
1824
|
+
if (!Array.isArray(value)) {
|
|
1825
|
+
return { ok: false, saved: false, message: '批量添加标签需要一个标签数组', snapshot: snapshot() }
|
|
1826
|
+
}
|
|
1827
|
+
const add = sanitizeTags(value)
|
|
1828
|
+
if (add.length === 0) {
|
|
1829
|
+
return { ok: false, saved: false, message: '没有可添加的有效标签(单个标签不超过 ' + MAX_TAG_LENGTH + ' 字符)', snapshot: snapshot() }
|
|
1830
|
+
}
|
|
1831
|
+
planner = (entry) => {
|
|
1832
|
+
const cur = Array.isArray(entry.tags) ? entry.tags : []
|
|
1833
|
+
const merged = sanitizeTags([...cur, ...add])
|
|
1834
|
+
if (merged.length === cur.length && merged.every((tag) => cur.includes(tag))) return null
|
|
1835
|
+
return { tags: merged }
|
|
1836
|
+
}
|
|
1837
|
+
} else {
|
|
1838
|
+
// remove-tags
|
|
1839
|
+
if (!Array.isArray(value)) {
|
|
1840
|
+
return { ok: false, saved: false, message: '批量移除标签需要一个标签数组', snapshot: snapshot() }
|
|
1841
|
+
}
|
|
1842
|
+
const remove = new Set(sanitizeTags(value))
|
|
1843
|
+
if (remove.size === 0) {
|
|
1844
|
+
return { ok: false, saved: false, message: '没有可移除的有效标签', snapshot: snapshot() }
|
|
1845
|
+
}
|
|
1846
|
+
planner = (entry) => {
|
|
1847
|
+
const cur = Array.isArray(entry.tags) ? entry.tags : []
|
|
1848
|
+
const kept = cur.filter((tag) => !remove.has(tag))
|
|
1849
|
+
return kept.length === cur.length ? null : { tags: kept }
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
const changes = []
|
|
1854
|
+
for (const entry of targets) {
|
|
1855
|
+
const patch = planner(entry)
|
|
1856
|
+
if (patch !== null) changes.push({ entry, patch })
|
|
1857
|
+
}
|
|
1858
|
+
if (changes.length === 0) {
|
|
1859
|
+
return { ok: true, saved: false, message: '所选条目均已是目标状态' + missingNote, snapshot: snapshot() }
|
|
1860
|
+
}
|
|
1861
|
+
const saved = await commit(() => {
|
|
1862
|
+
for (const change of changes) Object.assign(change.entry, change.patch)
|
|
1863
|
+
})
|
|
1864
|
+
const done = {
|
|
1865
|
+
'set-enabled': value ? '已启用 ' : '已停用 ',
|
|
1866
|
+
'set-priority': '已将 ',
|
|
1867
|
+
'add-tags': '已为 ',
|
|
1868
|
+
'remove-tags': '已从 ',
|
|
1869
|
+
}[op]
|
|
1870
|
+
const tail = {
|
|
1871
|
+
'set-enabled': changes.length + ' 条指令',
|
|
1872
|
+
'set-priority': changes.length + ' 条指令的优先级设为「' + priorityLabel(value) + '」',
|
|
1873
|
+
'add-tags': changes.length + ' 条指令添加标签',
|
|
1874
|
+
'remove-tags': changes.length + ' 条指令移除标签',
|
|
1875
|
+
}[op]
|
|
1876
|
+
return settle(done + tail + missingNote, saved)
|
|
1877
|
+
})
|
|
1878
|
+
|
|
1879
|
+
const saveProfile = (raw, baseRev) => enqueue(async () => {
|
|
1880
|
+
if (state.versionRejected) return versionRefusal()
|
|
1881
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
1882
|
+
const profile = sanitizeProfile(raw)
|
|
1883
|
+
if (profile === null) return { ok: false, saved: false, message: '形象名称与内容不能同时为空', snapshot: snapshot() }
|
|
1884
|
+
profile.updatedAt = Date.now()
|
|
1885
|
+
const replacesExisting = state.data.profiles.some((candidate) => candidate.id === profile.id)
|
|
1886
|
+
if (!replacesExisting && state.data.profiles.length >= MAX_PROFILES) {
|
|
1887
|
+
return { ok: false, saved: false, message: '最多保存 ' + MAX_PROFILES + ' 个用户形象', snapshot: snapshot() }
|
|
1888
|
+
}
|
|
1889
|
+
const cut = truncatedProfileFields(raw)
|
|
1890
|
+
const saved = await commit(() => {
|
|
1891
|
+
const list = state.data.profiles
|
|
1892
|
+
const index = list.findIndex((candidate) => candidate.id === profile.id)
|
|
1893
|
+
if (index >= 0) list[index] = profile
|
|
1894
|
+
else list.push(profile)
|
|
1895
|
+
})
|
|
1896
|
+
const note = cut.length > 0
|
|
1897
|
+
? '已保存;' + cut.join('、') + '超过长度上限(名称 ' + MAX_PROFILE_NAME + ' / 内容 ' + MAX_PROFILE_CONTENT + ' 字符),超出部分已被截断'
|
|
1898
|
+
: null
|
|
1899
|
+
return settle(note, saved)
|
|
1900
|
+
})
|
|
1901
|
+
|
|
1902
|
+
const deleteProfile = (id, baseRev) => enqueue(async () => {
|
|
1903
|
+
if (state.versionRejected) return versionRefusal()
|
|
1904
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
1905
|
+
// Mirror deleteEntry: an absent id changes nothing and must not touch disk.
|
|
1906
|
+
if (!state.data.profiles.some((profile) => profile.id === id)) {
|
|
1907
|
+
return { ok: true, saved: false, message: null, snapshot: snapshot() }
|
|
1908
|
+
}
|
|
1909
|
+
const saved = await commit(() => {
|
|
1910
|
+
state.data.profiles = state.data.profiles.filter((profile) => profile.id !== id)
|
|
1911
|
+
})
|
|
1912
|
+
return settle(null, saved)
|
|
1913
|
+
})
|
|
1914
|
+
|
|
1542
1915
|
const reload = () => enqueue(async () => {
|
|
1543
1916
|
// `saved` is only true when the load itself wrote to disk (the
|
|
1544
1917
|
// backup-promotion or legacy-migration branch); a plain re-read claiming
|
|
@@ -1547,7 +1920,6 @@ export function apply(ctx) {
|
|
|
1547
1920
|
syncSection()
|
|
1548
1921
|
return { ok: state.storageError === null, saved: wrote === true, message: state.storageError, snapshot: snapshot() }
|
|
1549
1922
|
})
|
|
1550
|
-
|
|
1551
1923
|
const exportData = () => enqueue(async () => ({
|
|
1552
1924
|
ok: true,
|
|
1553
1925
|
// An export reads the store and never writes it; a truthful `saved:false`
|
|
@@ -1566,15 +1938,16 @@ export function apply(ctx) {
|
|
|
1566
1938
|
if (payload === null || typeof payload !== 'object') {
|
|
1567
1939
|
return { ok: false, saved: false, message: '导入内容不是有效的 JSON 对象', snapshot: snapshot() }
|
|
1568
1940
|
}
|
|
1569
|
-
// Accept both the plugin's own export envelope ({app, data:{entries}})
|
|
1570
|
-
// a bare {entries}
|
|
1571
|
-
const
|
|
1572
|
-
|
|
1573
|
-
: (
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
if (rawEntries === null || rawEntries.length === 0)
|
|
1577
|
-
|
|
1941
|
+
// Accept both the plugin's own export envelope ({app, data:{entries,…}})
|
|
1942
|
+
// and a bare {entries}/{profiles} object (e.g. a hand-assembled file).
|
|
1943
|
+
const inner = payload.data !== null && typeof payload.data === 'object' ? payload.data : payload
|
|
1944
|
+
const rawEntries = Array.isArray(payload.entries) ? payload.entries
|
|
1945
|
+
: (Array.isArray(inner.entries) ? inner.entries : null)
|
|
1946
|
+
const rawProfiles = Array.isArray(payload.profiles) ? payload.profiles
|
|
1947
|
+
: (Array.isArray(inner.profiles) ? inner.profiles : null)
|
|
1948
|
+
if ((rawEntries === null || rawEntries.length === 0)
|
|
1949
|
+
&& (rawProfiles === null || rawProfiles.length === 0)) {
|
|
1950
|
+
return { ok: false, saved: false, message: '导入文件中没有找到条目或用户形象', snapshot: snapshot() }
|
|
1578
1951
|
}
|
|
1579
1952
|
// Merge, never replace: importing must not be able to wipe what is here.
|
|
1580
1953
|
// Duplicates (same id, or same title+content) and invalid items are
|
|
@@ -1589,7 +1962,7 @@ export function apply(ctx) {
|
|
|
1589
1962
|
const cutFields = new Set()
|
|
1590
1963
|
let truncatedCount = 0
|
|
1591
1964
|
let skipped = 0
|
|
1592
|
-
for (const candidate of rawEntries) {
|
|
1965
|
+
for (const candidate of (rawEntries || [])) {
|
|
1593
1966
|
const entry = sanitizeEntry(candidate)
|
|
1594
1967
|
if (entry === null || seenIds.has(entry.id) || seenBodies.has(entry.title + '\u0000' + entry.content)) {
|
|
1595
1968
|
skipped += 1
|
|
@@ -1613,18 +1986,70 @@ export function apply(ctx) {
|
|
|
1613
1986
|
seenIds.add(entry.id)
|
|
1614
1987
|
seenBodies.add(entry.title + '\u0000' + entry.content)
|
|
1615
1988
|
}
|
|
1616
|
-
|
|
1989
|
+
// Persona profiles merge under the same rules (same id, or same
|
|
1990
|
+
// name+content) in the SAME transaction, so a mixed import never lands
|
|
1991
|
+
// half-way.
|
|
1992
|
+
const existingProfiles = state.data.profiles
|
|
1993
|
+
const seenProfileIds = new Set(existingProfiles.map((profile) => profile.id))
|
|
1994
|
+
const seenProfileBodies = new Set(existingProfiles.map((profile) => profile.name + '\u0000' + profile.content))
|
|
1995
|
+
const acceptedProfiles = []
|
|
1996
|
+
const cutProfileFields = new Set()
|
|
1997
|
+
let truncatedProfileCount = 0
|
|
1998
|
+
let skippedProfiles = 0
|
|
1999
|
+
for (const candidate of (rawProfiles || [])) {
|
|
2000
|
+
const profile = sanitizeProfile(candidate)
|
|
2001
|
+
if (profile === null
|
|
2002
|
+
|| seenProfileIds.has(profile.id)
|
|
2003
|
+
|| seenProfileBodies.has(profile.name + '\u0000' + profile.content)) {
|
|
2004
|
+
skippedProfiles += 1
|
|
2005
|
+
continue
|
|
2006
|
+
}
|
|
2007
|
+
if (existingProfiles.length + acceptedProfiles.length >= MAX_PROFILES) {
|
|
2008
|
+
skippedProfiles += 1
|
|
2009
|
+
continue
|
|
2010
|
+
}
|
|
2011
|
+
const cut = truncatedProfileFields(candidate)
|
|
2012
|
+
if (cut.length > 0) {
|
|
2013
|
+
truncatedProfileCount += 1
|
|
2014
|
+
for (const field of cut) cutProfileFields.add(field)
|
|
2015
|
+
}
|
|
2016
|
+
profile.updatedAt = Date.now()
|
|
2017
|
+
acceptedProfiles.push(profile)
|
|
2018
|
+
seenProfileIds.add(profile.id)
|
|
2019
|
+
seenProfileBodies.add(profile.name + '\u0000' + profile.content)
|
|
2020
|
+
}
|
|
2021
|
+
if (accepted.length === 0 && acceptedProfiles.length === 0) {
|
|
1617
2022
|
// Nothing was written to disk, so claiming saved:true would be a lie the
|
|
1618
2023
|
// panel cannot distinguish from a real save.
|
|
1619
|
-
return {
|
|
2024
|
+
return {
|
|
2025
|
+
ok: true,
|
|
2026
|
+
saved: false,
|
|
2027
|
+
message: '没有新增内容:' + skipped + ' 条条目、' + skippedProfiles + ' 个用户形象已存在或无效',
|
|
2028
|
+
snapshot: snapshot(),
|
|
2029
|
+
}
|
|
1620
2030
|
}
|
|
1621
2031
|
const saved = await commit(() => {
|
|
1622
2032
|
state.data.entries.push(...accepted)
|
|
2033
|
+
state.data.profiles.push(...acceptedProfiles)
|
|
1623
2034
|
})
|
|
1624
|
-
const
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
2035
|
+
const parts = []
|
|
2036
|
+
if (accepted.length > 0) {
|
|
2037
|
+
const truncated = truncatedCount > 0
|
|
2038
|
+
? ';其中 ' + truncatedCount + ' 条超过长度上限,' + [...cutFields].join('、') + '已被截断'
|
|
2039
|
+
: ''
|
|
2040
|
+
parts.push('新增指令 ' + accepted.length + ' 条,跳过 ' + skipped + ' 条' + truncated)
|
|
2041
|
+
} else if (skipped > 0) {
|
|
2042
|
+
parts.push('指令跳过 ' + skipped + ' 条(已存在或无效)')
|
|
2043
|
+
}
|
|
2044
|
+
if (acceptedProfiles.length > 0) {
|
|
2045
|
+
const truncated = truncatedProfileCount > 0
|
|
2046
|
+
? ';其中 ' + truncatedProfileCount + ' 个超过长度上限,' + [...cutProfileFields].join('、') + '已被截断'
|
|
2047
|
+
: ''
|
|
2048
|
+
parts.push('新增用户形象 ' + acceptedProfiles.length + ' 个,跳过 ' + skippedProfiles + ' 个' + truncated)
|
|
2049
|
+
} else if (skippedProfiles > 0) {
|
|
2050
|
+
parts.push('用户形象跳过 ' + skippedProfiles + ' 个(已存在或无效)')
|
|
2051
|
+
}
|
|
2052
|
+
return settle('导入完成:' + parts.join(';'), saved)
|
|
1628
2053
|
})
|
|
1629
2054
|
|
|
1630
2055
|
const dispatch = async (method, args) => {
|
|
@@ -1661,6 +2086,20 @@ export function apply(ctx) {
|
|
|
1661
2086
|
return importData(
|
|
1662
2087
|
args !== null && typeof args === 'object' ? args.payload : null,
|
|
1663
2088
|
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
2089
|
+
case 'batch':
|
|
2090
|
+
return batchEntries(
|
|
2091
|
+
args !== null && typeof args === 'object' ? args.op : null,
|
|
2092
|
+
args !== null && typeof args === 'object' ? args.ids : null,
|
|
2093
|
+
args !== null && typeof args === 'object' ? args.value : undefined,
|
|
2094
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
2095
|
+
case 'save-profile':
|
|
2096
|
+
return saveProfile(
|
|
2097
|
+
args !== null && typeof args === 'object' ? args.profile : null,
|
|
2098
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
2099
|
+
case 'delete-profile':
|
|
2100
|
+
return deleteProfile(
|
|
2101
|
+
args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
|
|
2102
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
1664
2103
|
default:
|
|
1665
2104
|
return { ok: false, saved: false, message: '未知方法:' + String(method), snapshot: snapshot() }
|
|
1666
2105
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-instruction-memory",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the model has no write access. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|