dsh-instruction-memory 1.0.16 → 1.0.18

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,64 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.0.18] — 2026-10-13
11
+
12
+ ### 新增
13
+
14
+ - **标签分组(F2)**:每条指令可维护若干标签(编辑器内回车/逗号/分号提交的 chips,最多 8 个、
15
+ 单个不超过 24 字符;去空白、折叠连续空白、去重)。标签只服务于设置页,**不会进入注入文本**,
16
+ `buildBlock`/`planInjection` 的输出与决策不受其影响。条目新增可选 `tags: string[]`(格式仍为 v1,
17
+ 旧记忆无该字段即按无标签加载)。
18
+ - **多维筛选与排序(F2)**:列表可按标签(chips)、生效方式(始终/按需)、启用状态组合筛选,
19
+ 并在按优先级 / 按更新时间 / 按标题之间切换排序;关键词搜索与这些筛选叠加且覆盖标签文本。
20
+ 排序仅影响列表展示,宿主侧注入顺序不变。筛选无结果时给出独立空态文案。
21
+ - **批量操作(F3)**:设置页新增多选模式,可一次性启用、停用、设置优先级、加标签或删除;
22
+ 全选只作用于当前筛选/搜索后的可见条目,批量删除需确认。Host 新增 `batch` 路由
23
+ (`set-enabled` / `set-priority` / `add-tags` / `remove-tags` / `delete` 五个 op):
24
+ 整批在**单次事务**内完成(一次落盘、rev 只 +1、一次 SSE 推送),先纯计算变更计划,
25
+ 全部条目已是目标状态或只涉及不存在 id 时不写盘,部分命中会在消息中注明未找到的条数;
26
+ 复用未来版本拒绝、rev 冲突、写盘失败回滚等既有保护。批量元数据调整不重盖 `updatedAt`。
27
+ - 请求体上限推导纳入标签字段,继续保证「本插件导出的文件必能导回」。
28
+
29
+ ### 测试
30
+
31
+ - `smoke-test.mjs` 新增标签规范化与「标签不进注入文本/计划」断言。
32
+ - `contract-test.mjs` 新增独立 `batch` 验证块:经真实路由断言五个 op、单次事务(rev 恰好 +1)、
33
+ no-op 不写盘、部分命中计数、非法 op/value/空 ids 拒绝、stale rev 冲突、标签不进 preview。
34
+ - `verify.mjs` 新增客户端 `__parseTagInput` 断言(分隔符、空白折叠、去重、上限、非字符串输入)
35
+ 与 F2/F3 双语文案键存在性;`draftIsDirty` 对 `tags` 缺省/`[]` 归一比较,旧条目不被误判为已修改。
36
+ - `docs/demo.html` 演示数据加入标签,演示宿主支持 `batch` 路由与标签编辑/筛选/排序/批量操作。
37
+
38
+ ## [1.0.17] — 2026-10-12
39
+
40
+ ### 新增
41
+
42
+ - **注入预算可视化(F1)**:设置页在「注入字数上限」下方显示用量条与「实际注入 x / y 字符
43
+ (约 z token,估算)」(token 按 0.6 系数估算,与既有说明同一口径,仅作参考);被上限略过的
44
+ **生效中**条目在列表中逐条标注橙色「本轮不注入」(悬停说明原因),极端情况下首条被迫截断时
45
+ 标注红色「已截断」,用量条同时变为警示色并给出条数提示。调高上限或调整优先级保存后标记即时变化。
46
+ - Host 新增导出纯函数 `planInjection(data)`:在**不改变注入文本一个字节**的前提下,返回注入文本、
47
+ 生效上限(clamp 后的 budget)、候选条目数,以及每条 `included`(`{id, chars, truncated}`)
48
+ / `omitted`(`{id, chars}`)的取舍明细与考虑顺序(先「始终」后「按需」、同档优先级/更新时间降序);
49
+ `buildBlock()` 改为它的薄封装。
50
+ - 快照 `injection` 新增 `budget` / `activeCount` / `included` / `omitted` 字段(SSE 帧随之自动携带)。
51
+ 存储格式仍为 v1、**无新增落盘字段**;旧客户端收到新字段自然忽略,新客户端对没有这些字段的旧宿主
52
+ 快照回退到 `data.budgetChars`、不显示条目标记,两侧均不报错。
53
+
54
+ ### 测试
55
+
56
+ - `smoke-test.mjs` 新增 20+ 条 `planInjection` 断言:空/总开关关闭/非法输入、budget 越界夹取、
57
+ 与 `buildBlock()` 逐字节一致、停用与空条目不参与、「始终」先于「按需」、紧预算下 included/omitted
58
+ 恰好分区全部候选、单条超长首条截断标记、计划本身确定性。
59
+ - `contract-test.mjs` 新增独立验证块:经真实路由在 800 字符上限下断言快照预算、`chars === preview.length`
60
+ 且不超上限、取舍明细恰好覆盖全部生效条目且不含停用条目、预览脚注数量与 omitted 一致、提高上限后
61
+ 全部纳入;并把真实/旧版/垃圾三类快照喂给客户端 reducer 验证接缝。
62
+ - `verify.mjs` 新增客户端 `__budgetMeter` reducer 断言(计划字段优先、旧快照回退、非法项忽略、
63
+ 垃圾输入零值不抛错、percent 封顶 100、0.6 token 估算)及 6 个新 i18n 键的双语存在性;
64
+ 键集与占位符一致性仍由既有用例锁定。
65
+ - `docs/demo.html` 的演示宿主改为实时计算注入计划(与 `planInjection` 同一算法),演示数据
66
+ 默认 800 字符上限以直接展示用量条与「本轮不注入」标记;旧的手工静态预览文本移除。
67
+
10
68
  ## [1.0.16] — 2026-10-05
11
69
 
12
70
  一次定向缺陷修复 + 宿主范围扩展。每项修复都有对应回归断言(`npm test` 五套件全绿:
package/README.md CHANGED
@@ -54,6 +54,19 @@ 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
+ 存储格式不变,旧宿主/旧客户端对新字段一律忽略,互不影响。
62
+ - **标签、筛选与排序**:每条指令可加若干**标签**(回车或逗号添加,最多 8 个、单个不超过 24 字符)。
63
+ 标签只用于设置页分组与筛选,**不会进入注入文本**;标签缺失的旧记忆照常加载(等同无标签)。
64
+ 列表可按标签、生效方式(始终/按需)、启用状态组合筛选,并在「按优先级 / 按更新时间 / 按标题」
65
+ 之间切换排序;列表排序纯为查看方便,不改变宿主侧的注入顺序。
66
+ - **批量操作**:点「批量操作」进入多选,可对勾选条目一次性启用、停用、设置优先级、加标签或删除;
67
+ 多选与上方的筛选/搜索叠加(「全选」只作用于当前可见条目)。整批修改在宿主侧是**单次写入**
68
+ (只产生一次落盘、一次修订号递增与一次同步推送),全部条目已是目标状态时不写盘;批量删除需确认。
69
+ 批量的启停/优先级/标签调整不重盖更新时间,因此不会改变同优先级下的注入先后。
57
70
 
58
71
  条目全部停用或总开关关闭时,该提示词段落**整个注销**,系统提示词与未安装本插件时逐字节一致。
59
72
 
@@ -432,6 +445,7 @@ Package → Settings → Trusted publishing → Add trusted publisher
432
445
  "when": "",
433
446
  "priority": 1,
434
447
  "enabled": true,
448
+ "tags": ["沟通"],
435
449
  "updatedAt": 1700000000000
436
450
  }
437
451
  ]
@@ -439,4 +453,6 @@ Package → Settings → Trusted publishing → Add trusted publisher
439
453
  ```
440
454
 
441
455
  `mode` 为 `always` | `auto`;`priority` 为 `2` 高 / `1` 普通 / `0` 低。`rev` 是存储修订号,
442
- 由插件在每次成功写入时递增,**无需手填**(缺失视为 0)。
456
+ 由插件在每次成功写入时递增,**无需手填**(缺失视为 0)。`tags` 为可选的短标签字符串数组
457
+ (最多 8 个、单个不超过 24 字符,仅用于设置页筛选,不参与注入);缺失或非数组一律按无标签处理,
458
+ 非法项会被剔除、超长会被截断、重复项会去重,格式版本号仍为 `1`。
package/lib/client.js CHANGED
@@ -58,6 +58,31 @@ 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); }
67
+ .im-entry-tags { display:flex; flex-wrap:wrap; gap:4px; }
68
+ .im-chip { display:inline-flex; align-items:center; gap:3px; padding:1px 4px 1px 9px; border-radius:999px; font-size:11px;
69
+ border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.14)); color:var(--dsw-alias-label-secondary,#6b7280);
70
+ background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.03)); white-space:nowrap; }
71
+ .im-chip-x { border:none; background:transparent; color:inherit; cursor:pointer; font-size:13px; line-height:1;
72
+ padding:0 5px; border-radius:999px; }
73
+ .im-chip-x:hover:not(:disabled) { background:rgba(0,0,0,.08); }
74
+ .im-filter-row { display:flex; flex-direction:column; gap:7px; }
75
+ .im-filter-chips { display:flex; flex-wrap:wrap; gap:6px; }
76
+ .im-filter-chip { border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.18)); background:var(--dsw-alias-bg-layer-1,#fff);
77
+ color:inherit; border-radius:999px; padding:2px 11px; font-size:11.5px; font-family:inherit; cursor:pointer; }
78
+ .im-filter-chip-on { background:var(--dsw-alias-brand-primary,#3b6cff); border-color:transparent; color:#fff; }
79
+ .im-filter-selects { display:flex; flex-wrap:wrap; gap:12px; }
80
+ .im-fselect { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--dsw-alias-label-secondary,#8a9099); }
81
+ .im-select-sm { padding:3px 6px; font-size:12px; border-radius:7px; border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.18));
82
+ background:var(--dsw-alias-bg-base,#fff); color:inherit; }
83
+ .im-batch-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:10px 12px; border-radius:10px;
84
+ border:1px solid var(--dsw-alias-state-warn-primary,#d97706); background:rgba(217,119,6,.08); }
85
+ .im-batch-check { width:15px; height:15px; margin:0; cursor:pointer; accent-color:var(--dsw-alias-brand-primary,#3b6cff); }
61
86
  .im-empty { padding:26px; border-radius:10px; text-align:center; font-size:12px;
62
87
  border:1px dashed var(--dsw-alias-border-l2,rgba(0,0,0,.18)); color:var(--dsw-alias-label-secondary,#8a9099); }
63
88
  .im-err { font-size:12px; color:var(--dsw-alias-state-error-primary,#d94838); }
@@ -79,6 +104,7 @@ window.__ModuleLoader__.load({
79
104
  .im-body-open { max-height:none; }
80
105
  .im-more { margin-top:4px; font-size:11.5px; color:var(--dsw-alias-brand-primary,#3b6cff); cursor:pointer; user-select:none; }
81
106
  .im-search-meta { margin-top:5px; }
107
+ .im-meter-line { margin-top:5px; }
82
108
  .im-undo { display:flex; align-items:center; gap:10px; padding:9px 13px; border-radius:8px; font-size:12px;
83
109
  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
110
  .im-undo button { border:1px solid var(--dsw-alias-brand-primary,#3b6cff); color:var(--dsw-alias-brand-primary,#3b6cff);
@@ -140,10 +166,47 @@ window.__ModuleLoader__.load({
140
166
  'budget.unitHint': '单位是字符,不是 token',
141
167
  'budget.rangeError': '上限需在 {min} ~ {max} 之间',
142
168
  'budget.note': '单位为字符、非 token:按 DeepSeek 分词约 1 个中文字符 ≈ 0.6 token(英文 ≈ 0.3),{max} 字符上限约合 {tokens} token,实际以模型为准。',
169
+ 'budget.used': '实际注入 {used} / {budget} 字符(约 {tokens} token,估算)',
170
+ 'budget.omitted': '有 {count} 条生效指令因超出上限本轮未注入,可调高上限或调整优先级',
171
+ 'tag.omitted': '本轮不注入',
172
+ 'tag.truncated': '已截断',
173
+ 'tag.omittedTitle': '因超出注入字数上限,本条当前不会进入系统提示词',
174
+ 'tag.truncatedTitle': '本条过长,仅开头部分进入系统提示词',
143
175
  'rules.label': '生效规则',
144
176
  'rules.note': '「始终」无条件生效;「按需」由模型按相关性自动判断。超出上限时按优先级保留,其余暂不注入。',
145
177
  'search.placeholder': '搜索指令:标题 / 内容 / 适用场景(共 {count} 条)',
146
178
  'search.matchCount': '匹配 {shown} / {total} 条',
179
+ 'tags.label': '标签(可选,仅用于在本页分组与筛选,不会注入提示词)',
180
+ 'tags.placeholder': '输入标签后回车,逗号分隔多个,例如:代码规范',
181
+ 'tags.hint': '每条最多 {max} 个标签,单个不超过 {len} 字符',
182
+ 'tags.duplicate': '已有同名标签',
183
+ 'tags.full': '每条最多 {max} 个标签',
184
+ 'filter.all': '全部',
185
+ 'filter.tag': '标签',
186
+ 'filter.mode': '生效方式',
187
+ 'filter.status': '状态',
188
+ 'filter.modeAlways': '始终',
189
+ 'filter.modeAuto': '按需',
190
+ 'filter.statusEnabled': '已启用',
191
+ 'filter.statusDisabled': '已停用',
192
+ 'sort.label': '排序',
193
+ 'sort.priority': '按优先级',
194
+ 'sort.updated': '按更新时间',
195
+ 'sort.title': '按标题',
196
+ 'action.batch': '批量操作',
197
+ 'batch.exit': '退出批量',
198
+ 'batch.selectAll': '全选',
199
+ 'batch.selected': '已选 {count} 条',
200
+ 'batch.enable': '启用',
201
+ 'batch.disable': '停用',
202
+ 'batch.priority': '设优先级…',
203
+ 'batch.addTags': '加标签…',
204
+ 'batch.delete': '删除',
205
+ 'batch.confirmDelete': '确定删除选中的 {count} 条指令?删除后无法恢复。',
206
+ 'batch.tagPrompt': '输入要添加到 {count} 条指令的标签(多个用逗号分隔)',
207
+ 'batch.tagEmpty': '未输入有效的标签',
208
+ 'batch.noneSelected': '请先勾选要操作的指令',
209
+ 'batch.promptUnavailable': '当前环境不支持快捷输入,请逐条编辑标签',
147
210
  'action.new': '+ 新增指令',
148
211
  'action.reload': '重新载入',
149
212
  'action.export': '导出',
@@ -160,6 +223,7 @@ window.__ModuleLoader__.load({
160
223
  'preview.empty': '(当前没有生效中的指令)',
161
224
  'list.empty': '还没有指令记忆。点击「+ 新增指令」添加第一条,例如“所有回答使用简体中文”。',
162
225
  'list.noMatch': '没有匹配“{query}”的指令。',
226
+ 'list.filteredEmpty': '没有符合当前筛选条件的指令。',
163
227
  'tag.always': '始终',
164
228
  'tag.auto': '按需',
165
229
  'entry.enabled': '启用',
@@ -217,10 +281,47 @@ window.__ModuleLoader__.load({
217
281
  'budget.unitHint': 'The unit is characters, not tokens',
218
282
  'budget.rangeError': 'The limit must be between {min} and {max}',
219
283
  '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.',
284
+ 'budget.used': '{used} of {budget} characters actually injected (≈{tokens} tokens, estimate)',
285
+ 'budget.omitted': '{count} active entries are not injected this turn because the limit is exceeded; raise the limit or adjust priorities',
286
+ 'tag.omitted': 'Not injected',
287
+ 'tag.truncated': 'Truncated',
288
+ 'tag.omittedTitle': 'This entry currently stays out of the system prompt because the injection size limit is exceeded',
289
+ 'tag.truncatedTitle': 'This entry is too long; only its beginning reaches the system prompt',
220
290
  'rules.label': 'How instructions apply',
221
291
  '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
292
  'search.placeholder': 'Search instructions: title / content / scenario ({count} in total)',
223
293
  'search.matchCount': '{shown} of {total} match',
294
+ 'tags.label': 'Tags (optional; used only for grouping/filtering on this page, never injected)',
295
+ 'tags.placeholder': 'Type a tag and press Enter; separate several with commas, e.g. code-style',
296
+ 'tags.hint': 'Up to {max} tags per entry, each at most {len} characters',
297
+ 'tags.duplicate': 'This tag already exists',
298
+ 'tags.full': 'Up to {max} tags per entry',
299
+ 'filter.all': 'All',
300
+ 'filter.tag': 'Tag',
301
+ 'filter.mode': 'Mode',
302
+ 'filter.status': 'Status',
303
+ 'filter.modeAlways': 'Always',
304
+ 'filter.modeAuto': 'On demand',
305
+ 'filter.statusEnabled': 'Enabled',
306
+ 'filter.statusDisabled': 'Disabled',
307
+ 'sort.label': 'Sort',
308
+ 'sort.priority': 'By priority',
309
+ 'sort.updated': 'By updated time',
310
+ 'sort.title': 'By title',
311
+ 'action.batch': 'Bulk actions',
312
+ 'batch.exit': 'Exit bulk',
313
+ 'batch.selectAll': 'Select all',
314
+ 'batch.selected': '{count} selected',
315
+ 'batch.enable': 'Enable',
316
+ 'batch.disable': 'Disable',
317
+ 'batch.priority': 'Set priority…',
318
+ 'batch.addTags': 'Add tags…',
319
+ 'batch.delete': 'Delete',
320
+ 'batch.confirmDelete': 'Delete the {count} selected entries? This cannot be undone.',
321
+ 'batch.tagPrompt': 'Enter tags to add to {count} entries (separate several with commas)',
322
+ 'batch.tagEmpty': 'No valid tag entered',
323
+ 'batch.noneSelected': 'Tick the entries to act on first',
324
+ 'batch.promptUnavailable': 'Quick entry is unavailable in this environment; edit tags one by one',
224
325
  'action.new': '+ New instruction',
225
326
  'action.reload': 'Reload',
226
327
  'action.export': 'Export',
@@ -237,6 +338,7 @@ window.__ModuleLoader__.load({
237
338
  'preview.empty': '(no active instructions right now)',
238
339
  'list.empty': 'No instructions yet. Click “+ New instruction” to add your first one, e.g. “Always answer in Simplified Chinese”.',
239
340
  'list.noMatch': 'No instructions match “{query}”.',
341
+ 'list.filteredEmpty': 'No instructions match the current filters.',
240
342
  'tag.always': 'Always',
241
343
  'tag.auto': 'On demand',
242
344
  'entry.enabled': 'Enabled',
@@ -377,6 +479,32 @@ window.__ModuleLoader__.load({
377
479
 
378
480
  /* ---------------- data helpers ---------------- */
379
481
 
482
+ // Mirror the Host's tag limits (lib/index.js); kept local so the editor
483
+ // constrains input before the round-trip and tests can pin both to match.
484
+ const MAX_TAGS = 8
485
+ const MAX_TAG_LEN = 24
486
+
487
+ /**
488
+ * Parse free text typed into the tag box into normalised tag candidates.
489
+ * Tags are split on commas (ASCII/full-width), semicolons and newlines —
490
+ * never spaces, so a tag may contain spaces. Each piece is trimmed,
491
+ * collapsed to one line, cut to MAX_TAG_LEN, de-duplicated and capped at
492
+ * MAX_TAGS. Pinned as a pure export.
493
+ */
494
+ function parseTagInput(text) {
495
+ if (typeof text !== 'string') return []
496
+ const out = []
497
+ const seen = new Set()
498
+ for (const piece of text.split(/[,,;;\n\r]+/)) {
499
+ const tag = piece.replace(/\s+/g, ' ').trim().slice(0, MAX_TAG_LEN)
500
+ if (tag === '' || seen.has(tag)) continue
501
+ seen.add(tag)
502
+ if (out.length >= MAX_TAGS) break
503
+ out.push(tag)
504
+ }
505
+ return out
506
+ }
507
+
380
508
  function cloneEntry(entry) {
381
509
  return {
382
510
  id: entry.id,
@@ -386,12 +514,13 @@ window.__ModuleLoader__.load({
386
514
  when: entry.when,
387
515
  priority: entry.priority,
388
516
  enabled: entry.enabled,
517
+ tags: Array.isArray(entry.tags) ? entry.tags.slice() : [],
389
518
  updatedAt: entry.updatedAt,
390
519
  }
391
520
  }
392
521
 
393
522
  function blankDraft() {
394
- return { id: '', title: '', content: '', mode: 'always', when: '', priority: 1, enabled: true, updatedAt: 0 }
523
+ return { id: '', title: '', content: '', mode: 'always', when: '', priority: 1, enabled: true, tags: [], updatedAt: 0 }
395
524
  }
396
525
 
397
526
  /**
@@ -409,7 +538,11 @@ window.__ModuleLoader__.load({
409
538
  const fields = ['title', 'content', 'mode', 'when', 'priority', 'enabled']
410
539
  const base = draft.id === '' ? blankDraft() : (entries || []).find((entry) => entry.id === draft.id)
411
540
  if (base === undefined) return true
412
- return fields.some((field) => String(draft[field]) !== String(base[field]))
541
+ if (fields.some((field) => String(draft[field]) !== String(base[field]))) return true
542
+ // Tags are an optional array that may be absent on old entries; compare
543
+ // a normalised form so [] and undefined both read as "no tags".
544
+ const tagsKey = (entry) => (Array.isArray(entry && entry.tags) ? entry.tags : []).join('')
545
+ return tagsKey(draft) !== tagsKey(base)
413
546
  }
414
547
 
415
548
  /**
@@ -497,6 +630,41 @@ window.__ModuleLoader__.load({
497
630
  return focused ? currentText : String(incoming)
498
631
  }
499
632
 
633
+ /**
634
+ * Reduce a snapshot's injection plan to the values the header meter and
635
+ * the entry badges render. Pinned as a pure export so the fallback math is
636
+ * testable without React.
637
+ *
638
+ * Tolerates an older Host whose snapshot predates the plan fields: the
639
+ * ceiling then falls back to data.budgetChars and there are simply no
640
+ * per-entry marks (included/omitted). Garbage input degrades to zeros
641
+ * instead of throwing mid-render.
642
+ */
643
+ function budgetMeter(snapshot) {
644
+ const inj = snapshot !== null && typeof snapshot === 'object'
645
+ && snapshot.injection !== null && typeof snapshot.injection === 'object'
646
+ ? snapshot.injection : {}
647
+ const data = snapshot !== null && typeof snapshot === 'object'
648
+ && snapshot.data !== null && typeof snapshot.data === 'object'
649
+ ? snapshot.data : {}
650
+ const budget = typeof inj.budget === 'number' && inj.budget > 0
651
+ ? inj.budget
652
+ : (typeof data.budgetChars === 'number' && data.budgetChars > 0 ? data.budgetChars : 0)
653
+ const used = typeof inj.chars === 'number' && inj.chars >= 0 ? inj.chars : 0
654
+ const countFlagged = (list, wantTruncated) =>
655
+ Array.isArray(list)
656
+ ? list.filter((item) => item !== null && typeof item === 'object'
657
+ && typeof item.id === 'string' && item.id !== ''
658
+ && (wantTruncated ? item.truncated === true : true)).length
659
+ : 0
660
+ const omitted = countFlagged(inj.omitted, false)
661
+ const truncated = countFlagged(inj.included, true)
662
+ const percent = budget > 0 ? Math.min(100, Math.round((used / budget) * 100)) : 0
663
+ // The same rough ratio the budget note quotes; displayed as an ESTIMATE.
664
+ const tokens = Math.round(used * 0.6)
665
+ return { budget, used, omitted, truncated, percent, tokens }
666
+ }
667
+
500
668
  async function callHost(method, args) {
501
669
  // A hung host used to pin the panel on 保存中… forever; fail visibly
502
670
  // after a generous timeout instead.
@@ -588,6 +756,13 @@ window.__ModuleLoader__.load({
588
756
  onChange: (ev) => set({ when: ev.target.value }),
589
757
  })))
590
758
  }
759
+ children.push(h('div', { key: 'tags' },
760
+ h('label', { className: 'im-label' }, t('tags.label')),
761
+ h(TagInput, {
762
+ tags: draft.tags,
763
+ disabled: props.busy,
764
+ onChange: (next) => set({ tags: next }),
765
+ })))
591
766
  children.push(h('div', { className: 'im-row-wrap', key: 'a' },
592
767
  h('button', { className: 'im-btn im-btn-primary', disabled: props.busy, onClick: props.onSave },
593
768
  props.busy ? t('editor.saving') : t('editor.save')),
@@ -597,6 +772,73 @@ window.__ModuleLoader__.load({
597
772
  return h('div', { className: 'im-card' }, children)
598
773
  }
599
774
 
775
+ /**
776
+ * Tag chips editor for one entry. Tags live only on this panel (they are
777
+ * never injected). Committing (Enter / comma / semicolon / blur) parses the
778
+ * typed text through parseTagInput and merges de-duplicated tags up to the
779
+ * shared cap; Backspace on an empty box removes the last chip.
780
+ */
781
+ function TagInput(props) {
782
+ const [text, setText] = React.useState('')
783
+ const tags = Array.isArray(props.tags) ? props.tags : []
784
+ const merge = (pieces) => {
785
+ const merged = []
786
+ for (const tag of [...tags, ...pieces]) {
787
+ if (!merged.includes(tag)) merged.push(tag)
788
+ }
789
+ props.onChange(merged.slice(0, MAX_TAGS))
790
+ }
791
+ const commit = () => {
792
+ const parsed = parseTagInput(text)
793
+ if (parsed.length > 0) merge(parsed)
794
+ setText('')
795
+ }
796
+ const remove = (tag) => {
797
+ props.onChange(tags.filter((candidate) => candidate !== tag))
798
+ }
799
+ return h('div', null,
800
+ tags.length > 0
801
+ ? h('div', { className: 'im-entry-tags' },
802
+ tags.map((tag) => h('span', { className: 'im-chip', key: tag },
803
+ tag,
804
+ h('button', {
805
+ type: 'button', className: 'im-chip-x', disabled: props.disabled,
806
+ onClick: () => remove(tag),
807
+ }, '×'))))
808
+ : null,
809
+ h('input', {
810
+ className: 'im-input',
811
+ value: text,
812
+ disabled: props.disabled,
813
+ maxLength: MAX_TAG_LEN,
814
+ placeholder: t('tags.placeholder'),
815
+ onChange: (ev) => {
816
+ const v = ev.target.value
817
+ if (/[,,;;\n\r]/.test(v)) {
818
+ // A separator commits the pending text as chips immediately.
819
+ // v is the input's full new value (it already contains what was
820
+ // typed before the separator) — do NOT prepend `text`, that
821
+ // duplicated it ("b" + "b," -> "bb").
822
+ const parsed = parseTagInput(v)
823
+ if (parsed.length > 0) merge(parsed)
824
+ setText('')
825
+ } else {
826
+ setText(v.slice(0, MAX_TAG_LEN))
827
+ }
828
+ },
829
+ onKeyDown: (ev) => {
830
+ if (ev.key === 'Enter') {
831
+ ev.preventDefault()
832
+ commit()
833
+ } else if (ev.key === 'Backspace' && text === '' && tags.length > 0) {
834
+ remove(tags[tags.length - 1])
835
+ }
836
+ },
837
+ onBlur: commit,
838
+ }),
839
+ h('div', { className: 'im-meta' }, t('tags.hint', { max: MAX_TAGS, len: MAX_TAG_LEN })))
840
+ }
841
+
600
842
  /**
601
843
  * Entry body with an explicit 展开/收起 toggle. The collapsed cap lives in
602
844
  * CSS (max-height + overflow:hidden), so whether overflow actually happens
@@ -632,6 +874,14 @@ window.__ModuleLoader__.load({
632
874
  const [showPreview, setShowPreview] = React.useState(false)
633
875
  const [query, setQuery] = React.useState('')
634
876
  const [undo, setUndo] = React.useState(null)
877
+ // F2: tag/mode/status filters ('' = all) and the list sort key.
878
+ const [tagFilter, setTagFilter] = React.useState('')
879
+ const [modeFilter, setModeFilter] = React.useState('')
880
+ const [statusFilter, setStatusFilter] = React.useState('')
881
+ const [sortKey, setSortKey] = React.useState('priority')
882
+ // F3: bulk-select mode and the chosen entry ids in it.
883
+ const [batchMode, setBatchMode] = React.useState(false)
884
+ const [selected, setSelected] = React.useState([])
635
885
  const importInputRef = React.useRef(null)
636
886
  // Whether the budget input is being edited right now. A live SSE push
637
887
  // from another window must not replace the number under the cursor;
@@ -716,6 +966,54 @@ window.__ModuleLoader__.load({
716
966
  }
717
967
  }
718
968
 
969
+ // ---- F3 bulk actions: one request, one host-side transaction ----
970
+
971
+ /** Selected ids that still exist in the latest snapshot. */
972
+ const liveSelected = () => selected.filter((id) => data.entries.some((entry) => entry.id === id))
973
+
974
+ function toggleSelected(id) {
975
+ setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
976
+ }
977
+
978
+ function exitBatch() {
979
+ setBatchMode(false)
980
+ setSelected([])
981
+ }
982
+
983
+ async function doBatch(op, value) {
984
+ const ids = liveSelected()
985
+ if (ids.length === 0) {
986
+ setError(t('batch.noneSelected'))
987
+ return
988
+ }
989
+ if (op === 'delete'
990
+ && typeof window.confirm === 'function'
991
+ && !window.confirm(t('batch.confirmDelete', { count: ids.length }))) {
992
+ return
993
+ }
994
+ const result = await send('batch', { op, ids, value, baseRev: revOf() })
995
+ if (result !== null && result.ok === true && op === 'delete') {
996
+ // Drop deleted ids from the selection; other ops keep it so several
997
+ // bulk actions can be chained (e.g. set priority, then disable).
998
+ setSelected((prev) => prev.filter((id) => !ids.includes(id)))
999
+ }
1000
+ }
1001
+
1002
+ function batchAddTags() {
1003
+ if (typeof window.prompt !== 'function') {
1004
+ setError(t('batch.promptUnavailable'))
1005
+ return
1006
+ }
1007
+ const answer = window.prompt(t('batch.tagPrompt', { count: liveSelected().length }), '')
1008
+ if (answer === null) return
1009
+ const tags = parseTagInput(answer)
1010
+ if (tags.length === 0) {
1011
+ setError(t('batch.tagEmpty'))
1012
+ return
1013
+ }
1014
+ void doBatch('add-tags', tags)
1015
+ }
1016
+
719
1017
  async function doExport() {
720
1018
  try {
721
1019
  const result = await callHost('export-data', null)
@@ -835,15 +1133,73 @@ window.__ModuleLoader__.load({
835
1133
  const storage = view.storage || {}
836
1134
  const limits = view.limits || { minBudget: 800, maxBudget: 40000, maxEntries: 200 }
837
1135
  const injection = view.injection || { registered: false, chars: 0, error: null }
838
- const entries = data.entries.slice().sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
839
- const activeCount = entries.filter((e) => e.enabled && e.content !== '').length
1136
+ // List order is display-only and never changes what the Host injects
1137
+ // (the injection order is decided host-side). The default mirrors the
1138
+ // previous fixed order; updated-time and title are the other sorts.
1139
+ const sortEntries = (list) => {
1140
+ const copy = list.slice()
1141
+ if (sortKey === 'updated') {
1142
+ copy.sort((a, b) => (b.updatedAt - a.updatedAt) || (b.priority - a.priority))
1143
+ } else if (sortKey === 'title') {
1144
+ copy.sort((a, b) =>
1145
+ a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })
1146
+ || (b.priority - a.priority)
1147
+ || (b.updatedAt - a.updatedAt))
1148
+ } else {
1149
+ copy.sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
1150
+ }
1151
+ return copy
1152
+ }
1153
+ const entries = sortEntries(data.entries)
1154
+ const activeCount = data.entries.filter((e) => e.enabled && e.content !== '').length
1155
+ // Every tag actually in use, most frequent first (then name), for the
1156
+ // tag filter row.
1157
+ const allTags = (() => {
1158
+ const counts = new Map()
1159
+ for (const entry of data.entries) {
1160
+ for (const tag of Array.isArray(entry.tags) ? entry.tags : []) {
1161
+ if (typeof tag === 'string' && tag !== '') counts.set(tag, (counts.get(tag) || 0) + 1)
1162
+ }
1163
+ }
1164
+ return [...counts.entries()]
1165
+ .sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }))
1166
+ .map(([tag]) => tag)
1167
+ })()
1168
+ // Budget usage + per-entry marks straight from the Host's injection plan.
1169
+ // Older hosts simply publish none of these fields: the meter falls back
1170
+ // to data.budgetChars and the maps stay empty.
1171
+ const meter = budgetMeter(view)
1172
+ const omittedById = new Map()
1173
+ const truncatedById = new Map()
1174
+ {
1175
+ const planItems = injection && typeof injection === 'object' ? injection : {}
1176
+ for (const item of Array.isArray(planItems.omitted) ? planItems.omitted : []) {
1177
+ if (item !== null && typeof item === 'object' && typeof item.id === 'string') omittedById.set(item.id, item)
1178
+ }
1179
+ for (const item of Array.isArray(planItems.included) ? planItems.included : []) {
1180
+ if (item !== null && typeof item === 'object' && item.truncated === true && typeof item.id === 'string') {
1181
+ truncatedById.set(item.id, item)
1182
+ }
1183
+ }
1184
+ }
840
1185
  const keyword = query.trim().toLowerCase()
841
- const visible = keyword === ''
842
- ? entries
843
- : entries.filter((entry) =>
844
- entry.title.toLowerCase().indexOf(keyword) !== -1
1186
+ const filtersActive = tagFilter !== '' || modeFilter !== '' || statusFilter !== ''
1187
+ const matchesFilters = (entry) => {
1188
+ if (tagFilter !== '' && !(Array.isArray(entry.tags) && entry.tags.includes(tagFilter))) return false
1189
+ if (modeFilter !== '' && entry.mode !== modeFilter) return false
1190
+ if (statusFilter === 'enabled' && entry.enabled !== true) return false
1191
+ if (statusFilter === 'disabled' && entry.enabled !== false) return false
1192
+ return true
1193
+ }
1194
+ const visible = entries.filter((entry) => {
1195
+ if (!matchesFilters(entry)) return false
1196
+ if (keyword === '') return true
1197
+ const inTags = (Array.isArray(entry.tags) ? entry.tags.join(' ') : '').toLowerCase()
1198
+ return entry.title.toLowerCase().indexOf(keyword) !== -1
845
1199
  || entry.content.toLowerCase().indexOf(keyword) !== -1
846
- || entry.when.toLowerCase().indexOf(keyword) !== -1)
1200
+ || entry.when.toLowerCase().indexOf(keyword) !== -1
1201
+ || inTags.indexOf(keyword) !== -1
1202
+ })
847
1203
 
848
1204
  const dotClass = injection.error
849
1205
  ? 'im-dot im-dot-err'
@@ -902,6 +1258,112 @@ window.__ModuleLoader__.load({
902
1258
  h('label', { className: 'im-label' }, t('rules.label')),
903
1259
  h('div', { className: 'im-meta' },
904
1260
  t('rules.note')))),
1261
+ (data.enabled === true && activeCount > 0)
1262
+ ? h('div', { key: 'meter' },
1263
+ h('div', { className: 'im-meter' },
1264
+ h('div', {
1265
+ className: 'im-meter-fill' + (meter.omitted > 0 ? ' im-meter-fill-warn' : ''),
1266
+ style: { width: meter.percent + '%' },
1267
+ })),
1268
+ h('div', { className: 'im-meta im-meter-line' },
1269
+ t('budget.used', { used: meter.used, budget: meter.budget, tokens: meter.tokens })),
1270
+ meter.omitted > 0
1271
+ ? h('div', { className: 'im-warn im-meter-line' }, t('budget.omitted', { count: meter.omitted }))
1272
+ : null)
1273
+ : null,
1274
+ // F3 bulk action bar, only while in multi-select mode. "Select all"
1275
+ // covers the currently visible (filtered/searched) entries.
1276
+ (batchMode && entries.length > 0)
1277
+ ? h('div', { key: 'batchbar', className: 'im-batch-bar' },
1278
+ h('label', { className: 'im-switch' },
1279
+ h('input', {
1280
+ type: 'checkbox',
1281
+ className: 'im-batch-check',
1282
+ checked: visible.length > 0 && visible.every((entry) => selected.includes(entry.id)),
1283
+ disabled: busy,
1284
+ onChange: (ev) => {
1285
+ const visibleIds = visible.map((entry) => entry.id)
1286
+ if (ev.target.checked) {
1287
+ setSelected([...new Set([...selected, ...visibleIds])])
1288
+ } else {
1289
+ setSelected(selected.filter((id) => !visibleIds.includes(id)))
1290
+ }
1291
+ },
1292
+ }),
1293
+ t('batch.selectAll')),
1294
+ h('span', { className: 'im-meta' }, t('batch.selected', { count: liveSelected().length })),
1295
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => doBatch('set-enabled', true) }, t('batch.enable')),
1296
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => doBatch('set-enabled', false) }, t('batch.disable')),
1297
+ h('select', {
1298
+ className: 'im-select im-select-sm',
1299
+ defaultValue: '',
1300
+ disabled: busy,
1301
+ onChange: (ev) => {
1302
+ const v = Number(ev.target.value)
1303
+ if (v === 0 || v === 1 || v === 2) void doBatch('set-priority', v)
1304
+ ev.target.value = ''
1305
+ },
1306
+ },
1307
+ h('option', { value: '' }, t('batch.priority')),
1308
+ h('option', { value: '2' }, t('priority.high')),
1309
+ h('option', { value: '1' }, t('priority.normal')),
1310
+ h('option', { value: '0' }, t('priority.low'))),
1311
+ h('button', { className: 'im-btn', disabled: busy, onClick: batchAddTags }, t('batch.addTags')),
1312
+ h('span', { className: 'im-spacer' }),
1313
+ h('button', {
1314
+ className: 'im-btn im-btn-danger',
1315
+ disabled: busy,
1316
+ onClick: () => doBatch('delete', undefined),
1317
+ }, t('batch.delete')),
1318
+ h('button', { className: 'im-btn', disabled: busy, onClick: exitBatch }, t('batch.exit')))
1319
+ : null,
1320
+ // F2 filters: tag chips (only when tags exist) plus mode/status/sort.
1321
+ entries.length > 0
1322
+ ? h('div', { key: 'filters', className: 'im-filter-row' },
1323
+ allTags.length > 0
1324
+ ? h('div', { className: 'im-filter-chips' },
1325
+ h('button', {
1326
+ type: 'button',
1327
+ className: 'im-filter-chip' + (tagFilter === '' ? ' im-filter-chip-on' : ''),
1328
+ onClick: () => setTagFilter(''),
1329
+ }, t('filter.all')),
1330
+ allTags.map((tag) => h('button', {
1331
+ type: 'button',
1332
+ key: tag,
1333
+ className: 'im-filter-chip' + (tagFilter === tag ? ' im-filter-chip-on' : ''),
1334
+ title: t('filter.tag'),
1335
+ onClick: () => setTagFilter(tagFilter === tag ? '' : tag),
1336
+ }, tag)))
1337
+ : null,
1338
+ h('div', { className: 'im-filter-selects' },
1339
+ h('label', { className: 'im-fselect' }, t('filter.mode'),
1340
+ h('select', {
1341
+ className: 'im-select im-select-sm',
1342
+ value: modeFilter,
1343
+ onChange: (ev) => setModeFilter(ev.target.value),
1344
+ },
1345
+ h('option', { value: '' }, t('filter.all')),
1346
+ h('option', { value: 'always' }, t('filter.modeAlways')),
1347
+ h('option', { value: 'auto' }, t('filter.modeAuto')))),
1348
+ h('label', { className: 'im-fselect' }, t('filter.status'),
1349
+ h('select', {
1350
+ className: 'im-select im-select-sm',
1351
+ value: statusFilter,
1352
+ onChange: (ev) => setStatusFilter(ev.target.value),
1353
+ },
1354
+ h('option', { value: '' }, t('filter.all')),
1355
+ h('option', { value: 'enabled' }, t('filter.statusEnabled')),
1356
+ h('option', { value: 'disabled' }, t('filter.statusDisabled')))),
1357
+ h('label', { className: 'im-fselect' }, t('sort.label'),
1358
+ h('select', {
1359
+ className: 'im-select im-select-sm',
1360
+ value: sortKey,
1361
+ onChange: (ev) => setSortKey(ev.target.value),
1362
+ },
1363
+ h('option', { value: 'priority' }, t('sort.priority')),
1364
+ h('option', { value: 'updated' }, t('sort.updated')),
1365
+ h('option', { value: 'title' }, t('sort.title'))))))
1366
+ : null,
905
1367
  entries.length > 0 ? h('div', { key: 'search' },
906
1368
  h('input', {
907
1369
  className: 'im-input',
@@ -909,7 +1371,9 @@ window.__ModuleLoader__.load({
909
1371
  placeholder: t('search.placeholder', { count: entries.length }),
910
1372
  onChange: (ev) => setQuery(ev.target.value),
911
1373
  }),
912
- query.trim() !== '' ? h('div', { className: 'im-meta im-search-meta' }, t('search.matchCount', { shown: visible.length, total: entries.length })) : null) : null,
1374
+ (query.trim() !== '' || filtersActive)
1375
+ ? h('div', { className: 'im-meta im-search-meta' }, t('search.matchCount', { shown: visible.length, total: entries.length }))
1376
+ : null) : null,
913
1377
  h('div', { className: 'im-row-wrap' },
914
1378
  h('button', {
915
1379
  className: 'im-btn im-btn-primary',
@@ -931,6 +1395,11 @@ window.__ModuleLoader__.load({
931
1395
  disabled: busy,
932
1396
  onClick: () => setShowPreview(!showPreview),
933
1397
  }, showPreview ? t('action.hidePreview') : t('action.showPreview')),
1398
+ h('button', {
1399
+ className: 'im-btn',
1400
+ disabled: busy || entries.length === 0,
1401
+ onClick: () => { setBatchMode(!batchMode); setSelected([]) },
1402
+ }, batchMode ? t('batch.exit') : t('action.batch')),
934
1403
  h('span', { className: 'im-spacer' }),
935
1404
  h('span', { className: 'im-meta' }, t('stats.count', { total: entries.length, active: activeCount }))),
936
1405
  h('div', { className: 'im-divider' }),
@@ -987,51 +1456,81 @@ window.__ModuleLoader__.load({
987
1456
  ]
988
1457
  if (entry.priority === 2) tags.push(h('span', { className: 'im-tag im-tag-high', key: 'p' }, t('priority.high')))
989
1458
  else if (entry.priority === 0) tags.push(h('span', { className: 'im-tag', key: 'p' }, t('priority.low')))
1459
+ // Budget marks from the Host's injection plan. Only active entries can
1460
+ // appear in the plans, so a disabled entry never gets a stale mark.
1461
+ if (omittedById.has(entry.id)) {
1462
+ tags.push(h('span', {
1463
+ className: 'im-tag im-tag-off', key: 'off', title: t('tag.omittedTitle'),
1464
+ }, t('tag.omitted')))
1465
+ } else if (truncatedById.has(entry.id)) {
1466
+ tags.push(h('span', {
1467
+ className: 'im-tag im-tag-cut', key: 'cut', title: t('tag.truncatedTitle'),
1468
+ }, t('tag.truncated')))
1469
+ }
990
1470
  return h('div', {
991
1471
  className: 'im-item' + (entry.enabled ? '' : ' im-item-off'),
992
1472
  key: entry.id,
993
1473
  },
994
1474
  h('div', { className: 'im-item-head' },
1475
+ batchMode
1476
+ ? h('input', {
1477
+ type: 'checkbox',
1478
+ className: 'im-batch-check',
1479
+ checked: selected.includes(entry.id),
1480
+ disabled: busy,
1481
+ onChange: () => toggleSelected(entry.id),
1482
+ })
1483
+ : null,
995
1484
  h('span', { className: 'im-title' }, entry.title),
996
1485
  tags,
997
1486
  h('span', { className: 'im-spacer' }),
998
- h('label', { className: 'im-switch' },
999
- h('input', {
1000
- type: 'checkbox',
1001
- checked: entry.enabled === true,
1487
+ batchMode
1488
+ ? null
1489
+ : h('label', { className: 'im-switch' },
1490
+ h('input', {
1491
+ type: 'checkbox',
1492
+ checked: entry.enabled === true,
1493
+ disabled: busy,
1494
+ onChange: () => {
1495
+ const next = cloneEntry(entry)
1496
+ next.enabled = !entry.enabled
1497
+ send('save-entry', { entry: next, baseRev: revOf() })
1498
+ },
1499
+ }),
1500
+ t('entry.enabled')),
1501
+ batchMode
1502
+ ? null
1503
+ : h('button', {
1504
+ className: 'im-btn',
1505
+ disabled: busy,
1506
+ onClick: () => { setDraft(cloneEntry(entry)); setError('') },
1507
+ }, t('entry.edit')),
1508
+ batchMode
1509
+ ? null
1510
+ : h('button', {
1511
+ className: 'im-btn im-btn-danger',
1002
1512
  disabled: busy,
1003
- onChange: () => {
1004
- const next = cloneEntry(entry)
1005
- next.enabled = !entry.enabled
1006
- send('save-entry', { entry: next, baseRev: revOf() })
1513
+ onClick: () => {
1514
+ // confirm() is standard DOM, guarded anyway: if the embedded
1515
+ // shell omits it, deletion proceeds rather than breaking.
1516
+ if (typeof window.confirm === 'function'
1517
+ && !window.confirm(t('entry.confirmDelete', {
1518
+ title: entry.title !== '' ? entry.title : t('entry.untitled'),
1519
+ }))) return
1520
+ // Keep the full entry for the undo bar; the POST response
1521
+ // (not this closure) is what confirms the deletion happened.
1522
+ setUndo(null)
1523
+ send('delete-entry', { id: entry.id, baseRev: revOf() }).then((result) => {
1524
+ if (result === null || result.ok !== true) return
1525
+ setUndo({ entry, expires: Date.now() + UNDO_TTL_MS })
1526
+ })
1007
1527
  },
1008
- }),
1009
- t('entry.enabled')),
1010
- h('button', {
1011
- className: 'im-btn',
1012
- disabled: busy,
1013
- onClick: () => { setDraft(cloneEntry(entry)); setError('') },
1014
- }, t('entry.edit')),
1015
- h('button', {
1016
- className: 'im-btn im-btn-danger',
1017
- disabled: busy,
1018
- onClick: () => {
1019
- // confirm() is standard DOM, guarded anyway: if the embedded
1020
- // shell omits it, deletion proceeds rather than breaking.
1021
- if (typeof window.confirm === 'function'
1022
- && !window.confirm(t('entry.confirmDelete', {
1023
- title: entry.title !== '' ? entry.title : t('entry.untitled'),
1024
- }))) return
1025
- // Keep the full entry for the undo bar; the POST response
1026
- // (not this closure) is what confirms the deletion happened.
1027
- setUndo(null)
1028
- send('delete-entry', { id: entry.id, baseRev: revOf() }).then((result) => {
1029
- if (result === null || result.ok !== true) return
1030
- setUndo({ entry, expires: Date.now() + UNDO_TTL_MS })
1031
- })
1032
- },
1033
- }, t('entry.delete'))),
1528
+ }, t('entry.delete'))),
1034
1529
  h(EntryBody, { text: entry.content, key: 'body' }),
1530
+ Array.isArray(entry.tags) && entry.tags.length > 0
1531
+ ? h('div', { className: 'im-entry-tags' },
1532
+ entry.tags.map((tag) => h('span', { className: 'im-chip', key: tag }, tag)))
1533
+ : null,
1035
1534
  entry.mode === 'auto' && entry.when !== ''
1036
1535
  ? h('div', { className: 'im-meta' }, t('entry.when', { when: entry.when }))
1037
1536
  : null,
@@ -1047,7 +1546,10 @@ window.__ModuleLoader__.load({
1047
1546
  ? h('div', { className: 'im-empty', key: 'empty' },
1048
1547
  t('list.empty'))
1049
1548
  : visible.length === 0
1050
- ? h('div', { className: 'im-empty', key: 'no-match' }, t('list.noMatch', { query: query.trim() }))
1549
+ ? h('div', { className: 'im-empty', key: 'no-match' },
1550
+ keyword === ''
1551
+ ? t('list.filteredEmpty')
1552
+ : t('list.noMatch', { query: query.trim() }))
1051
1553
  : h('div', { className: 'im-list', key: 'list' }, items)
1052
1554
 
1053
1555
  return h('div', { className: 'im-root' }, header, list)
@@ -1136,6 +1638,8 @@ window.__ModuleLoader__.load({
1136
1638
  exports.__confirmDiscard = confirmDiscard
1137
1639
  exports.__importSizeVerdict = importSizeVerdict
1138
1640
  exports.__nextBudgetText = nextBudgetText
1641
+ exports.__budgetMeter = budgetMeter
1642
+ exports.__parseTagInput = parseTagInput
1139
1643
  exports.__strings = STRINGS
1140
1644
  exports.__ns = NS
1141
1645
  exports.__dictionaryT = dictionaryT
package/lib/index.js CHANGED
@@ -92,6 +92,15 @@ 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
+ * The batch operations the `batch` route accepts. Kept here so the command and
101
+ * the route's dispatch share one list.
102
+ */
103
+ const BATCH_OPS = ['set-enabled', 'set-priority', 'add-tags', 'remove-tags', 'delete']
95
104
 
96
105
  /**
97
106
  * Request body ceiling for the settings-page route; anything larger is refused
@@ -106,7 +115,7 @@ const MAX_WHEN = 200
106
115
  * the flat slack covers the `{method,args:{payload:{app,…,data:{entries:[…]}}}}`
107
116
  * wrapper.
108
117
  */
109
- export const MAX_BODY_CHARS = MAX_ENTRIES * (MAX_CONTENT + MAX_TITLE + MAX_WHEN + 200) * 2 + 100_000
118
+ export const MAX_BODY_CHARS = MAX_ENTRIES * (MAX_CONTENT + MAX_TITLE + MAX_WHEN + MAX_TAGS_PER_ENTRY * MAX_TAG_LENGTH + 200) * 2 + 100_000
110
119
 
111
120
  /** How long a crashed holder's `memory.json.lock` may block writes before it is stolen. */
112
121
  const LOCK_STALE_MS = 10_000
@@ -158,6 +167,34 @@ function priorityLabel(value) {
158
167
  return '普通'
159
168
  }
160
169
 
170
+ /**
171
+ * Normalise an entry's `tags`: a de-duplicated, single-line, capped string
172
+ * array used ONLY by the settings page for grouping/filtering. Tags never
173
+ * appear in the injected block, so they do not touch buildBlock. Exported for
174
+ * tests and reused by the batch "add-tags" path so both merge through one
175
+ * rule. Non-array input (an old/hand-made entry without tags) is simply no
176
+ * tags, not an error; non-string/blank items are dropped; anything past
177
+ * MAX_TAGS_PER_ENTRY is ignored after de-duplication.
178
+ */
179
+ export function sanitizeTags(raw) {
180
+ if (!Array.isArray(raw)) return []
181
+ const out = []
182
+ const seen = new Set()
183
+ for (const item of raw) {
184
+ if (typeof item !== 'string') continue
185
+ // Collapse ALL whitespace runs (newlines and repeated spaces) so the same
186
+ // typed label normalises identically whether it came from the editor's
187
+ // tag box, an import, or a hand edit — otherwise "a b" and "a b" would
188
+ // both survive and fail to de-duplicate.
189
+ const tag = singleLine(item, MAX_TAG_LENGTH).replace(/\s+/g, ' ').trim()
190
+ if (tag === '' || seen.has(tag)) continue
191
+ seen.add(tag)
192
+ if (out.length >= MAX_TAGS_PER_ENTRY) break
193
+ out.push(tag)
194
+ }
195
+ return out
196
+ }
197
+
161
198
  export function sanitizeEntry(raw) {
162
199
  if (raw === null || typeof raw !== 'object') return null
163
200
  const title = singleLine(raw.title, MAX_TITLE)
@@ -171,6 +208,7 @@ export function sanitizeEntry(raw) {
171
208
  when: singleLine(raw.when, MAX_WHEN),
172
209
  priority: raw.priority === 2 || raw.priority === 0 ? raw.priority : 1,
173
210
  enabled: raw.enabled !== false,
211
+ tags: sanitizeTags(raw.tags),
174
212
  updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
175
213
  }
176
214
  }
@@ -518,19 +556,41 @@ function formatEntry(entry, tag) {
518
556
  }
519
557
 
520
558
  /**
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.
559
+ * Build the injection plan: the exact text assembled into the system prompt
560
+ * PLUS the per-entry decisions behind it, so the settings page can show how
561
+ * much of the character budget is in use and flag the enabled entries the
562
+ * current budget leaves out.
563
+ *
564
+ * Deliberately deterministic: identical memory produces a byte-identical
565
+ * plan on every step, so the prompt prefix stays cacheable across a session.
566
+ *
567
+ * Returned shape:
568
+ * {
569
+ * text: string — exactly what buildBlock() returns
570
+ * budget: number — the clamped budgetChars in effect
571
+ * activeCount: number — enabled, non-blank candidates considered
572
+ * included: [{ id, chars, truncated }] — entries that reach the prompt,
573
+ * in consideration order (始终 first, then 按需; within
574
+ * each, priority desc then updatedAt desc); chars is the
575
+ * rendered length of that entry's block (its cut length
576
+ * when truncated)
577
+ * omitted: [{ id, chars }] — active entries the budget could not fit;
578
+ * chars is the full rendered block length they would need
579
+ * }
580
+ *
581
+ * Disabled or blank entries are candidates for neither list: they are not
582
+ * competing for the budget at all. When nothing renders, `text` is '' and
583
+ * `included` is [] even though `budget` still reports the effective ceiling.
524
584
  */
525
- export function buildBlock(data) {
526
- if (data === null || typeof data !== 'object' || data.enabled !== true) return ''
585
+ export function planInjection(data) {
586
+ const budget = clampBudget(data !== null && typeof data === 'object' ? data.budgetChars : undefined)
587
+ const blank = () => ({ text: '', budget, activeCount: 0, included: [], omitted: [] })
588
+ if (data === null || typeof data !== 'object' || data.enabled !== true) return blank()
527
589
  const active = (Array.isArray(data.entries) ? data.entries : [])
528
590
  .filter((entry) => entry.enabled === true && entry.content !== '')
529
- if (active.length === 0) return ''
591
+ if (active.length === 0) return blank()
530
592
  active.sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
531
593
 
532
- const budget = clampBudget(data.budgetChars)
533
-
534
594
  // Richest preamble that still leaves room for one real entry plus the
535
595
  // worst-case omission footer. A tight budget degrades the preamble instead of
536
596
  // silently injecting nothing.
@@ -565,6 +625,8 @@ export function buildBlock(data) {
565
625
  }
566
626
 
567
627
  const blocks = []
628
+ const included = []
629
+ const omittedPlan = []
568
630
  let used = header.length
569
631
  let omitted = 0
570
632
 
@@ -576,6 +638,7 @@ export function buildBlock(data) {
576
638
  if (used + block.length + 2 + reserve <= budget) {
577
639
  used += block.length + 2
578
640
  blocks.push(block)
641
+ included.push({ id: entry.id, chars: block.length, truncated: false })
579
642
  return
580
643
  }
581
644
  // The first entry always gets in, truncated if the budget demands it: a
@@ -593,17 +656,36 @@ export function buildBlock(data) {
593
656
  const body = head + note
594
657
  blocks.push(body)
595
658
  used += body.length + 2
659
+ included.push({ id: entry.id, chars: body.length, truncated: true })
596
660
  return
597
661
  }
598
662
  }
599
663
  omitted += 1
664
+ omittedPlan.push({ id: entry.id, chars: block.length })
600
665
  }
601
666
 
602
667
  for (const entry of active) if (entry.mode === 'always') consider(entry, '始终')
603
668
  for (const entry of active) if (entry.mode !== 'always') consider(entry, '按需')
604
669
 
605
- if (blocks.length === 0) return ''
606
- return header + blocks.join('\n\n') + footerFor(omitted)
670
+ if (blocks.length === 0) {
671
+ return { text: '', budget, activeCount: active.length, included: [], omitted: omittedPlan }
672
+ }
673
+ return {
674
+ text: header + blocks.join('\n\n') + footerFor(omitted),
675
+ budget,
676
+ activeCount: active.length,
677
+ included,
678
+ omitted: omittedPlan,
679
+ }
680
+ }
681
+
682
+ /**
683
+ * Render the injected block text. A thin wrapper over planInjection: the
684
+ * decision process lives in one place, and callers that only need the text
685
+ * (the prompt section) keep this byte-stable entry point.
686
+ */
687
+ export function buildBlock(data) {
688
+ return planInjection(data).text
607
689
  }
608
690
 
609
691
  /* ------------------------------------------------------------------ *
@@ -725,6 +807,22 @@ export function apply(ctx) {
725
807
  }
726
808
  }
727
809
 
810
+ /**
811
+ * The full injection plan (text + per-entry budget decisions) behind the
812
+ * registered section. The snapshot publishes it so the settings page can
813
+ * show budget usage and mark the enabled entries that currently do not fit.
814
+ * A render failure degrades to an empty plan at the effective ceiling
815
+ * rather than throwing into the route/SSE path.
816
+ */
817
+ const sectionPlan = () => {
818
+ try {
819
+ return planInjection(state.data)
820
+ } catch (err) {
821
+ console.error('[instruction-memory] render failed:', err)
822
+ return { text: '', budget: clampBudget(state.data.budgetChars), activeCount: 0, included: [], omitted: [] }
823
+ }
824
+ }
825
+
728
826
  /**
729
827
  * The thunk handed to the harness.
730
828
  *
@@ -1287,7 +1385,8 @@ export function apply(ctx) {
1287
1385
  /* ---------------- snapshots ---------------- */
1288
1386
 
1289
1387
  const snapshot = () => {
1290
- const preview = sectionText()
1388
+ const plan = sectionPlan()
1389
+ const preview = plan.text
1291
1390
  return {
1292
1391
  data: {
1293
1392
  version: 1,
@@ -1310,6 +1409,16 @@ export function apply(ctx) {
1310
1409
  available: true,
1311
1410
  error: state.sectionError,
1312
1411
  chars: preview.length,
1412
+ /**
1413
+ * The budget decision behind `preview`, for the settings page's
1414
+ * usage meter and per-entry marks. `budget` is the effective ceiling;
1415
+ * `included`/`omitted` carry one item per active entry in
1416
+ * consideration order; disabled or blank entries appear in neither.
1417
+ */
1418
+ budget: plan.budget,
1419
+ activeCount: plan.activeCount,
1420
+ included: plan.included.map((item) => ({ id: item.id, chars: item.chars, truncated: item.truncated === true })),
1421
+ omitted: plan.omitted.map((item) => ({ id: item.id, chars: item.chars })),
1313
1422
  /**
1314
1423
  * How many times the harness has pulled this section into a prompt since
1315
1424
  * boot. 0 means "prepared, never assembled" — the distinction the old
@@ -1468,6 +1577,126 @@ export function apply(ctx) {
1468
1577
  return settle(null, saved)
1469
1578
  })
1470
1579
 
1580
+ /**
1581
+ * Apply ONE operation to many entries in a single transaction.
1582
+ *
1583
+ * The settings page's multi-select must not loop save-entry/delete-entry:
1584
+ * that would bump `rev`, write the file and broadcast an SSE frame once per
1585
+ * id. Instead the whole batch is planned first (only entries that actually
1586
+ * change are touched — an all-no-op batch, like deleting ids that are not
1587
+ * there, writes nothing and keeps `rev` still), then committed through the
1588
+ * same commit()/writeToDisk() path as every other mutation, so the lock, rev
1589
+ * guard, rollback-on-failed-write and broadcast all apply once.
1590
+ *
1591
+ * `value` semantics per op: set-enabled -> boolean; set-priority -> 0|1|2;
1592
+ * add-tags/remove-tags -> string[] (run through sanitizeTags); delete ->
1593
+ * unused. Batch edits deliberately do NOT re-stamp updatedAt: enabling a
1594
+ * group or tagging it must not reshuffle the within-priority injection
1595
+ * order, which is tied to updatedAt.
1596
+ */
1597
+ const batchEntries = (op, ids, value, baseRev) => enqueue(async () => {
1598
+ if (state.versionRejected) return versionRefusal()
1599
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
1600
+ if (typeof op !== 'string' || !BATCH_OPS.includes(op)) {
1601
+ return { ok: false, saved: false, message: '未知的批量操作:' + String(op), snapshot: snapshot() }
1602
+ }
1603
+ const uniqueIds = Array.isArray(ids)
1604
+ ? [...new Set(ids.filter((id) => typeof id === 'string' && id !== ''))]
1605
+ : []
1606
+ if (uniqueIds.length === 0) {
1607
+ return { ok: false, saved: false, message: '没有选择任何条目', snapshot: snapshot() }
1608
+ }
1609
+
1610
+ const list = state.data.entries
1611
+ const targets = list.filter((entry) => uniqueIds.includes(entry.id))
1612
+ const missing = uniqueIds.length - targets.length
1613
+ const missingNote = missing > 0
1614
+ ? '(另有 ' + missing + ' 条未找到,可能已被其他窗口或进程修改)'
1615
+ : ''
1616
+
1617
+ if (op === 'delete') {
1618
+ // Deleting ids that are all absent changes nothing — mirror single
1619
+ // deleteEntry's no-write behaviour rather than manufacturing a save.
1620
+ if (targets.length === 0) {
1621
+ return { ok: true, saved: false, message: null, snapshot: snapshot() }
1622
+ }
1623
+ const gone = new Set(targets.map((entry) => entry.id))
1624
+ const affected = targets.length
1625
+ const saved = await commit(() => {
1626
+ state.data.entries = state.data.entries.filter((entry) => !gone.has(entry.id))
1627
+ })
1628
+ return settle('已删除 ' + affected + ' 条指令' + missingNote, saved)
1629
+ }
1630
+
1631
+ // Pure planners: return the patch to apply, or null when this entry is
1632
+ // already at the target state. Nothing is mutated here.
1633
+ let planner
1634
+ if (op === 'set-enabled') {
1635
+ if (typeof value !== 'boolean') {
1636
+ return { ok: false, saved: false, message: '批量启停需要一个布尔值', snapshot: snapshot() }
1637
+ }
1638
+ planner = (entry) => (entry.enabled === value ? null : { enabled: value })
1639
+ } else if (op === 'set-priority') {
1640
+ if (value !== 0 && value !== 1 && value !== 2) {
1641
+ return { ok: false, saved: false, message: '批量优先级只能是 0、1 或 2', snapshot: snapshot() }
1642
+ }
1643
+ planner = (entry) => (entry.priority === value ? null : { priority: value })
1644
+ } else if (op === 'add-tags') {
1645
+ if (!Array.isArray(value)) {
1646
+ return { ok: false, saved: false, message: '批量添加标签需要一个标签数组', snapshot: snapshot() }
1647
+ }
1648
+ const add = sanitizeTags(value)
1649
+ if (add.length === 0) {
1650
+ return { ok: false, saved: false, message: '没有可添加的有效标签(单个标签不超过 ' + MAX_TAG_LENGTH + ' 字符)', snapshot: snapshot() }
1651
+ }
1652
+ planner = (entry) => {
1653
+ const cur = Array.isArray(entry.tags) ? entry.tags : []
1654
+ const merged = sanitizeTags([...cur, ...add])
1655
+ if (merged.length === cur.length && merged.every((tag) => cur.includes(tag))) return null
1656
+ return { tags: merged }
1657
+ }
1658
+ } else {
1659
+ // remove-tags
1660
+ if (!Array.isArray(value)) {
1661
+ return { ok: false, saved: false, message: '批量移除标签需要一个标签数组', snapshot: snapshot() }
1662
+ }
1663
+ const remove = new Set(sanitizeTags(value))
1664
+ if (remove.size === 0) {
1665
+ return { ok: false, saved: false, message: '没有可移除的有效标签', snapshot: snapshot() }
1666
+ }
1667
+ planner = (entry) => {
1668
+ const cur = Array.isArray(entry.tags) ? entry.tags : []
1669
+ const kept = cur.filter((tag) => !remove.has(tag))
1670
+ return kept.length === cur.length ? null : { tags: kept }
1671
+ }
1672
+ }
1673
+
1674
+ const changes = []
1675
+ for (const entry of targets) {
1676
+ const patch = planner(entry)
1677
+ if (patch !== null) changes.push({ entry, patch })
1678
+ }
1679
+ if (changes.length === 0) {
1680
+ return { ok: true, saved: false, message: '所选条目均已是目标状态' + missingNote, snapshot: snapshot() }
1681
+ }
1682
+ const saved = await commit(() => {
1683
+ for (const change of changes) Object.assign(change.entry, change.patch)
1684
+ })
1685
+ const done = {
1686
+ 'set-enabled': value ? '已启用 ' : '已停用 ',
1687
+ 'set-priority': '已将 ',
1688
+ 'add-tags': '已为 ',
1689
+ 'remove-tags': '已从 ',
1690
+ }[op]
1691
+ const tail = {
1692
+ 'set-enabled': changes.length + ' 条指令',
1693
+ 'set-priority': changes.length + ' 条指令的优先级设为「' + priorityLabel(value) + '」',
1694
+ 'add-tags': changes.length + ' 条指令添加标签',
1695
+ 'remove-tags': changes.length + ' 条指令移除标签',
1696
+ }[op]
1697
+ return settle(done + tail + missingNote, saved)
1698
+ })
1699
+
1471
1700
  const reload = () => enqueue(async () => {
1472
1701
  // `saved` is only true when the load itself wrote to disk (the
1473
1702
  // backup-promotion or legacy-migration branch); a plain re-read claiming
@@ -1590,6 +1819,12 @@ export function apply(ctx) {
1590
1819
  return importData(
1591
1820
  args !== null && typeof args === 'object' ? args.payload : null,
1592
1821
  args !== null && typeof args === 'object' ? args.baseRev : undefined)
1822
+ case 'batch':
1823
+ return batchEntries(
1824
+ args !== null && typeof args === 'object' ? args.op : null,
1825
+ args !== null && typeof args === 'object' ? args.ids : null,
1826
+ args !== null && typeof args === 'object' ? args.value : undefined,
1827
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
1593
1828
  default:
1594
1829
  return { ok: false, saved: false, message: '未知方法:' + String(method), snapshot: snapshot() }
1595
1830
  }
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.18",
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",