dsh-instruction-memory 1.0.16 → 1.0.17

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,36 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.0.17] — 2026-10-12
11
+
12
+ ### 新增
13
+
14
+ - **注入预算可视化(F1)**:设置页在「注入字数上限」下方显示用量条与「实际注入 x / y 字符
15
+ (约 z token,估算)」(token 按 0.6 系数估算,与既有说明同一口径,仅作参考);被上限略过的
16
+ **生效中**条目在列表中逐条标注橙色「本轮不注入」(悬停说明原因),极端情况下首条被迫截断时
17
+ 标注红色「已截断」,用量条同时变为警示色并给出条数提示。调高上限或调整优先级保存后标记即时变化。
18
+ - Host 新增导出纯函数 `planInjection(data)`:在**不改变注入文本一个字节**的前提下,返回注入文本、
19
+ 生效上限(clamp 后的 budget)、候选条目数,以及每条 `included`(`{id, chars, truncated}`)
20
+ / `omitted`(`{id, chars}`)的取舍明细与考虑顺序(先「始终」后「按需」、同档优先级/更新时间降序);
21
+ `buildBlock()` 改为它的薄封装。
22
+ - 快照 `injection` 新增 `budget` / `activeCount` / `included` / `omitted` 字段(SSE 帧随之自动携带)。
23
+ 存储格式仍为 v1、**无新增落盘字段**;旧客户端收到新字段自然忽略,新客户端对没有这些字段的旧宿主
24
+ 快照回退到 `data.budgetChars`、不显示条目标记,两侧均不报错。
25
+
26
+ ### 测试
27
+
28
+ - `smoke-test.mjs` 新增 20+ 条 `planInjection` 断言:空/总开关关闭/非法输入、budget 越界夹取、
29
+ 与 `buildBlock()` 逐字节一致、停用与空条目不参与、「始终」先于「按需」、紧预算下 included/omitted
30
+ 恰好分区全部候选、单条超长首条截断标记、计划本身确定性。
31
+ - `contract-test.mjs` 新增独立验证块:经真实路由在 800 字符上限下断言快照预算、`chars === preview.length`
32
+ 且不超上限、取舍明细恰好覆盖全部生效条目且不含停用条目、预览脚注数量与 omitted 一致、提高上限后
33
+ 全部纳入;并把真实/旧版/垃圾三类快照喂给客户端 reducer 验证接缝。
34
+ - `verify.mjs` 新增客户端 `__budgetMeter` reducer 断言(计划字段优先、旧快照回退、非法项忽略、
35
+ 垃圾输入零值不抛错、percent 封顶 100、0.6 token 估算)及 6 个新 i18n 键的双语存在性;
36
+ 键集与占位符一致性仍由既有用例锁定。
37
+ - `docs/demo.html` 的演示宿主改为实时计算注入计划(与 `planInjection` 同一算法),演示数据
38
+ 默认 800 字符上限以直接展示用量条与「本轮不注入」标记;旧的手工静态预览文本移除。
39
+
10
40
  ## [1.0.16] — 2026-10-05
11
41
 
12
42
  一次定向缺陷修复 + 宿主范围扩展。每项修复都有对应回归断言(`npm test` 五套件全绿:
package/README.md CHANGED
@@ -54,6 +54,11 @@ dsh plugin --profile web remove dsh-instruction-memory
54
54
  上限单位是**字符**而非 token——按 DeepSeek 官方分词比例约 1 个中文字符 ≈ 0.6 token、
55
55
  1 个英文字符 ≈ 0.3 token(不同模型分词有差异,实际用量以模型返回为准),上限 40000
56
56
  字符约合 24000 token。
57
+ - **预算用量可视化**:设置页在上限输入框下方显示用量条——「实际注入 x / y 字符(约 z token,
58
+ 估算)」(token 数按 0.6 系数估算,仅作参考);被上限略过的**生效中**条目会在列表里逐条
59
+ 标注橙色「本轮不注入」(悬停可见原因),极端情况下首条被迫截断时标注红色「已截断」。
60
+ 调高上限或调整优先级后标记实时变化。这些数据只是对注入取舍的**展示**,不改变注入文本本身;
61
+ 存储格式不变,旧宿主/旧客户端对新字段一律忽略,互不影响。
57
62
 
58
63
  条目全部停用或总开关关闭时,该提示词段落**整个注销**,系统提示词与未安装本插件时逐字节一致。
59
64
 
package/lib/client.js CHANGED
@@ -58,6 +58,12 @@ window.__ModuleLoader__.load({
58
58
  .im-tag-always { color:var(--dsw-alias-brand-primary,#3b6cff); border-color:currentColor; }
59
59
  .im-tag-auto { color:var(--dsw-alias-state-success-primary,#18a058); border-color:currentColor; }
60
60
  .im-tag-high { color:var(--dsw-alias-state-warn-primary,#d97706); border-color:currentColor; }
61
+ .im-tag-off { color:var(--dsw-alias-state-warn-primary,#d97706); border-color:currentColor; }
62
+ .im-tag-cut { color:var(--dsw-alias-state-error-primary,#d94838); border-color:currentColor; }
63
+ .im-meter { height:6px; border-radius:999px; overflow:hidden;
64
+ background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.08)); }
65
+ .im-meter-fill { height:100%; border-radius:999px; background:var(--dsw-alias-brand-primary,#3b6cff); }
66
+ .im-meter-fill-warn { background:var(--dsw-alias-state-warn-primary,#d97706); }
61
67
  .im-empty { padding:26px; border-radius:10px; text-align:center; font-size:12px;
62
68
  border:1px dashed var(--dsw-alias-border-l2,rgba(0,0,0,.18)); color:var(--dsw-alias-label-secondary,#8a9099); }
63
69
  .im-err { font-size:12px; color:var(--dsw-alias-state-error-primary,#d94838); }
@@ -79,6 +85,7 @@ window.__ModuleLoader__.load({
79
85
  .im-body-open { max-height:none; }
80
86
  .im-more { margin-top:4px; font-size:11.5px; color:var(--dsw-alias-brand-primary,#3b6cff); cursor:pointer; user-select:none; }
81
87
  .im-search-meta { margin-top:5px; }
88
+ .im-meter-line { margin-top:5px; }
82
89
  .im-undo { display:flex; align-items:center; gap:10px; padding:9px 13px; border-radius:8px; font-size:12px;
83
90
  border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.14)); background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04)); }
84
91
  .im-undo button { border:1px solid var(--dsw-alias-brand-primary,#3b6cff); color:var(--dsw-alias-brand-primary,#3b6cff);
@@ -140,6 +147,12 @@ window.__ModuleLoader__.load({
140
147
  'budget.unitHint': '单位是字符,不是 token',
141
148
  'budget.rangeError': '上限需在 {min} ~ {max} 之间',
142
149
  'budget.note': '单位为字符、非 token:按 DeepSeek 分词约 1 个中文字符 ≈ 0.6 token(英文 ≈ 0.3),{max} 字符上限约合 {tokens} token,实际以模型为准。',
150
+ 'budget.used': '实际注入 {used} / {budget} 字符(约 {tokens} token,估算)',
151
+ 'budget.omitted': '有 {count} 条生效指令因超出上限本轮未注入,可调高上限或调整优先级',
152
+ 'tag.omitted': '本轮不注入',
153
+ 'tag.truncated': '已截断',
154
+ 'tag.omittedTitle': '因超出注入字数上限,本条当前不会进入系统提示词',
155
+ 'tag.truncatedTitle': '本条过长,仅开头部分进入系统提示词',
143
156
  'rules.label': '生效规则',
144
157
  'rules.note': '「始终」无条件生效;「按需」由模型按相关性自动判断。超出上限时按优先级保留,其余暂不注入。',
145
158
  'search.placeholder': '搜索指令:标题 / 内容 / 适用场景(共 {count} 条)',
@@ -217,6 +230,12 @@ window.__ModuleLoader__.load({
217
230
  'budget.unitHint': 'The unit is characters, not tokens',
218
231
  'budget.rangeError': 'The limit must be between {min} and {max}',
219
232
  'budget.note': 'Characters, not tokens: with DeepSeek tokenization roughly 1 Chinese character ≈ 0.6 token (English ≈ 0.3), the {max}-character ceiling is about {tokens} tokens; actual usage depends on the model.',
233
+ 'budget.used': '{used} of {budget} characters actually injected (≈{tokens} tokens, estimate)',
234
+ 'budget.omitted': '{count} active entries are not injected this turn because the limit is exceeded; raise the limit or adjust priorities',
235
+ 'tag.omitted': 'Not injected',
236
+ 'tag.truncated': 'Truncated',
237
+ 'tag.omittedTitle': 'This entry currently stays out of the system prompt because the injection size limit is exceeded',
238
+ 'tag.truncatedTitle': 'This entry is too long; only its beginning reaches the system prompt',
220
239
  'rules.label': 'How instructions apply',
221
240
  'rules.note': '“Always” applies unconditionally; “On demand” lets the model judge by relevance. When the limit is exceeded, higher-priority entries are kept and the rest are skipped.',
222
241
  'search.placeholder': 'Search instructions: title / content / scenario ({count} in total)',
@@ -497,6 +516,41 @@ window.__ModuleLoader__.load({
497
516
  return focused ? currentText : String(incoming)
498
517
  }
499
518
 
519
+ /**
520
+ * Reduce a snapshot's injection plan to the values the header meter and
521
+ * the entry badges render. Pinned as a pure export so the fallback math is
522
+ * testable without React.
523
+ *
524
+ * Tolerates an older Host whose snapshot predates the plan fields: the
525
+ * ceiling then falls back to data.budgetChars and there are simply no
526
+ * per-entry marks (included/omitted). Garbage input degrades to zeros
527
+ * instead of throwing mid-render.
528
+ */
529
+ function budgetMeter(snapshot) {
530
+ const inj = snapshot !== null && typeof snapshot === 'object'
531
+ && snapshot.injection !== null && typeof snapshot.injection === 'object'
532
+ ? snapshot.injection : {}
533
+ const data = snapshot !== null && typeof snapshot === 'object'
534
+ && snapshot.data !== null && typeof snapshot.data === 'object'
535
+ ? snapshot.data : {}
536
+ const budget = typeof inj.budget === 'number' && inj.budget > 0
537
+ ? inj.budget
538
+ : (typeof data.budgetChars === 'number' && data.budgetChars > 0 ? data.budgetChars : 0)
539
+ const used = typeof inj.chars === 'number' && inj.chars >= 0 ? inj.chars : 0
540
+ const countFlagged = (list, wantTruncated) =>
541
+ Array.isArray(list)
542
+ ? list.filter((item) => item !== null && typeof item === 'object'
543
+ && typeof item.id === 'string' && item.id !== ''
544
+ && (wantTruncated ? item.truncated === true : true)).length
545
+ : 0
546
+ const omitted = countFlagged(inj.omitted, false)
547
+ const truncated = countFlagged(inj.included, true)
548
+ const percent = budget > 0 ? Math.min(100, Math.round((used / budget) * 100)) : 0
549
+ // The same rough ratio the budget note quotes; displayed as an ESTIMATE.
550
+ const tokens = Math.round(used * 0.6)
551
+ return { budget, used, omitted, truncated, percent, tokens }
552
+ }
553
+
500
554
  async function callHost(method, args) {
501
555
  // A hung host used to pin the panel on 保存中… forever; fail visibly
502
556
  // after a generous timeout instead.
@@ -837,6 +891,23 @@ window.__ModuleLoader__.load({
837
891
  const injection = view.injection || { registered: false, chars: 0, error: null }
838
892
  const entries = data.entries.slice().sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
839
893
  const activeCount = entries.filter((e) => e.enabled && e.content !== '').length
894
+ // Budget usage + per-entry marks straight from the Host's injection plan.
895
+ // Older hosts simply publish none of these fields: the meter falls back
896
+ // to data.budgetChars and the maps stay empty.
897
+ const meter = budgetMeter(view)
898
+ const omittedById = new Map()
899
+ const truncatedById = new Map()
900
+ {
901
+ const planItems = injection && typeof injection === 'object' ? injection : {}
902
+ for (const item of Array.isArray(planItems.omitted) ? planItems.omitted : []) {
903
+ if (item !== null && typeof item === 'object' && typeof item.id === 'string') omittedById.set(item.id, item)
904
+ }
905
+ for (const item of Array.isArray(planItems.included) ? planItems.included : []) {
906
+ if (item !== null && typeof item === 'object' && item.truncated === true && typeof item.id === 'string') {
907
+ truncatedById.set(item.id, item)
908
+ }
909
+ }
910
+ }
840
911
  const keyword = query.trim().toLowerCase()
841
912
  const visible = keyword === ''
842
913
  ? entries
@@ -902,6 +973,19 @@ window.__ModuleLoader__.load({
902
973
  h('label', { className: 'im-label' }, t('rules.label')),
903
974
  h('div', { className: 'im-meta' },
904
975
  t('rules.note')))),
976
+ (data.enabled === true && activeCount > 0)
977
+ ? h('div', { key: 'meter' },
978
+ h('div', { className: 'im-meter' },
979
+ h('div', {
980
+ className: 'im-meter-fill' + (meter.omitted > 0 ? ' im-meter-fill-warn' : ''),
981
+ style: { width: meter.percent + '%' },
982
+ })),
983
+ h('div', { className: 'im-meta im-meter-line' },
984
+ t('budget.used', { used: meter.used, budget: meter.budget, tokens: meter.tokens })),
985
+ meter.omitted > 0
986
+ ? h('div', { className: 'im-warn im-meter-line' }, t('budget.omitted', { count: meter.omitted }))
987
+ : null)
988
+ : null,
905
989
  entries.length > 0 ? h('div', { key: 'search' },
906
990
  h('input', {
907
991
  className: 'im-input',
@@ -987,6 +1071,17 @@ window.__ModuleLoader__.load({
987
1071
  ]
988
1072
  if (entry.priority === 2) tags.push(h('span', { className: 'im-tag im-tag-high', key: 'p' }, t('priority.high')))
989
1073
  else if (entry.priority === 0) tags.push(h('span', { className: 'im-tag', key: 'p' }, t('priority.low')))
1074
+ // Budget marks from the Host's injection plan. Only active entries can
1075
+ // appear in the plans, so a disabled entry never gets a stale mark.
1076
+ if (omittedById.has(entry.id)) {
1077
+ tags.push(h('span', {
1078
+ className: 'im-tag im-tag-off', key: 'off', title: t('tag.omittedTitle'),
1079
+ }, t('tag.omitted')))
1080
+ } else if (truncatedById.has(entry.id)) {
1081
+ tags.push(h('span', {
1082
+ className: 'im-tag im-tag-cut', key: 'cut', title: t('tag.truncatedTitle'),
1083
+ }, t('tag.truncated')))
1084
+ }
990
1085
  return h('div', {
991
1086
  className: 'im-item' + (entry.enabled ? '' : ' im-item-off'),
992
1087
  key: entry.id,
@@ -1136,6 +1231,7 @@ window.__ModuleLoader__.load({
1136
1231
  exports.__confirmDiscard = confirmDiscard
1137
1232
  exports.__importSizeVerdict = importSizeVerdict
1138
1233
  exports.__nextBudgetText = nextBudgetText
1234
+ exports.__budgetMeter = budgetMeter
1139
1235
  exports.__strings = STRINGS
1140
1236
  exports.__ns = NS
1141
1237
  exports.__dictionaryT = dictionaryT
package/lib/index.js CHANGED
@@ -518,19 +518,41 @@ function formatEntry(entry, tag) {
518
518
  }
519
519
 
520
520
  /**
521
- * Render the injected block. Deliberately deterministic: identical memory
522
- * produces byte-identical text on every step, so the prompt prefix stays
523
- * cacheable across a session.
521
+ * Build the injection plan: the exact text assembled into the system prompt
522
+ * PLUS the per-entry decisions behind it, so the settings page can show how
523
+ * much of the character budget is in use and flag the enabled entries the
524
+ * current budget leaves out.
525
+ *
526
+ * Deliberately deterministic: identical memory produces a byte-identical
527
+ * plan on every step, so the prompt prefix stays cacheable across a session.
528
+ *
529
+ * Returned shape:
530
+ * {
531
+ * text: string — exactly what buildBlock() returns
532
+ * budget: number — the clamped budgetChars in effect
533
+ * activeCount: number — enabled, non-blank candidates considered
534
+ * included: [{ id, chars, truncated }] — entries that reach the prompt,
535
+ * in consideration order (始终 first, then 按需; within
536
+ * each, priority desc then updatedAt desc); chars is the
537
+ * rendered length of that entry's block (its cut length
538
+ * when truncated)
539
+ * omitted: [{ id, chars }] — active entries the budget could not fit;
540
+ * chars is the full rendered block length they would need
541
+ * }
542
+ *
543
+ * Disabled or blank entries are candidates for neither list: they are not
544
+ * competing for the budget at all. When nothing renders, `text` is '' and
545
+ * `included` is [] even though `budget` still reports the effective ceiling.
524
546
  */
525
- export function buildBlock(data) {
526
- if (data === null || typeof data !== 'object' || data.enabled !== true) return ''
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: [] })
550
+ if (data === null || typeof data !== 'object' || data.enabled !== true) return blank()
527
551
  const active = (Array.isArray(data.entries) ? data.entries : [])
528
552
  .filter((entry) => entry.enabled === true && entry.content !== '')
529
- if (active.length === 0) return ''
553
+ if (active.length === 0) return blank()
530
554
  active.sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
531
555
 
532
- const budget = clampBudget(data.budgetChars)
533
-
534
556
  // Richest preamble that still leaves room for one real entry plus the
535
557
  // worst-case omission footer. A tight budget degrades the preamble instead of
536
558
  // silently injecting nothing.
@@ -565,6 +587,8 @@ export function buildBlock(data) {
565
587
  }
566
588
 
567
589
  const blocks = []
590
+ const included = []
591
+ const omittedPlan = []
568
592
  let used = header.length
569
593
  let omitted = 0
570
594
 
@@ -576,6 +600,7 @@ export function buildBlock(data) {
576
600
  if (used + block.length + 2 + reserve <= budget) {
577
601
  used += block.length + 2
578
602
  blocks.push(block)
603
+ included.push({ id: entry.id, chars: block.length, truncated: false })
579
604
  return
580
605
  }
581
606
  // The first entry always gets in, truncated if the budget demands it: a
@@ -593,17 +618,36 @@ export function buildBlock(data) {
593
618
  const body = head + note
594
619
  blocks.push(body)
595
620
  used += body.length + 2
621
+ included.push({ id: entry.id, chars: body.length, truncated: true })
596
622
  return
597
623
  }
598
624
  }
599
625
  omitted += 1
626
+ omittedPlan.push({ id: entry.id, chars: block.length })
600
627
  }
601
628
 
602
629
  for (const entry of active) if (entry.mode === 'always') consider(entry, '始终')
603
630
  for (const entry of active) if (entry.mode !== 'always') consider(entry, '按需')
604
631
 
605
- if (blocks.length === 0) return ''
606
- return header + blocks.join('\n\n') + footerFor(omitted)
632
+ if (blocks.length === 0) {
633
+ return { text: '', budget, activeCount: active.length, included: [], omitted: omittedPlan }
634
+ }
635
+ return {
636
+ text: header + blocks.join('\n\n') + footerFor(omitted),
637
+ budget,
638
+ activeCount: active.length,
639
+ included,
640
+ omitted: omittedPlan,
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Render the injected block text. A thin wrapper over planInjection: the
646
+ * decision process lives in one place, and callers that only need the text
647
+ * (the prompt section) keep this byte-stable entry point.
648
+ */
649
+ export function buildBlock(data) {
650
+ return planInjection(data).text
607
651
  }
608
652
 
609
653
  /* ------------------------------------------------------------------ *
@@ -725,6 +769,22 @@ export function apply(ctx) {
725
769
  }
726
770
  }
727
771
 
772
+ /**
773
+ * The full injection plan (text + per-entry budget decisions) behind the
774
+ * registered section. The snapshot publishes it so the settings page can
775
+ * show budget usage and mark the enabled entries that currently do not fit.
776
+ * A render failure degrades to an empty plan at the effective ceiling
777
+ * rather than throwing into the route/SSE path.
778
+ */
779
+ const sectionPlan = () => {
780
+ try {
781
+ return planInjection(state.data)
782
+ } catch (err) {
783
+ console.error('[instruction-memory] render failed:', err)
784
+ return { text: '', budget: clampBudget(state.data.budgetChars), activeCount: 0, included: [], omitted: [] }
785
+ }
786
+ }
787
+
728
788
  /**
729
789
  * The thunk handed to the harness.
730
790
  *
@@ -1287,7 +1347,8 @@ export function apply(ctx) {
1287
1347
  /* ---------------- snapshots ---------------- */
1288
1348
 
1289
1349
  const snapshot = () => {
1290
- const preview = sectionText()
1350
+ const plan = sectionPlan()
1351
+ const preview = plan.text
1291
1352
  return {
1292
1353
  data: {
1293
1354
  version: 1,
@@ -1310,6 +1371,16 @@ export function apply(ctx) {
1310
1371
  available: true,
1311
1372
  error: state.sectionError,
1312
1373
  chars: preview.length,
1374
+ /**
1375
+ * The budget decision behind `preview`, for the settings page's
1376
+ * usage meter and per-entry marks. `budget` is the effective ceiling;
1377
+ * `included`/`omitted` carry one item per active entry in
1378
+ * consideration order; disabled or blank entries appear in neither.
1379
+ */
1380
+ budget: plan.budget,
1381
+ activeCount: plan.activeCount,
1382
+ included: plan.included.map((item) => ({ id: item.id, chars: item.chars, truncated: item.truncated === true })),
1383
+ omitted: plan.omitted.map((item) => ({ id: item.id, chars: item.chars })),
1313
1384
  /**
1314
1385
  * How many times the harness has pulled this section into a prompt since
1315
1386
  * boot. 0 means "prepared, never assembled" — the distinction the old
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
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",