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/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 + 200) * 2 + 100_000
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
- return { data, dropped, optionFixes }
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
- export function planInjection(data) {
548
- const budget = clampBudget(data !== null && typeof data === 'object' ? data.budgetChars : undefined)
549
- const blank = () => ({ text: '', budget, activeCount: 0, included: [], omitted: [] })
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 { text: '', budget: clampBudget(state.data.budgetChars), activeCount: 0, included: [], omitted: [] }
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: { minBudget: MIN_BUDGET, maxBudget: MAX_BUDGET, maxEntries: MAX_ENTRIES, maxBodyChars: MAX_BODY_CHARS },
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}}) and
1570
- // a bare {entries} array (e.g. a hand-assembled file).
1571
- const rawEntries = Array.isArray(payload.entries)
1572
- ? payload.entries
1573
- : (payload.data !== null && typeof payload.data === 'object' && Array.isArray(payload.data.entries)
1574
- ? payload.data.entries
1575
- : null)
1576
- if (rawEntries === null || rawEntries.length === 0) {
1577
- return { ok: false, saved: false, message: '导入文件中没有找到条目', snapshot: snapshot() }
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
- if (accepted.length === 0) {
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 { ok: true, saved: false, message: '没有新增条目:' + skipped + ' 条已存在或无效', snapshot: snapshot() }
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 truncated = truncatedCount > 0
1625
- ? ';其中 ' + truncatedCount + ' 条超过长度上限,' + [...cutFields].join('、') + '已被截断'
1626
- : ''
1627
- return settle('导入完成:新增 ' + accepted.length + ' 条,跳过 ' + skipped + ' 条(已存在或无效)' + truncated, saved)
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.17",
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",