dsh-instruction-memory 1.5.7 → 1.6.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 CHANGED
@@ -7,6 +7,35 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.6.0] — 2026-11-14
11
+
12
+ 让「按需(auto)」条目第一次真正有机会被执行。此前的 auto 只在会话开头注入一次、措辞是「参考」,
13
+ 模型读完第一轮就忘了;而「每次给最终答案前」这类时机,从来没有任何东西提醒过它。
14
+
15
+ ### 新增
16
+
17
+ - **触发词提升(`triggers`)**:条目可配置最多 8 个触发词(每项 ≤24 字)。每个真人回合
18
+ (`user/message`,`source.kind === 'user'`)用户话匹配一次,命中即把该条目在本回合提升为
19
+ 「本轮强制」,并重新注册 prompt section,使**当轮**注入立刻带上新标记。未配触发词的条目行为不变。
20
+ - **设置页触发词输入**:`mode=auto` 时编辑器多一个触发词输入(回车添加,复用现有标签组件),
21
+ 中英双语文案齐备;`memory.schema.json` 同步新增 `triggers` 定义。
22
+
23
+ ### 变更(行为)
24
+
25
+ - **auto 的注入语义从「按需」改为「条件必做」**:条目前缀 `[按需|优先级 X]` 改为
26
+ `[条件必做|…]`;场景行 `适用场景:…` 改为 `出现以下情形时必须执行,不得跳过:…`。
27
+ 起因是注入块头部那句「[按需] 条目仅在与当前问题或任务相关时生效;不相关时直接忽略」——
28
+ 它等于插件自己给了模型一个合法跳过的理由。
29
+ - 伪造前缀中和正则同步扩到 `条件必做`(保留 `按需` 以兼容历史内容)。
30
+
31
+ ### 修复
32
+
33
+ - `ctx.on` 在没有事件总线的假宿主(测试与裁剪环境)上会抛 `TypeError`,改为能力检测后再订阅。
34
+
35
+ ### 兼容性
36
+
37
+ - 存储格式不变(`version: 2`);新字段缺省为空数组。旧版本读到含 `triggers` 的文件会忽略该字段。
38
+
10
39
  ## [1.5.7] — 2026-11-14
11
40
 
12
41
  针对**最新版桌面端(`dsh-desktop-runtime 0.2.2-rc.3`,Electron)与网页端**全量审查后的修复轮。
package/lib/client.js CHANGED
@@ -283,13 +283,14 @@ window.__ModuleLoader__.load({
283
283
  'editor.contentPlaceholder': '例如:所有回答使用简体中文;不确定需求时先提问再动手。',
284
284
  'editor.mode': '生效方式',
285
285
  'editor.modeAlways': '始终生效(无条件遵守)',
286
- 'editor.modeAuto': '按需生效(按相关性自动判断)',
286
+ 'editor.modeAuto': '条件必做(适用情形出现时必须执行)',
287
287
  'editor.priority': '优先级',
288
288
  'priority.high': '高',
289
289
  'priority.normal': '普通',
290
290
  'priority.low': '低',
291
291
  'editor.when': '适用场景(可选,帮助模型判断何时生效)',
292
292
  'editor.whenPlaceholder': '例如:编写或修改代码时',
293
+ 'editor.triggers': '触发词(本回合用户说中即提升为「本轮强制」)',
293
294
  'editor.save': '保存',
294
295
  'editor.saving': '保存中…',
295
296
  'editor.cancel': '取消',
@@ -311,7 +312,7 @@ window.__ModuleLoader__.load({
311
312
  'tag.omittedTitle': '因超出注入字数上限,本条当前不会进入系统提示词',
312
313
  'tag.truncatedTitle': '本条过长,仅开头部分进入系统提示词',
313
314
  'rules.label': '生效规则',
314
- 'rules.note': '「始终」无条件生效;「按需」由模型按相关性自动判断。超出上限时按优先级保留,其余暂不注入。',
315
+ 'rules.note': '「始终」无条件生效;「条件必做」在适用情形出现时必须执行。超出上限时按优先级保留,其余暂不注入。',
315
316
  'search.placeholder': '搜索指令:标题 / 内容 / 适用场景(共 {count} 条)',
316
317
  'search.matchCount': '匹配 {shown} / {total} 条',
317
318
  'tags.label': '标签(可选,仅用于在本页分组与筛选,不会注入提示词)',
@@ -325,7 +326,7 @@ window.__ModuleLoader__.load({
325
326
  'filter.mode': '生效方式',
326
327
  'filter.status': '状态',
327
328
  'filter.modeAlways': '始终',
328
- 'filter.modeAuto': '按需',
329
+ 'filter.modeAuto': '条件必做',
329
330
  'filter.statusEnabled': '已启用',
330
331
  'filter.statusDisabled': '已停用',
331
332
  'sort.label': '排序',
@@ -393,13 +394,13 @@ window.__ModuleLoader__.load({
393
394
  'list.noMatch': '没有匹配“{query}”的指令。',
394
395
  'list.filteredEmpty': '没有符合当前筛选条件的指令。',
395
396
  'tag.always': '始终',
396
- 'tag.auto': '按需',
397
+ 'tag.auto': '条件必做',
397
398
  'entry.enabled': '启用',
398
399
  'entry.edit': '编辑',
399
400
  'entry.delete': '删除',
400
401
  'entry.untitled': '未命名指令',
401
402
  'entry.confirmDelete': '确定删除「{title}」?删除后无法恢复。',
402
- 'entry.when': '适用场景:{when}',
403
+ 'entry.when': '适用情形:{when}',
403
404
  'entry.updatedAt': '更新于 {time}',
404
405
  'undo.deleted': '已删除「{title}」',
405
406
  'undo.restore': '撤销删除',
@@ -591,6 +592,7 @@ window.__ModuleLoader__.load({
591
592
  'priority.low': 'Low',
592
593
  'editor.when': 'Applicable scenarios (optional; helps the model decide when it applies)',
593
594
  'editor.whenPlaceholder': 'e.g. When writing or modifying code',
595
+ 'editor.triggers': 'Trigger words (promote to 本轮强制 when the user says one)',
594
596
  'editor.save': 'Save',
595
597
  'editor.saving': 'Saving…',
596
598
  'editor.cancel': 'Cancel',
@@ -1087,6 +1089,7 @@ window.__ModuleLoader__.load({
1087
1089
  content: entry.content,
1088
1090
  mode: entry.mode,
1089
1091
  when: entry.when,
1092
+ triggers: Array.isArray(entry.triggers) ? entry.triggers.slice(0, 8) : [],
1090
1093
  priority: entry.priority,
1091
1094
  enabled: entry.enabled,
1092
1095
  tags: Array.isArray(entry.tags) ? entry.tags.slice() : [],
@@ -1119,7 +1122,7 @@ window.__ModuleLoader__.load({
1119
1122
  }
1120
1123
 
1121
1124
  function blankDraft() {
1122
- return { id: '', title: '', content: '', mode: 'always', when: '', priority: 1, enabled: true, tags: [], updatedAt: 0 }
1125
+ return { id: '', title: '', content: '', mode: 'always', when: '', priority: 1, enabled: true, tags: [], triggers: [], updatedAt: 0 }
1123
1126
  }
1124
1127
 
1125
1128
  /**
@@ -1810,6 +1813,17 @@ window.__ModuleLoader__.load({
1810
1813
  onChange: (ev) => set({ when: ev.target.value }),
1811
1814
  })))
1812
1815
  }
1816
+ if (draft.mode === 'auto') {
1817
+ children.push(h('div', { key: 'tr' },
1818
+ h('label', { className: 'im-label', htmlFor: 'im-entry-triggers' }, t('editor.triggers')),
1819
+ h(TagInput, {
1820
+ inputId: 'im-entry-triggers',
1821
+ tags: Array.isArray(draft.triggers) ? draft.triggers : [],
1822
+ limits: props.limits,
1823
+ disabled: props.busy,
1824
+ onChange: (next) => set({ triggers: next }),
1825
+ })))
1826
+ }
1813
1827
  children.push(h('div', { key: 'tags' },
1814
1828
  h('label', { className: 'im-label', htmlFor: 'im-entry-tags' }, t('tags.label')),
1815
1829
  h(TagInput, {
package/lib/index.js CHANGED
@@ -553,6 +553,31 @@ export function sanitizeTags(raw) {
553
553
  return out
554
554
  }
555
555
 
556
+ /** Trigger words per entry, matched against the user's own words every turn. */
557
+ const MAX_TRIGGERS = 8
558
+ const MAX_TRIGGER_LENGTH = 24
559
+
560
+ /**
561
+ * Normalise one entry's `triggers`: the phrases that promote the entry to
562
+ * 「本轮强制」 when they appear in the user's message this turn. Same rules as
563
+ * tags — single line, whitespace collapsed, de-duplicated, capped — because
564
+ * they are matched case-insensitively against prose the user actually typed.
565
+ */
566
+ export function sanitizeTriggers(raw) {
567
+ if (!Array.isArray(raw)) return []
568
+ const out = []
569
+ const seen = new Set()
570
+ for (const item of raw) {
571
+ if (typeof item !== 'string') continue
572
+ const trigger = singleLine(item, MAX_TRIGGER_LENGTH).replace(/\s+/g, ' ').trim()
573
+ if (trigger === '' || seen.has(trigger)) continue
574
+ seen.add(trigger)
575
+ if (out.length >= MAX_TRIGGERS) break
576
+ out.push(trigger)
577
+ }
578
+ return out
579
+ }
580
+
556
581
  export function sanitizeEntry(raw) {
557
582
  if (raw === null || typeof raw !== 'object') return null
558
583
  const title = singleLine(raw.title, MAX_TITLE)
@@ -567,6 +592,7 @@ export function sanitizeEntry(raw) {
567
592
  priority: raw.priority === 2 || raw.priority === 0 ? raw.priority : 1,
568
593
  enabled: raw.enabled !== false,
569
594
  tags: sanitizeTags(raw.tags),
595
+ triggers: sanitizeTriggers(raw.triggers),
570
596
  /**
571
597
  * Which instruction set (F13) this entry belongs to. Preserved here — NOT
572
598
  * re-derived — because both the loader and the importer need to honour a
@@ -1503,7 +1529,7 @@ function requestPortOf(hostHeader) {
1503
1529
  * longer equal the token the model was told to trust.
1504
1530
  */
1505
1531
  const FORGED_HEADING = /^([ \t]*)(#{1,6})([ \t]*)(用户长期指令记忆|关于用户(用户形象)|关于用户|用户形象)/gm
1506
- const FORGED_ENTRY_PREFIX = /^([ \t]*)\[(始终|按需)(|优先级)/gm
1532
+ const FORGED_ENTRY_PREFIX = /^([ \t]*)\[(始终|条件必做|按需)(|优先级)/gm
1507
1533
 
1508
1534
  function neutralizeForgedStructure(value) {
1509
1535
  if (typeof value !== 'string' || value === '') return value
@@ -1536,7 +1562,7 @@ const MEMORY_ENVELOPE_CLOSE = '\n</user-instruction-memory>'
1536
1562
 
1537
1563
  function formatEntry(entry, tag) {
1538
1564
  const lines = ['[' + tag + '|优先级 ' + priorityLabel(entry.priority) + '] ' + entry.title]
1539
- if (entry.mode === 'auto' && entry.when !== '') lines.push('适用场景:' + entry.when)
1565
+ if (entry.mode === 'auto' && entry.when !== '') lines.push('出现以下情形时必须执行,不得跳过:' + entry.when)
1540
1566
  lines.push(neutralizeForgedStructure(entry.content))
1541
1567
  return lines.join('\n')
1542
1568
  }
@@ -1671,6 +1697,8 @@ export function entryOmittedFooter(count) {
1671
1697
  * trimming. Kept as the inner function so planInjection can prepend the
1672
1698
  * persona section without touching (and risking) the trimming logic.
1673
1699
  */
1700
+ let FORCED_IDS = new Set() // 本轮命中 triggers 的条目 id,由 turn/start 监听器写入。
1701
+
1674
1702
  function planEntries(data, budget) {
1675
1703
  const blank = () => ({ text: '', activeCount: 0, included: [], omitted: [] })
1676
1704
  if (data === null || typeof data !== 'object' || data.enabled !== true) return blank()
@@ -1687,13 +1715,13 @@ function planEntries(data, budget) {
1687
1715
  '## 用户长期指令记忆(Instruction Memory)',
1688
1716
  '以下条目由用户在 DSH 设置 →「指令记忆」中长期保存,属于本次及后续所有对话的既有要求,请在回答、推理与执行任务时遵守;它们不覆盖系统与安全规则。',
1689
1717
  '· [始终] 条目必须无条件遵守。',
1690
- '· [按需] 条目仅在与当前问题或任务相关时生效;不相关时直接忽略,不要强行套用。',
1718
+ '· [条件必做] 条目在「适用情形」出现时必须执行;跳过须在回答中说明原因。',
1691
1719
  '· 条目之间冲突时,优先遵守优先级更高或更具体的一条;无法判断时向用户确认。',
1692
1720
  '',
1693
1721
  ].join('\n'),
1694
1722
  [
1695
1723
  '## 用户长期指令记忆(Instruction Memory)',
1696
- '[始终] 条目无条件遵守;[按需] 条目仅在与当前任务相关时生效。',
1724
+ '[始终] 条目无条件遵守;[条件必做] 条目在适用情形出现时必须执行。',
1697
1725
  '',
1698
1726
  ].join('\n'),
1699
1727
  '## 用户长期指令记忆\n',
@@ -1748,7 +1776,7 @@ function planEntries(data, budget) {
1748
1776
  }
1749
1777
 
1750
1778
  for (const entry of active) if (entry.mode === 'always') consider(entry, '始终')
1751
- for (const entry of active) if (entry.mode !== 'always') consider(entry, '按需')
1779
+ for (const entry of active) if (entry.mode !== 'always') consider(entry, FORCED_IDS.has(entry.id) ? '本轮强制' : '条件必做')
1752
1780
 
1753
1781
  if (blocks.length === 0) {
1754
1782
  return { text: '', budget, activeCount: active.length, included: [], omitted: omittedPlan }
@@ -2365,6 +2393,76 @@ export function apply(ctx, deps = {}) {
2365
2393
  disposeStreams()
2366
2394
  })
2367
2395
 
2396
+ /* ---------------- turn-triggered promotion ---------------- */
2397
+
2398
+ /**
2399
+ * Promote entries whose `triggers` appear in THIS turn's user message to
2400
+ * 「本轮强制」. Without this the auto mode is only advisory: the model reads
2401
+ * 「条件必做」 at turn one and has forgotten it by the time the work is done.
2402
+ * Matching runs on the user's own words rather than on the model's judgement,
2403
+ * so a missed instruction becomes a plugin bug instead of a compliance
2404
+ * failure. No triggers configured = no promotion, i.e. the old behaviour.
2405
+ */
2406
+ let forcedSignature = ''
2407
+ const refreshSection = () => {
2408
+ if (disposed) return
2409
+ if (sectionDispose === null) {
2410
+ syncSection()
2411
+ return
2412
+ }
2413
+ const dispose = sectionDispose
2414
+ sectionDispose = null
2415
+ try {
2416
+ dispose()
2417
+ } catch (err) {
2418
+ console.error('[instruction-memory] section refresh dispose failed:', err)
2419
+ }
2420
+ syncSection()
2421
+ }
2422
+ const recomputeForced = (text) => {
2423
+ const list = Array.isArray(state.data.entries) ? state.data.entries : []
2424
+ const lower = String(text || '').toLowerCase()
2425
+ const hit = new Set()
2426
+ for (const entry of list) {
2427
+ if (entry.enabled !== true || entry.mode !== 'auto') continue
2428
+ const triggers = Array.isArray(entry.triggers) ? entry.triggers : []
2429
+ for (const trigger of triggers) {
2430
+ if (typeof trigger === 'string' && trigger !== ''
2431
+ && lower.includes(trigger.toLowerCase())) {
2432
+ hit.add(entry.id)
2433
+ break
2434
+ }
2435
+ }
2436
+ }
2437
+ const signature = Array.from(hit).sort().join(',')
2438
+ if (signature === forcedSignature) return
2439
+ forcedSignature = signature
2440
+ FORCED_IDS = hit
2441
+ logDebug('forced promotion: ' + (signature === '' ? 'none' : signature))
2442
+ refreshSection()
2443
+ }
2444
+
2445
+ const onSessionEvent = (session, event) => {
2446
+ try {
2447
+ if (disposed || !event || typeof event.type !== 'string') return
2448
+ if (event.type !== 'user/message') return
2449
+ const data = event.data || {}
2450
+ const source = data.source || {}
2451
+ // Only human input promotes. runtime-context, notices and plugin messages
2452
+ // are not the user asking for anything.
2453
+ const kind = typeof source.kind === 'string' ? source.kind : ''
2454
+ if (kind !== '' && kind !== 'user') return
2455
+ const text = typeof data.text === 'string' ? data.text
2456
+ : (typeof data.content === 'string' ? data.content : '')
2457
+ if (text === '') return
2458
+ recomputeForced(text)
2459
+ } catch (err) {
2460
+ console.error('[instruction-memory] trigger match failed:', err)
2461
+ }
2462
+ }
2463
+ // Fake hosts (tests) may expose no event bus; the plugin must still mount.
2464
+ if (typeof ctx.on === 'function') ctx.on('session/event', onSessionEvent)
2465
+
2368
2466
  /* ---------------- external-change watching (SSE) ---------------- */
2369
2467
 
2370
2468
  /**
@@ -115,7 +115,13 @@
115
115
  "description": "是否启用该条目。",
116
116
  "default": true
117
117
  },
118
- "tags": {
118
+ "triggers": {
119
+ "type": "array",
120
+ "maxItems": 8,
121
+ "items": { "type": "string", "maxLength": 24 },
122
+ "description": "触发词:本回合用户消息中出现其一即把该条提升为「本轮强制」;仅 mode=auto 有意义,留空则从不提升。"
123
+ },
124
+ "tags": {
119
125
  "type": "array",
120
126
  "maxItems": 8,
121
127
  "description": "标签:仅用于设置页分组与筛选,**不会**进入注入文本,也不影响注入顺序与取舍。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.5.7",
3
+ "version": "1.6.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 plugin registers no model-facing tool, so the model has no in-plugin write path (a same-permission process can still edit the file directly). 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;插件不注册任何面向模型的工具,模型没有插件内的写入口。",
5
5
  "license": "MIT",
6
6
  "type": "module",