dsh-instruction-memory 1.2.0 → 1.3.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,67 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.3.0] — 2026-10-15
11
+
12
+ 两个审查修复与两项体验增强(F12 / F7)。
13
+
14
+ ### 修复
15
+
16
+ - **切换配置集时关闭打开中的编辑草稿**:切换成功后同时关闭指令编辑器与用户形象编辑器
17
+ (并退出批量选择模式)。此前留在屏幕上的表单仍显示旧集合的内容,保存时会落进新集合
18
+ (服务端跨集合保护保证不覆盖、不丢数据,但表单本身就是误导)。若切换时确有草稿被关闭,
19
+ 会在提示里说明。ui-test 新增回归:打开形象编辑器 → 切换集合 → 断言编辑器消失且有披露;
20
+ 并钉住「条目编辑器为整页接管,打开时切换按钮不可达」这一结构事实。
21
+ - **导入回退到激活集合时按目标集合重新去重**:导入条目声明的集合不存在(或已达 20 个上限
22
+ 无法创建)时会归入当前激活集合,但去重仍按**声明的集合**计算,可能把与激活集合已有条目
23
+ title+content 完全相同的内容再插一份。现在在提交阶段先确定每项的真实归属,再对回退项
24
+ 按目标集合补一次查重(含同批其他回退项与正常落集项之间的互查):重复的不再插入并单独
25
+ 计数披露(「另有 N 项因回退到当前配置集后与已有内容重复被跳过」);全部被跳过时保持
26
+ no-op 不写盘。回退目标中 id 已被占用而正文是新内容时改发新 id,不再把重复 id 写进同一
27
+ 集合。正常路径(集合存在/成功创建)的幂等性不变——重新导入同一导出文件仍是 no-op。
28
+
29
+ ### 新增
30
+
31
+ - **诊断与安全状态集中面板(F12)**:设置页新增可折叠「诊断信息」卡片(默认收起、纯展示、
32
+ 无写路由),集中展示存储(路径/在盘/载入来源/最近保存/rev)、注入(注册状态、`pulls`
33
+ 自启动计数、字符数/上限)、备份与历史(位置、`.bak`、历史目录与快照数、Schema 文件)与
34
+ 全部错误/警告;「复制诊断信息」生成含 `navigator.userAgent` 的纯文本(**不含任何用户文本**,
35
+ 剪贴板不可用时降级为手动全选文本框;序列化为导出纯函数 `__diagnosticsText` 并被
36
+ `verify.mjs` 锁定)。快照新增 `security`(`hostGuarded`/`boundHost`,与路由 Host 校验同源)与
37
+ `diagnostics`(`loadedFrom`/`rev`):绑定在非回环地址时页面顶部显示醒目橙色安全警告,
38
+ 补上对外发布场景缺失的信号。`docs/demo.html` 桩同步补齐。
39
+ - **临时静音(F7)**:总开关旁「暂停注入…」——30 分钟 / 1 小时 / 到今天结束(本地时区
40
+ 24:00),暂停期间指令与形象都不注入(系统提示词与未安装插件逐字节一致),到期由宿主侧
41
+ 定时器自动恢复,**无需操作、不写盘(rev 不变)**;状态存于新增的**可选**顶层字段
42
+ `disabledUntil`(epoch 毫秒;0/缺失 = 未暂停;格式版本仍为 2,无迁移;跨重启保留)。
43
+ `set-options` 接受该字段:0 = 立即恢复,过去时间视为 0,超过 7 天上限钳到 now+7d 并披露;
44
+ 手动切换总开关清零它(显式开关优先于定时)。注入渲染引入**可注入时钟**:`isMuted(data, now)`、
45
+ `planInjection(data, now = Date.now())`、`buildBlock(data, now = Date.now())`(默认参数仍是
46
+ 当前时间,纯函数允许固定 now,字节结果可复现);到期决策 `muteExpired(data, now)` 由
47
+ `syncSection` 末尾的单一定时器消费(每次状态变化先清旧定时器,插件卸载时清理,`unref`
48
+ 不阻退出)。客户端提供暂停菜单、剩余时间倒计时(1s interval,随暂停状态自动建立/清理)
49
+ 与「立即恢复」;`memory.schema.json`、README、demo 桩同步更新。
50
+
51
+ ### 测试
52
+
53
+ - contract-test:导入回退去重六组回归(激活集合重复 / 20 集上限 / 批内互查 / 形象重复 /
54
+ id 重发 / 跨集同文不误伤);先红后绿验证(回退修复后新用例失败)。
55
+ - ui-test:切换集合关闭草稿回归;同以 stash 验证测试有效性。
56
+ - smoke-test:isMuted 真值表与 muteExpired 到期决策;planInjection/buildBlock 注入时钟在
57
+ 暂停前/后的字节一致性(暂停中 ≡ enabled:false;到期后 ≡ 未暂停);形象随暂停一起置空;
58
+ 时钟参数可省(arity = 1)。
59
+ - contract-test(F7/F12):set-options disabledUntil 写入/披露/过去归零/7 天钳制/随总开关
60
+ 清零/非法拒绝;暂停中快照 injection.muted=true、registered=false、preview 为空;存储文件/
61
+ 导出/restore 往返/源码模式携带该字段;手改非法值载入时重置并披露;F12 security/diagnostics
62
+ 字段存在且 hostGuarded 随绑定地址变化(回环 vs 0.0.0.0)。
63
+ - verify.mjs:__diagnosticsText 输出含关键字段、含环境信息、不含条目/形象/预演文本;
64
+ schema 记录 disabledUntil(integer ≥ 0);F12/F7 双语键组检查。
65
+ - ui-test(F12/F7):诊断卡片展开/收起与复制降级路径(无剪贴板 → 可手动全选的转储,断言
66
+ 不含用户文本);非回环绑定警告出现/消失(第二宿主实例真实切换绑定);暂停菜单三档 →
67
+ 状态条倒计时 → 立即恢复;50ms 真实定时器到期自动恢复(文件字节不变的到期语义)。
68
+ ui-test 垫片补上 React 的 effect cleanup 语义(重跑前先清理),倒计时 interval 不再泄漏到
69
+ 后续断言。
70
+
10
71
  ## [1.2.0] — 2026-10-15
11
72
 
12
73
  P2 进阶增强:多情境配置集、版本历史与回滚、手改友好化(JSON Schema)、源码模式、
package/README.md CHANGED
@@ -80,6 +80,17 @@ dsh plugin --profile web remove dsh-instruction-memory
80
80
  总开关关闭时,该提示词段落(指令与用户形象)**整个注销**,系统提示词与未安装本插件时逐字节一致;
81
81
  总开关开启但没有任何生效指令、也没有启用形象时,段落同样不出现。
82
82
 
83
+ ### 临时静音(暂停注入一段时间,F7)
84
+
85
+ 总开关旁提供「暂停注入…」:**30 分钟 / 1 小时 / 到今天结束**(本地时区 24:00)。暂停期间
86
+ 指令与用户形象**都不注入**,系统提示词与「未安装本插件」**逐字节一致**;到期由插件内的
87
+ 宿主侧定时器**自动恢复**——无需用户操作,也不产生写盘(存储文件与修订号不变)。暂停
88
+ 状态保存在存储文件的 `disabledUntil` 字段里(仍是格式版本 2 的可选字段,缺失即未暂停),
89
+ 因此**跨重启保留**。手动切换总开关会立即清除暂停(显式开关优先于定时)。设置页在暂停中
90
+ 显示剩余时间倒计时,并可随时「立即恢复」;倒计时只是展示,权威判定始终在宿主快照
91
+ (`injection.muted`)里。到期判定与注入渲染都是**可注入时钟**的纯函数(`isMuted`、
92
+ `planInjection(data, now)`),相同输入永远得到相同字节。
93
+
83
94
  ## 存储
84
95
 
85
96
  记忆保存在 **DSH 数据根目录**下(设置页会显示完整绝对路径):
@@ -350,6 +361,26 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。
350
361
  **逐字节相同**(`verify.mjs` 有断言锁定这一点)。`debug` 级别由宿主日志级别过滤;
351
362
  没有 logger 服务的环境下需要 `DSH_INSTRUCTION_MEMORY_DEBUG=1` 才会输出到标准错误。
352
363
 
364
+ ### 诊断与安全状态面板(F12)
365
+
366
+ 设置页的「诊断信息」卡片把散在快照里的排障信息集中展示(默认收起,纯展示、无写路由):
367
+
368
+ - **存储**:路径、是否已在磁盘、载入来源(`loadedFrom`:首次创建 / 采用现有文件 / `.bak`
369
+ 恢复 / 旧版迁移等)、最近保存时间、修订号(rev);
370
+ - **注入**:是否已注册、`pulls` 拉取计数(**自本次插件启动**,含义见上文)、当前字符数 / 上限;
371
+ - **备份与历史**:备份位置、是否默认、`.bak` 覆盖副本路径、版本历史目录与快照数、Schema 文件位置;
372
+ - **错误与警告**:存储错误 / 存储警告 / 历史错误 / 注入错误原样显示。
373
+
374
+ 「复制诊断信息」把以上内容序列化成纯文本(附 `navigator.userAgent`、语言与当前时间,
375
+ 便于提 issue)。它**绝不包含条目正文、形象内容等用户文本**;剪贴板 API 不可用时降级为
376
+ `execCommand` 复制,再不可用则把文本放进可手动全选的文本框(序列化逻辑是导出的纯函数
377
+ `__diagnosticsText`,`verify.mjs` 断言其输出含关键字段且不含任何用户文本)。
378
+
379
+ 卡片同时补上了此前缺失的唯一安全信号:**`security.hostGuarded`**。设置路由绑定在**非回环
380
+ 地址**(主动对外发布)时,页面顶部会出现醒目橙色警告:同一网络内的设备可无鉴权访问此页面、
381
+ 请勿在不可信网络暴露,并指向下节「经反向代理对外发布」。该判定与路由实际执行的 Host 校验
382
+ 是**同一个开关**;快照同时携带 `boundHost`(路由注册时捕获的绑定地址),警告会点名具体地址。
383
+
353
384
  ## 请求来源防护(Host / Origin)
354
385
 
355
386
  设置页路由之所以要求 `content-type: application/json`,是因为 HTML 表单无法伪造 JSON body,
@@ -579,6 +610,7 @@ Package → Settings → Trusted publishing → Add trusted publisher
579
610
  "version": 2,
580
611
  "rev": 3,
581
612
  "enabled": true,
613
+ "disabledUntil": 0,
582
614
  "budgetChars": 4000,
583
615
  "backupDir": "",
584
616
  "activeSetId": "st-default",
@@ -626,6 +658,11 @@ Package → Settings → Trusted publishing → Add trusted publisher
626
658
  `backupDir` 为**本机**的自定义备份位置(留空 = 与存储文件同目录,最多 512 字符);它不是
627
659
  可移植内容,从备份恢复时不会被改写。
628
660
 
661
+ `disabledUntil` 为**临时静音**(F7)的到期时刻(epoch 毫秒):`0` 或缺失 = 未暂停;未来值 =
662
+ 暂停中,到期由插件自动恢复(无需操作、不写盘、rev 不变),跨重启保留。手动切换总开关会
663
+ 清零它(显式开关优先于定时)。这是格式版本 2 的**可选**字段:旧文件缺失即未暂停,不触发
664
+ 迁移;手改成非法值(非数字/负数)会被重置为 0 并在设置页披露。
665
+
629
666
  `profiles` 为可选的**用户形象**数组(缺失按空数组处理):`name` 单行不超过 40 字符,
630
667
  `content` 不超过 4000 字符,最多 50 个;`enabled` 控制是否使用该形象。名称与内容同时为空的形象会在
631
668
  载入时剔除并明确披露;名称为空时自动取内容前 20 字符。
package/lib/client.js CHANGED
@@ -153,6 +153,26 @@ window.__ModuleLoader__.load({
153
153
  .im-backup { display:flex; flex-direction:column; gap:8px; }
154
154
  .im-backup-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
155
155
  .im-backup-row .im-input { flex:1; min-width:200px; }
156
+ /* ---- F12 诊断与安全状态 ---- */
157
+ .im-hostwarn { display:flex; gap:8px; align-items:flex-start; padding:9px 12px; border-radius:8px; font-size:12px;
158
+ color:var(--dsw-alias-state-warn-primary,#d97706); border:1px solid var(--dsw-alias-state-warn-primary,#d97706);
159
+ background:var(--dsw-alias-state-warn-bg,rgba(217,119,6,.12)); font-weight:500; }
160
+ .im-diag { display:flex; flex-direction:column; gap:8px; }
161
+ .im-diag-group { display:flex; flex-direction:column; gap:3px; padding:8px 10px; border-radius:8px;
162
+ border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); background:var(--dsw-alias-bg-base,#fff); }
163
+ .im-diag-sub { color:var(--dsw-alias-label-secondary,#8a9099); font-size:11.5px; }
164
+ .im-diag-fallback { display:flex; flex-direction:column; gap:5px; }
165
+ .im-diag-fallback .im-textarea { min-height:170px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11px; }
166
+ /* ---- F7 临时静音 ---- */
167
+ .im-master-col { display:flex; flex-direction:column; align-items:flex-end; gap:6px; }
168
+ .im-pause-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; justify-content:flex-end; }
169
+ .im-pause-menu { display:flex; flex-direction:column; gap:4px; padding:8px 10px; border-radius:8px;
170
+ border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.18)); background:var(--dsw-alias-bg-base,#fff);
171
+ box-shadow:0 4px 14px rgba(0,0,0,.1); }
172
+ .im-pause-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; justify-content:flex-end;
173
+ padding:6px 10px; border-radius:8px; font-size:12px;
174
+ border:1px solid var(--dsw-alias-state-warn-primary,#d97706);
175
+ background:var(--dsw-alias-state-warn-bg,rgba(217,119,6,.12)); color:var(--dsw-alias-state-warn-primary,#d97706); }
156
176
  @media (max-width:640px) { .im-grid2 { grid-template-columns:1fr; } }
157
177
  `
158
178
 
@@ -315,6 +335,50 @@ window.__ModuleLoader__.load({
315
335
  'sets.copySuffix': ' 副本',
316
336
  'sets.deleteConfirm': '确定删除配置集「{name}」?其中的 {entries} 条指令与 {profiles} 个用户形象会一并删除,且无法撤销。',
317
337
  'sets.deleteActive': '当前激活的配置集不能删除;请先切换到其他配置集。',
338
+ 'sets.draftsClosed': '已关闭切换前打开的编辑草稿',
339
+ // ---- F12 诊断与安全状态 ----
340
+ 'diag.section': '诊断信息',
341
+ 'diag.show': '展开诊断',
342
+ 'diag.hide': '收起诊断',
343
+ 'diag.copy': '复制诊断信息',
344
+ 'diag.copied': '诊断信息已复制到剪贴板',
345
+ 'diag.copyManual': '自动复制不可用;请手动全选复制下方文本。',
346
+ 'diag.hostWarn': '安全提示:设置路由当前绑定在非回环地址({host}),同一网络内的设备可以无鉴权访问此页面,请勿在不可信网络暴露;如需对外发布,请阅读 README 的「经反向代理对外发布」。',
347
+ 'diag.groupStorage': '存储',
348
+ 'diag.groupInjection': '注入',
349
+ 'diag.groupBackup': '备份与历史',
350
+ 'diag.groupIssues': '错误与警告',
351
+ 'diag.none': '(无)',
352
+ 'diag.storagePath': '存储文件',
353
+ 'diag.onDisk': '存储文件已在磁盘',
354
+ 'diag.loadedFrom': '载入来源',
355
+ 'diag.savedAt': '最近保存',
356
+ 'diag.rev': '修订号(rev)',
357
+ 'diag.registered': '注入已注册',
358
+ 'diag.yes': '是',
359
+ 'diag.no': '否',
360
+ 'diag.pulls': '注入拉取次数(自本次插件启动计数)',
361
+ 'diag.chars': '当前注入字符数 / 上限',
362
+ 'diag.backupDir': '备份位置',
363
+ 'diag.backupDefault': '默认(与存储文件同目录)',
364
+ 'diag.backupPath': '覆盖副本(.bak)',
365
+ 'diag.historyDir': '版本历史目录',
366
+ 'diag.historyCount': '历史快照数',
367
+ 'diag.schemaFile': 'Schema 文件',
368
+ 'diag.issueStorage': '存储错误',
369
+ 'diag.issueWarning': '存储警告',
370
+ 'diag.issueHistory': '历史错误',
371
+ 'diag.issueSection': '注入错误',
372
+ // ---- F7 临时静音 ----
373
+ 'mute.menu': '暂停注入…',
374
+ 'mute.30m': '30 分钟',
375
+ 'mute.1h': '1 小时',
376
+ 'mute.today': '到今天结束',
377
+ 'mute.resume': '立即恢复',
378
+ 'mute.active': '注入已暂停,剩余 {remaining}',
379
+ 'mute.hint': '暂停期间不注入任何指令或用户形象,到期后自动恢复;此状态跨重启保留。',
380
+ 'mute.remainingMs': '{m} 分 {s} 秒',
381
+ 'mute.remainingH': '{h} 小时 {m} 分',
318
382
  // ---- F14 版本历史 ----
319
383
  'history.section': '版本历史',
320
384
  'history.subtitle': '每次保存都会留一份带时间戳的快照(每台设备最多保留 {max} 份,逐出最旧的)。可在此查看并一键回滚。',
@@ -522,6 +586,50 @@ window.__ModuleLoader__.load({
522
586
  'sets.copySuffix': ' copy',
523
587
  'sets.deleteConfirm': 'Delete the set “{name}”? Its {entries} instructions and {profiles} personas are deleted with it. This cannot be undone.',
524
588
  'sets.deleteActive': 'The active set cannot be deleted; switch to another set first.',
589
+ 'sets.draftsClosed': 'The editor draft open before the switch was closed',
590
+ // ---- F12 diagnostics & security panel ----
591
+ 'diag.section': 'Diagnostics',
592
+ 'diag.show': 'Show diagnostics',
593
+ 'diag.hide': 'Hide diagnostics',
594
+ 'diag.copy': 'Copy diagnostics',
595
+ 'diag.copied': 'Diagnostics copied to the clipboard',
596
+ 'diag.copyManual': 'Automatic copy is unavailable; select and copy the text below manually.',
597
+ 'diag.hostWarn': 'Security notice: the settings route is bound to a non-loopback address ({host}); other devices on the same network can reach this page without authentication. Do not expose it on an untrusted network — for public deployments read “Publishing behind a reverse proxy” in the README.',
598
+ 'diag.groupStorage': 'Storage',
599
+ 'diag.groupInjection': 'Injection',
600
+ 'diag.groupBackup': 'Backup & history',
601
+ 'diag.groupIssues': 'Errors & warnings',
602
+ 'diag.none': '(none)',
603
+ 'diag.storagePath': 'Store file',
604
+ 'diag.onDisk': 'Store file on disk',
605
+ 'diag.loadedFrom': 'Loaded from',
606
+ 'diag.savedAt': 'Last saved',
607
+ 'diag.rev': 'Revision (rev)',
608
+ 'diag.registered': 'Section registered',
609
+ 'diag.yes': 'yes',
610
+ 'diag.no': 'no',
611
+ 'diag.pulls': 'Prompt pulls (counted since this plugin started)',
612
+ 'diag.chars': 'Injected chars / budget',
613
+ 'diag.backupDir': 'Backup location',
614
+ 'diag.backupDefault': 'default (next to the store file)',
615
+ 'diag.backupPath': 'Rolling copy (.bak)',
616
+ 'diag.historyDir': 'History directory',
617
+ 'diag.historyCount': 'History snapshots',
618
+ 'diag.schemaFile': 'Schema file',
619
+ 'diag.issueStorage': 'Storage error',
620
+ 'diag.issueWarning': 'Storage warning',
621
+ 'diag.issueHistory': 'History error',
622
+ 'diag.issueSection': 'Injection error',
623
+ // ---- F7 temporary mute ----
624
+ 'mute.menu': 'Pause injection…',
625
+ 'mute.30m': '30 minutes',
626
+ 'mute.1h': '1 hour',
627
+ 'mute.today': 'Until the end of today',
628
+ 'mute.resume': 'Resume now',
629
+ 'mute.active': 'Injection paused — {remaining} left',
630
+ 'mute.hint': 'While paused, neither instructions nor personas are injected. It resumes automatically and survives a restart.',
631
+ 'mute.remainingMs': '{m}m {s}s',
632
+ 'mute.remainingH': '{h}h {m}m',
525
633
  // ---- F14 version history ----
526
634
  'history.section': 'Version history',
527
635
  'history.subtitle': 'Every save leaves a timestamped snapshot ({max} kept per device, oldest evicted first). Review them here and roll back with one click.',
@@ -924,6 +1032,71 @@ window.__ModuleLoader__.load({
924
1032
  return { budget, used, omitted, truncated, percent, tokens }
925
1033
  }
926
1034
 
1035
+ /**
1036
+ * F12: serialize a diagnostic snapshot into plain text for an issue report.
1037
+ * Pinned as a pure export so verify.mjs can hold the contract: the dump
1038
+ * carries the operational fields (store shape, storage, injection,
1039
+ * security, load origin) plus the reporter's environment, and NEVER
1040
+ * carries user text — no entry titles, no entry or persona bodies. Paths
1041
+ * (including the absolute backup directory) are machine-local but are the
1042
+ * whole point of a support dump, so they stay.
1043
+ */
1044
+ function diagnosticsText(view, env) {
1045
+ const sec = (root, name) => (root !== null && typeof root === 'object'
1046
+ && root[name] !== null && typeof root[name] === 'object' ? root[name] : {})
1047
+ const data = sec(view, 'data')
1048
+ const storage = sec(view, 'storage')
1049
+ const injection = sec(view, 'injection')
1050
+ const security = sec(view, 'security')
1051
+ const diagnostics = sec(view, 'diagnostics')
1052
+ const environment = env !== null && typeof env === 'object' ? env : {}
1053
+ const v = (value) => (value === null || value === undefined ? '' : String(value))
1054
+ const count = (value) => (Array.isArray(value) ? String(value.length) : '0')
1055
+ return [
1056
+ 'dsh-instruction-memory diagnostics',
1057
+ '--- store ---',
1058
+ 'version: ' + v(data.version),
1059
+ 'rev: ' + v(data.rev),
1060
+ 'enabled: ' + v(data.enabled),
1061
+ 'budgetChars: ' + v(data.budgetChars),
1062
+ 'disabledUntil: ' + v(data.disabledUntil),
1063
+ 'sets: ' + count(data.sets),
1064
+ 'entries(active set): ' + count(data.entries),
1065
+ 'profiles(active set): ' + count(data.profiles),
1066
+ '--- storage ---',
1067
+ 'path: ' + v(storage.path),
1068
+ 'onDisk: ' + v(storage.onDisk),
1069
+ 'loadedFrom: ' + v(diagnostics.loadedFrom),
1070
+ 'rev: ' + v(diagnostics.rev),
1071
+ 'savedAt: ' + (typeof storage.savedAt === 'number' && storage.savedAt > 0 ? new Date(storage.savedAt).toISOString() : ''),
1072
+ 'migratedFrom: ' + v(storage.migratedFrom),
1073
+ 'backupDir: ' + v(storage.backupDir),
1074
+ 'isDefaultBackupDir: ' + v(storage.isDefaultBackupDir),
1075
+ 'backupPath: ' + v(storage.backupPath),
1076
+ 'historyDir: ' + v(storage.historyDir),
1077
+ 'historyCount: ' + v(storage.historyCount),
1078
+ 'schemaFile: ' + v(storage.schemaFile),
1079
+ '--- injection ---',
1080
+ 'registered: ' + v(injection.registered),
1081
+ 'muted: ' + v(injection.muted),
1082
+ 'error: ' + v(injection.error),
1083
+ 'chars: ' + v(injection.chars),
1084
+ 'budget: ' + v(injection.budget),
1085
+ 'pulls(since boot): ' + v(injection.pulls),
1086
+ '--- security ---',
1087
+ 'hostGuarded: ' + v(security.hostGuarded),
1088
+ 'boundHost: ' + v(security.boundHost),
1089
+ '--- errors/warnings ---',
1090
+ 'storage.error: ' + v(storage.error),
1091
+ 'storage.warning: ' + v(storage.warning),
1092
+ 'historyError: ' + v(storage.historyError),
1093
+ '--- environment ---',
1094
+ 'userAgent: ' + v(environment.userAgent),
1095
+ 'language: ' + v(environment.language),
1096
+ 'time: ' + v(environment.now),
1097
+ ].join('\n')
1098
+ }
1099
+
927
1100
  /* ---------------- F17: local relevance preview ---------------- */
928
1101
 
929
1102
  /** The most terms one query may be split into; keeps matching cheap. */
@@ -1318,6 +1491,16 @@ window.__ModuleLoader__.load({
1318
1491
  // draft: editing an instruction never closes an open persona and vice
1319
1492
  // versa is handled by closing one when opening the other (see handlers).
1320
1493
  const [profileDraft, setProfileDraft] = React.useState(null)
1494
+ // Latest live values of the two editor drafts. switchSet's success
1495
+ // callback is a closure created at click time; between the click and the
1496
+ // response landing, a draft can still be opened (the busy flag only
1497
+ // re-renders on the next pass), so the callback must read the drafts
1498
+ // through refs to see what is REALLY open when the switch lands — a
1499
+ // stale `draft === null` would leave the old set's form on screen.
1500
+ const draftRef = React.useRef(null)
1501
+ draftRef.current = draft
1502
+ const profileDraftRef = React.useRef(null)
1503
+ profileDraftRef.current = profileDraft
1321
1504
  const [budgetText, setBudgetText] = React.useState('')
1322
1505
  // Latest live value of the draft, because the SSE onmessage closure below
1323
1506
  // is created once and must not read a stale first-render budgetText.
@@ -1354,6 +1537,17 @@ window.__ModuleLoader__.load({
1354
1537
  const [historyOpen, setHistoryOpen] = React.useState(false)
1355
1538
  const [history, setHistory] = React.useState(null)
1356
1539
  const [historyBusy, setHistoryBusy] = React.useState(false)
1540
+ // F12: the diagnostics card is collapsed by default; a failed automatic
1541
+ // copy parks the dump here so it can be selected manually instead of
1542
+ // pretending the copy happened.
1543
+ const [diagOpen, setDiagOpen] = React.useState(false)
1544
+ const [diagFallback, setDiagFallback] = React.useState(null)
1545
+ // F7: the pause menu (null-state boolean) and a 1s clock tick that exists
1546
+ // ONLY while a pause is live (its effect cleans up after itself). The
1547
+ // tick drives nothing but the remaining-time text; authority stays with
1548
+ // the host snapshot (injection.muted / data.disabledUntil).
1549
+ const [pauseMenuOpen, setPauseMenuOpen] = React.useState(false)
1550
+ const [nowTick, setNowTick] = React.useState(Date.now())
1357
1551
  // F18: the backup-location draft (focused-input protection like budget).
1358
1552
  const [backupText, setBackupText] = React.useState('')
1359
1553
  const backupTextRef = React.useRef('')
@@ -1396,6 +1590,20 @@ window.__ModuleLoader__.load({
1396
1590
  return () => clearTimeout(timer)
1397
1591
  }, [undo])
1398
1592
 
1593
+ // F7: tick once per second while a pause is live so the remaining-time
1594
+ // text counts down. The interval exists only while muted — the cleanup
1595
+ // clears it the moment the snapshot says otherwise. This effect sits
1596
+ // BEFORE the early returns (a hook skipped by a conditional return would
1597
+ // desync the hook order); it reads the snapshot through `view` because
1598
+ // the derived `injection` does not exist yet up here.
1599
+ const mutedNow = view !== null && view.injection !== null && typeof view.injection === 'object'
1600
+ && view.injection.muted === true
1601
+ React.useEffect(() => {
1602
+ if (!mutedNow) return undefined
1603
+ const tick = setInterval(() => setNowTick(Date.now()), 1000)
1604
+ return () => clearInterval(tick)
1605
+ }, [mutedNow])
1606
+
1399
1607
  /**
1400
1608
  * F16 live validation, debounced: parsing on every keystroke would stall
1401
1609
  * the panel on a multi-megabyte paste, and 250 ms is below the threshold
@@ -1566,6 +1774,22 @@ window.__ModuleLoader__.load({
1566
1774
  if (result !== null && result.ok === true) {
1567
1775
  setSelected([])
1568
1776
  setUndo(null)
1777
+ // Batch mode exists to act on listed ids; after a switch it would
1778
+ // act on ids from the wrong set, so it leaves with the selection.
1779
+ setBatchMode(false)
1780
+ // An editor left open across a switch is a form full of the OLD
1781
+ // set's content that would save into the NEW set. The server-side
1782
+ // cross-set guard means no data is lost, but the form itself is a
1783
+ // lie — so it closes with the switch, and the user is told. The
1784
+ // refs (not the closure state) say whether anything was open.
1785
+ setDraft(null)
1786
+ setProfileDraft(null)
1787
+ if (draftRef.current !== null || profileDraftRef.current !== null) {
1788
+ const base = typeof result.message === 'string' && result.message !== ''
1789
+ ? result.message + ';'
1790
+ : ''
1791
+ setNotice(base + t('sets.draftsClosed'))
1792
+ }
1569
1793
  }
1570
1794
  })
1571
1795
  }
@@ -1729,6 +1953,49 @@ window.__ModuleLoader__.load({
1729
1953
  }
1730
1954
  }
1731
1955
 
1956
+ // ---- F12 diagnostics copy ----
1957
+
1958
+ function copyDiagnostics() {
1959
+ if (view === null) return
1960
+ const text = diagnosticsText(view, {
1961
+ userAgent: typeof navigator !== 'undefined' && navigator.userAgent !== undefined ? String(navigator.userAgent) : '',
1962
+ language: typeof navigator !== 'undefined' && navigator.language !== undefined ? String(navigator.language) : '',
1963
+ now: new Date().toISOString(),
1964
+ })
1965
+ const clipboard = typeof navigator !== 'undefined' && navigator.clipboard
1966
+ && typeof navigator.clipboard.writeText === 'function' ? navigator.clipboard : null
1967
+ if (clipboard !== null) {
1968
+ clipboard.writeText(text).then(() => setNotice(t('diag.copied')), () => fallbackCopyDiagnostics(text))
1969
+ return
1970
+ }
1971
+ fallbackCopyDiagnostics(text)
1972
+ }
1973
+
1974
+ /**
1975
+ * The webview may ship neither the async clipboard API nor
1976
+ * document.execCommand; then the only honest move is to surface the dump
1977
+ * in a selectable textarea instead of pretending the copy happened.
1978
+ */
1979
+ function fallbackCopyDiagnostics(text) {
1980
+ try {
1981
+ const area = document.createElement('textarea')
1982
+ area.value = text
1983
+ area.setAttribute('readonly', '')
1984
+ const host = document.body || document.head
1985
+ if (host && typeof host.appendChild === 'function') host.appendChild(area)
1986
+ if (typeof area.select === 'function') area.select()
1987
+ const ok = typeof document.execCommand === 'function' && document.execCommand('copy') === true
1988
+ if (host && typeof host.removeChild === 'function' && area.parentNode === host) host.removeChild(area)
1989
+ if (ok) {
1990
+ setNotice(t('diag.copied'))
1991
+ return
1992
+ }
1993
+ throw new Error('copy rejected')
1994
+ } catch {
1995
+ setDiagFallback(text)
1996
+ }
1997
+ }
1998
+
1732
1999
  async function doExport() {
1733
2000
  try {
1734
2001
  const result = await callHost('export-data', null)
@@ -1854,6 +2121,11 @@ window.__ModuleLoader__.load({
1854
2121
  // it here keeps the effect's dependency list down to the text itself.
1855
2122
  sourceCeilingRef.current = typeof limits.maxBodyChars === 'number' ? limits.maxBodyChars : 0
1856
2123
  const injection = view.injection || { registered: false, chars: 0, error: null }
2124
+ // F12: the security block is optional (older Host) and then means "no
2125
+ // known problem" — an absent field must not manufacture a warning.
2126
+ const security = view.security !== null && typeof view.security === 'object'
2127
+ ? view.security
2128
+ : { hostGuarded: true, boundHost: '' }
1857
2129
  // List order is display-only and never changes what the Host injects
1858
2130
  // (the injection order is decided host-side). The default mirrors the
1859
2131
  // previous fixed order; updated-time and title are the other sorts.
@@ -1972,20 +2244,90 @@ window.__ModuleLoader__.load({
1972
2244
  : (injection.registered
1973
2245
  ? t('status.mounted', { chars: injection.chars })
1974
2246
  : (activeCount === 0 ? t('status.notMountedEmpty') : t('status.notMounted')))
2247
+ // F12: display-only rows. Every value comes straight from the snapshot;
2248
+ // a missing field (older Host) degrades to (无) instead of throwing
2249
+ // mid-render.
2250
+ const diagValue = (value) => (value === null || value === undefined || value === '' ? t('diag.none') : String(value))
2251
+ const diagYesNo = (value) => (value === true ? t('diag.yes') : t('diag.no'))
2252
+ const diagRow = (label, value) => h('div', { className: 'im-meta' }, label + ':' + value)
2253
+ const diagLoadedFrom = view.diagnostics !== null && typeof view.diagnostics === 'object'
2254
+ ? view.diagnostics.loadedFrom
2255
+ : null
2256
+ const diagRev = view.diagnostics !== null && typeof view.diagnostics === 'object'
2257
+ && typeof view.diagnostics.rev === 'number'
2258
+ ? view.diagnostics.rev
2259
+ : (data.rev || 0)
2260
+ // F7: the pause decision itself belongs to the host (injection.muted);
2261
+ // the countdown below is display-only. At zero the bar yields back to
2262
+ // the normal control (the host's own timer is delivering a fresh
2263
+ // snapshot at that moment in any case).
2264
+ const muted = injection.muted === true
2265
+ const disabledUntil = typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil)
2266
+ ? data.disabledUntil
2267
+ : 0
2268
+ const muteRemainingMs = muted ? Math.max(0, disabledUntil - nowTick) : 0
2269
+ const muteRemainingText = (ms) => {
2270
+ const totalSec = Math.max(0, Math.round(ms / 1000))
2271
+ const hh = Math.floor(totalSec / 3600)
2272
+ const mm = Math.floor((totalSec % 3600) / 60)
2273
+ const ss = totalSec % 60
2274
+ return hh > 0 ? t('mute.remainingH', { h: hh, m: mm }) : t('mute.remainingMs', { m: mm, s: ss })
2275
+ }
2276
+ const endOfToday = () => {
2277
+ const d = new Date()
2278
+ d.setHours(24, 0, 0, 0)
2279
+ return d.getTime()
2280
+ }
2281
+ function pauseFor(ms) {
2282
+ if (busy || typeof ms !== 'number' || !(ms > 0)) return
2283
+ setPauseMenuOpen(false)
2284
+ void send('set-options', { disabledUntil: Date.now() + ms, baseRev: revOf() })
2285
+ }
2286
+ function resumePause() {
2287
+ if (busy) return
2288
+ setPauseMenuOpen(false)
2289
+ void send('set-options', { disabledUntil: 0, baseRev: revOf() })
2290
+ }
1975
2291
 
1976
2292
  const header = h('div', { className: 'im-card', key: 'head' },
2293
+ // F12: the non-loopback warning sits at the TOP of the panel — not
2294
+ // inside the collapsible diagnostics card — so it cannot be missed.
2295
+ security.hostGuarded === false
2296
+ ? h('div', { className: 'im-hostwarn', key: 'hostwarn' },
2297
+ t('diag.hostWarn', { host: security.boundHost !== '' ? security.boundHost : '?' }))
2298
+ : null,
1977
2299
  h('div', { className: 'im-row' },
1978
2300
  h('div', null,
1979
2301
  h('h3', { className: 'im-h' }, t('panel.title')),
1980
2302
  h('p', { className: 'im-sub' }, t('panel.subtitle'))),
1981
- h('label', { className: 'im-switch' },
1982
- h('input', {
1983
- type: 'checkbox',
1984
- checked: data.enabled === true,
1985
- disabled: busy,
1986
- onChange: () => send('set-options', { enabled: !data.enabled, baseRev: revOf() }),
1987
- }),
1988
- t('panel.masterSwitch'))),
2303
+ h('div', { className: 'im-master-col' },
2304
+ h('label', { className: 'im-switch' },
2305
+ h('input', {
2306
+ type: 'checkbox',
2307
+ checked: data.enabled === true,
2308
+ disabled: busy,
2309
+ onChange: () => send('set-options', { enabled: !data.enabled, baseRev: revOf() }),
2310
+ }),
2311
+ t('panel.masterSwitch')),
2312
+ // F7 临时静音:暂停期间提示词与「未安装插件」逐字节一致;到期由宿主定时器
2313
+ // 自动恢复,无需用户操作、不写盘。倒计时只是展示,权威在快照。
2314
+ muted && muteRemainingMs > 0
2315
+ ? h('div', { className: 'im-pause-bar' },
2316
+ h('span', null, t('mute.active', { remaining: muteRemainingText(muteRemainingMs) })),
2317
+ h('button', { className: 'im-btn', disabled: busy, onClick: resumePause }, t('mute.resume')))
2318
+ : h('div', { className: 'im-pause-row' },
2319
+ h('button', {
2320
+ className: 'im-btn',
2321
+ disabled: busy,
2322
+ onClick: () => setPauseMenuOpen(!pauseMenuOpen),
2323
+ }, t('mute.menu')),
2324
+ pauseMenuOpen
2325
+ ? h('div', { className: 'im-pause-menu' },
2326
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => pauseFor(30 * 60000) }, t('mute.30m')),
2327
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => pauseFor(60 * 60000) }, t('mute.1h')),
2328
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => pauseFor(endOfToday() - Date.now()) }, t('mute.today')),
2329
+ h('div', { className: 'im-meta' }, t('mute.hint')))
2330
+ : null))),
1989
2331
  h('div', { className: 'im-status' },
1990
2332
  h('span', { className: dotClass }),
1991
2333
  h('span', { className: injection.error ? 'im-err' : 'im-meta' }, dotText)),
@@ -2410,6 +2752,58 @@ window.__ModuleLoader__.load({
2410
2752
  history !== null && history.dir !== ''
2411
2753
  ? h('div', { className: 'im-meta' }, t('history.dir', { dir: history.dir }))
2412
2754
  : null),
2755
+ // ---- F12 诊断与安全状态(默认收起;纯展示,无写路由) ----
2756
+ h('div', { key: 'diag', className: 'im-diag' },
2757
+ h('div', { className: 'im-profiles-head' },
2758
+ h('span', { className: 'im-section-title' }, t('diag.section')),
2759
+ h('span', { className: 'im-spacer' }),
2760
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => setDiagOpen(!diagOpen) },
2761
+ diagOpen ? t('diag.hide') : t('diag.show')),
2762
+ h('button', { className: 'im-btn', disabled: busy, onClick: copyDiagnostics }, t('diag.copy'))),
2763
+ diagOpen
2764
+ ? h('div', null,
2765
+ h('div', { className: 'im-diag-group' },
2766
+ h('span', { className: 'im-section-title im-diag-sub' }, t('diag.groupStorage')),
2767
+ diagRow(t('diag.storagePath'), diagValue(storage.path)),
2768
+ diagRow(t('diag.onDisk'), diagYesNo(storage.onDisk === true)),
2769
+ diagRow(t('diag.loadedFrom'), diagValue(diagLoadedFrom)),
2770
+ diagRow(t('diag.savedAt'), typeof storage.savedAt === 'number' && storage.savedAt > 0
2771
+ ? new Date(storage.savedAt).toLocaleString()
2772
+ : t('diag.none')),
2773
+ diagRow(t('diag.rev'), String(diagRev))),
2774
+ h('div', { className: 'im-diag-group' },
2775
+ h('span', { className: 'im-section-title im-diag-sub' }, t('diag.groupInjection')),
2776
+ diagRow(t('diag.registered'), diagYesNo(injection.registered === true)),
2777
+ diagRow(t('diag.pulls'), String(typeof injection.pulls === 'number' ? injection.pulls : 0)),
2778
+ diagRow(t('diag.chars'),
2779
+ String(typeof injection.chars === 'number' ? injection.chars : 0) + ' / '
2780
+ + String(typeof injection.budget === 'number' ? injection.budget : data.budgetChars))),
2781
+ h('div', { className: 'im-diag-group' },
2782
+ h('span', { className: 'im-section-title im-diag-sub' }, t('diag.groupBackup')),
2783
+ diagRow(t('diag.backupDir'), typeof storage.backupDir === 'string' && storage.backupDir !== ''
2784
+ ? storage.backupDir
2785
+ : t('diag.backupDefault')),
2786
+ diagRow(t('diag.backupPath'), diagValue(storage.backupPath)),
2787
+ diagRow(t('diag.historyDir'), diagValue(storage.historyDir)),
2788
+ diagRow(t('diag.historyCount'), String(typeof storage.historyCount === 'number' ? storage.historyCount : 0)),
2789
+ diagRow(t('diag.schemaFile'), diagValue(storage.schemaFile))),
2790
+ h('div', { className: 'im-diag-group' },
2791
+ h('span', { className: 'im-section-title im-diag-sub' }, t('diag.groupIssues')),
2792
+ diagRow(t('diag.issueStorage'), diagValue(storage.error)),
2793
+ diagRow(t('diag.issueWarning'), diagValue(storage.warning)),
2794
+ diagRow(t('diag.issueHistory'), diagValue(storage.historyError)),
2795
+ diagRow(t('diag.issueSection'), diagValue(injection.error))),
2796
+ diagFallback !== null
2797
+ ? h('div', { className: 'im-diag-fallback' },
2798
+ h('div', { className: 'im-meta' }, t('diag.copyManual')),
2799
+ h('textarea', {
2800
+ className: 'im-textarea',
2801
+ readOnly: true,
2802
+ value: diagFallback,
2803
+ onFocus: (ev) => { if (typeof ev.target.select === 'function') ev.target.select() },
2804
+ }))
2805
+ : null)
2806
+ : null),
2413
2807
  error !== '' ? h('div', { className: 'im-err' }, error) : null,
2414
2808
  error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
2415
2809
  error === '' && notice === '' && storage.error ? h('div', { className: 'im-err' }, storage.error) : null,
@@ -2717,6 +3111,7 @@ window.__ModuleLoader__.load({
2717
3111
  exports.__relevanceTerms = relevanceTerms
2718
3112
  exports.__relevancePreview = relevancePreview
2719
3113
  exports.__sourceParseVerdict = sourceParseVerdict
3114
+ exports.__diagnosticsText = diagnosticsText
2720
3115
  exports.__strings = STRINGS
2721
3116
  exports.__ns = NS
2722
3117
  exports.__dictionaryT = dictionaryT
package/lib/index.js CHANGED
@@ -84,6 +84,12 @@ const MAX_SETS = 20
84
84
  const MAX_SET_NAME = 30
85
85
  /** Rolling history snapshots (F14): how many are kept PER writing device. */
86
86
  const MAX_HISTORY = 20
87
+ /**
88
+ * F7: the longest single pause. Doubles as the setTimeout guard — 7 days is
89
+ * far below the 2^31-1 ms (≈24.8 days) timer ceiling, so the delay never
90
+ * overflows Node's timer range.
91
+ */
92
+ const MAX_MUTE_MS = 7 * 24 * 60 * 60 * 1000
87
93
  const HISTORY_DIR_NAME = 'history'
88
94
  /** Name of the JSON Schema shipped with the package and copied next to the store. */
89
95
  const SCHEMA_FILE_NAME = 'memory.schema.json'
@@ -552,6 +558,7 @@ function normalizeData(raw) {
552
558
  version: FORMAT_VERSION,
553
559
  rev: 0,
554
560
  enabled: true,
561
+ disabledUntil: 0,
555
562
  budgetChars: DEFAULT_BUDGET,
556
563
  backupDir: '',
557
564
  activeSetId: DEFAULT_SET_ID,
@@ -599,6 +606,18 @@ function normalizeData(raw) {
599
606
  } else if ('backupDir' in raw && raw.backupDir !== null && raw.backupDir !== '') {
600
607
  optionFixes.push('backupDir ' + describeValue(raw.backupDir) + ' → ""(需要字符串路径)')
601
608
  }
609
+ // F7 temporary mute. The field is OPTIONAL (the format stays v2): missing
610
+ // means "not paused" and is adopted silently, exactly like a v1 file without
611
+ // sets. Present but not a usable instant is reset to 0 with a disclosure,
612
+ // like every other hand-edited option.
613
+ if ('disabledUntil' in raw) {
614
+ const muteValue = raw.disabledUntil
615
+ if (typeof muteValue === 'number' && Number.isFinite(muteValue) && muteValue >= 0) {
616
+ data.disabledUntil = Math.round(muteValue)
617
+ } else {
618
+ optionFixes.push('disabledUntil ' + describeValue(muteValue) + ' → 0(需要 ≥ 0 的 epoch 毫秒数,0 表示未暂停)')
619
+ }
620
+ }
602
621
 
603
622
  // Instruction sets (F13). A missing/blank list means a v1 file (or a hand
604
623
  // edit that removed it): synthesise the single 默认 set and record the fact,
@@ -705,6 +724,9 @@ function cloneData(data) {
705
724
  version: FORMAT_VERSION,
706
725
  rev: data.rev || 0,
707
726
  enabled: data.enabled === true,
727
+ disabledUntil: typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil) && data.disabledUntil > 0
728
+ ? data.disabledUntil
729
+ : 0,
708
730
  budgetChars: data.budgetChars,
709
731
  backupDir: typeof data.backupDir === 'string' ? data.backupDir : '',
710
732
  activeSetId: data.activeSetId,
@@ -1145,13 +1167,44 @@ function planEntries(data, budget) {
1145
1167
  * - a profile-only store (no instructions) still injects, and the master
1146
1168
  * `enabled` switch suppresses both halves as before.
1147
1169
  */
1148
- export function planInjection(data) {
1170
+ /**
1171
+ * F7 temporary mute: a store-level pause with an expiry. `disabledUntil` is an
1172
+ * epoch-ms instant; while it lies in the future (and the master switch is on)
1173
+ * the store is muted. The wall clock is only the DEFAULT: both this and the
1174
+ * render entry points take `now`, so a fixed clock reproduces the same bytes
1175
+ * and tests pin the behaviour without waiting.
1176
+ */
1177
+ export function isMuted(data, now = Date.now()) {
1178
+ return data !== null && typeof data === 'object' && data.enabled === true
1179
+ && typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil)
1180
+ && data.disabledUntil > now
1181
+ }
1182
+
1183
+ /**
1184
+ * The lapsed-mute decision the host timer acts on: a mute whose instant has
1185
+ * passed needs the section re-registered (and a snapshot pushed), but writes
1186
+ * NOTHING — expiry is a state change, not a save; the store file and its rev
1187
+ * stay untouched, and the stale value is simply ignored until the next real
1188
+ * write rewrites the file without it.
1189
+ */
1190
+ export function muteExpired(data, now = Date.now()) {
1191
+ return data !== null && typeof data === 'object' && data.enabled === true
1192
+ && typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil)
1193
+ && data.disabledUntil > 0 && data.disabledUntil <= now
1194
+ }
1195
+
1196
+ export function planInjection(data, now = Date.now()) {
1149
1197
  const budget = clampBudget(data !== null && typeof data === 'object' ? data.budgetChars : undefined)
1150
1198
  const blank = () => ({
1151
1199
  text: '', budget, activeCount: 0, included: [], omitted: [],
1152
1200
  profiles: [], profileChars: 0, entryChars: 0,
1153
1201
  })
1154
1202
  if (data === null || typeof data !== 'object' || data.enabled !== true) return blank()
1203
+ // F7 temporary mute: while the pause is live the plan is EXACTLY the
1204
+ // enabled:false plan — same fields, same empty text — so the prompt section
1205
+ // disappears and the prompt is byte-identical to a deployment without this
1206
+ // plugin.
1207
+ if (isMuted(data, now)) return blank()
1155
1208
  /**
1156
1209
  * The set filter (F13) is applied HERE, at the one place both the rendered
1157
1210
  * text and the budget decision are produced, so no caller can inject an
@@ -1186,8 +1239,8 @@ export function planInjection(data) {
1186
1239
  * decision process lives in one place, and callers that only need the text
1187
1240
  * (the prompt section) keep this byte-stable entry point.
1188
1241
  */
1189
- export function buildBlock(data) {
1190
- return planInjection(data).text
1242
+ export function buildBlock(data, now = Date.now()) {
1243
+ return planInjection(data, now).text
1191
1244
  }
1192
1245
 
1193
1246
  /* ------------------------------------------------------------------ *
@@ -1238,8 +1291,28 @@ export function apply(ctx) {
1238
1291
  storeExists: false,
1239
1292
  /** How many history snapshots the current directory holds (diagnostics). */
1240
1293
  historyCount: 0,
1294
+ /**
1295
+ * The address the settings route is actually bound to (F12 security panel).
1296
+ * `''` until a web server publishes; the panel turns it into a warning when
1297
+ * the bind is not loopback.
1298
+ */
1299
+ boundHost: '',
1241
1300
  }
1242
1301
  let sectionDispose = null
1302
+ /**
1303
+ * Which Host values this route accepts. Set when the route is registered,
1304
+ * from the address the web server is actually bound to (see the long note on
1305
+ * requestOriginVerdict). Until then the route does not exist at all, so the
1306
+ * default only matters if something registers it out of band. Declared next
1307
+ * to `state` so the snapshot can publish it at any time without a
1308
+ * temporal-dead-zone hazard (F12 publishes it as `security.hostGuarded`).
1309
+ */
1310
+ let guardHost = true
1311
+ /**
1312
+ * F7: the pending mute-expiry timer, or null. Re-armed by scheduleMuteTimer
1313
+ * (from syncSection) and cleared in the effect cleanup.
1314
+ */
1315
+ let muteTimer = null
1243
1316
 
1244
1317
  /**
1245
1318
  * A first run deliberately writes NOTHING (see readFromDisk step 3). This flag
@@ -1320,7 +1393,7 @@ export function apply(ctx) {
1320
1393
 
1321
1394
  const sectionText = () => {
1322
1395
  try {
1323
- return buildBlock(state.data)
1396
+ return buildBlock(state.data, Date.now())
1324
1397
  } catch (err) {
1325
1398
  console.error('[instruction-memory] render failed:', err)
1326
1399
  return ''
@@ -1336,7 +1409,7 @@ export function apply(ctx) {
1336
1409
  */
1337
1410
  const sectionPlan = () => {
1338
1411
  try {
1339
- return planInjection(state.data)
1412
+ return planInjection(state.data, Date.now())
1340
1413
  } catch (err) {
1341
1414
  console.error('[instruction-memory] render failed:', err)
1342
1415
  return {
@@ -1361,6 +1434,37 @@ export function apply(ctx) {
1361
1434
  return sectionText()
1362
1435
  }
1363
1436
 
1437
+ /**
1438
+ * F7: keep one host-side timer pointed at the mute's expiry. Expiry must be
1439
+ * automatic — no user action, no write: when the instant passes, the section
1440
+ * re-registers (or stays dropped) and open panels get a fresh snapshot,
1441
+ * while the store file and its rev stay untouched. syncSection re-arms it
1442
+ * on every state change (boot, commit, external reload), and re-arming
1443
+ * clears the previous timer first, so there is never more than one.
1444
+ */
1445
+ const scheduleMuteTimer = () => {
1446
+ if (muteTimer !== null) {
1447
+ clearTimeout(muteTimer)
1448
+ muteTimer = null
1449
+ }
1450
+ if (!isMuted(state.data)) return
1451
+ const delay = Math.min(state.data.disabledUntil - Date.now(), MAX_MUTE_MS)
1452
+ muteTimer = setTimeout(() => {
1453
+ muteTimer = null
1454
+ // Clock skew or an early fire: if the mute is still live, re-arm instead
1455
+ // of acting; the sync below happens only on a real expiry.
1456
+ if (isMuted(state.data)) {
1457
+ scheduleMuteTimer()
1458
+ return
1459
+ }
1460
+ syncSection()
1461
+ pushSnapshot()
1462
+ logDebug('mute expired: section re-synced without a write')
1463
+ }, Math.max(1, delay))
1464
+ // A 7-day timer must not hold a shutting-down process open.
1465
+ if (typeof muteTimer.unref === 'function') muteTimer.unref()
1466
+ }
1467
+
1364
1468
  /**
1365
1469
  * Keep the registered section exactly in step with the stored data: present
1366
1470
  * when something must be injected, absent otherwise. An empty memory must
@@ -1380,9 +1484,7 @@ export function apply(ctx) {
1380
1484
  state.sectionError = '注入注册失败:' + describeError(err)
1381
1485
  console.error('[instruction-memory] section registration failed:', err)
1382
1486
  }
1383
- return
1384
- }
1385
- if (!wanted) {
1487
+ } else if (!wanted) {
1386
1488
  if (sectionDispose !== null) {
1387
1489
  const dispose = sectionDispose
1388
1490
  sectionDispose = null
@@ -1396,6 +1498,9 @@ export function apply(ctx) {
1396
1498
  state.sectionRegistered = false
1397
1499
  state.sectionError = null
1398
1500
  }
1501
+ // F7: whatever this sync just published, the mute timer must point at
1502
+ // exactly this state (and re-arming clears the previous timer first).
1503
+ scheduleMuteTimer()
1399
1504
  }
1400
1505
 
1401
1506
  ctx.effect(() => () => {
@@ -1408,6 +1513,10 @@ export function apply(ctx) {
1408
1513
  console.error('[instruction-memory] cleanup failed:', err)
1409
1514
  }
1410
1515
  }
1516
+ if (muteTimer !== null) {
1517
+ clearTimeout(muteTimer)
1518
+ muteTimer = null
1519
+ }
1411
1520
  stopWatching()
1412
1521
  disposeStreams()
1413
1522
  })
@@ -1540,6 +1649,9 @@ export function apply(ctx) {
1540
1649
  version: FORMAT_VERSION,
1541
1650
  rev: state.data.rev || 0,
1542
1651
  enabled: state.data.enabled === true,
1652
+ disabledUntil: typeof state.data.disabledUntil === 'number' && Number.isFinite(state.data.disabledUntil) && state.data.disabledUntil > 0
1653
+ ? state.data.disabledUntil
1654
+ : 0,
1543
1655
  budgetChars: clampBudget(state.data.budgetChars),
1544
1656
  backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
1545
1657
  activeSetId: state.data.activeSetId,
@@ -2070,14 +2182,29 @@ export function apply(ctx) {
2070
2182
  if (!needsMaterialize) return false
2071
2183
  /**
2072
2184
  * A store may have APPEARED since boot — a hand-copied memory.json, a
2073
- * backup restored by another tool, a second process. Materialising the
2074
- * empty in-memory store over it would destroy exactly what the user just
2075
- * put there, so the disk is re-checked first and an existing file is
2076
- * adopted instead. (The gap is wide enough to matter: the panel's first
2077
- * `state` call can come minutes after boot.)
2185
+ * backup restored by another tool, a second process. Three cases, and the
2186
+ * difference between the last two is the whole point:
2187
+ * - nothing there: materialise the empty store (the documented first run);
2188
+ * - READABLE: adopt it instead of overwriting whatever the user just put
2189
+ * there (the gap is wide — the panel's first `state` call can come
2190
+ * minutes after boot);
2191
+ * - present but UNREADABLE: write nothing at all and let the loader's own
2192
+ * disclosure say why. Overwriting would destroy the very file a user
2193
+ * could still repair by hand, and it would report `ok:true` while doing
2194
+ * it — the silent data loss this plugin must never commit.
2078
2195
  */
2079
- const appeared = await readStoreFile(storePath).catch(() => null)
2080
- if (appeared !== null) {
2196
+ let appeared = null
2197
+ let unreadable = false
2198
+ try {
2199
+ appeared = await readStoreFile(storePath)
2200
+ } catch (err) {
2201
+ // ENOENT is the FIRST RUN itself — nothing to adopt, so materialise.
2202
+ // Anything else (EACCES, EISDIR, a parse failure …) means a file IS
2203
+ // there and could not be adopted; readFromDisk() below reports exactly
2204
+ // what it was, and nothing is written.
2205
+ unreadable = !(err !== null && typeof err === 'object' && err.code === 'ENOENT')
2206
+ }
2207
+ if (unreadable || appeared !== null) {
2081
2208
  const wrote = await readFromDisk()
2082
2209
  syncSection()
2083
2210
  return wrote
@@ -2170,6 +2297,9 @@ export function apply(ctx) {
2170
2297
  version: FORMAT_VERSION,
2171
2298
  rev: state.data.rev || 0,
2172
2299
  enabled: state.data.enabled === true,
2300
+ disabledUntil: typeof state.data.disabledUntil === 'number' && Number.isFinite(state.data.disabledUntil) && state.data.disabledUntil > 0
2301
+ ? state.data.disabledUntil
2302
+ : 0,
2173
2303
  budgetChars: clampBudget(state.data.budgetChars),
2174
2304
  backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
2175
2305
  activeSetId: state.data.activeSetId,
@@ -2195,6 +2325,28 @@ export function apply(ctx) {
2195
2325
  profiles: state.data.profiles.filter((profile) => belongsTo(profile, set.id)).length,
2196
2326
  })),
2197
2327
  },
2328
+ /**
2329
+ * F12: the one security signal the panel was missing. `hostGuarded` is
2330
+ * the SAME flag the route enforces on every request — false means the
2331
+ * Host allowlist is OFF because the server is bound beyond loopback,
2332
+ * i.e. the page is reachable without authentication from the local
2333
+ * network. `boundHost` says which address produced that decision so the
2334
+ * warning can name it instead of guessing.
2335
+ */
2336
+ security: {
2337
+ hostGuarded: guardHost === true,
2338
+ boundHost: state.boundHost,
2339
+ },
2340
+ /**
2341
+ * F12: load-side diagnostics that used to be log-only. `loadedFrom` says
2342
+ * which branch of the boot load produced the current data (fresh store,
2343
+ * adopted file, recovered .bak, …); `rev` mirrors data.rev so a support
2344
+ * dump pairs it with the storage fields without another round trip.
2345
+ */
2346
+ diagnostics: {
2347
+ loadedFrom: state.loadedFrom,
2348
+ rev: state.data.rev || 0,
2349
+ },
2198
2350
  storage: {
2199
2351
  path: state.storageDisplay !== null ? state.storageDisplay : state.storagePath,
2200
2352
  error: state.storageError,
@@ -2218,6 +2370,12 @@ export function apply(ctx) {
2218
2370
  registered: state.sectionRegistered,
2219
2371
  available: true,
2220
2372
  error: state.sectionError,
2373
+ /**
2374
+ * F7: the pause is decided by the HOST clock (isMuted on the live
2375
+ * store) and published as a derived flag; the client only renders a
2376
+ * countdown from data.disabledUntil and never decides by itself.
2377
+ */
2378
+ muted: isMuted(state.data, Date.now()),
2221
2379
  chars: preview.length,
2222
2380
  /**
2223
2381
  * The budget decision behind `preview`, for the settings page's
@@ -2505,25 +2663,72 @@ export function apply(ctx) {
2505
2663
  }
2506
2664
  }
2507
2665
  }
2666
+ // F7 temporary mute: validated BEFORE the transaction so a bad value is
2667
+ // refused with a reason instead of being silently normalized. 0 means
2668
+ // "resume now"; a past instant means the same thing; anything beyond the
2669
+ // 7-day ceiling is clamped (and disclosed) rather than refused — a pause
2670
+ // is a de-escalation, refusing it would be user-hostile.
2671
+ let muteTarget = null
2672
+ let muteClamped = false
2673
+ if (patch !== null && typeof patch === 'object' && 'disabledUntil' in patch) {
2674
+ const rawMute = patch.disabledUntil
2675
+ if (typeof rawMute !== 'number' || !Number.isFinite(rawMute) || rawMute < 0) {
2676
+ return {
2677
+ ok: false,
2678
+ saved: false,
2679
+ message: 'disabledUntil 需要 0(立即恢复)或未来的 epoch 毫秒数',
2680
+ snapshot: snapshot(),
2681
+ }
2682
+ }
2683
+ let wanted = Math.round(rawMute)
2684
+ if (wanted <= Date.now()) {
2685
+ wanted = 0
2686
+ } else if (wanted > Date.now() + MAX_MUTE_MS) {
2687
+ wanted = Date.now() + MAX_MUTE_MS
2688
+ muteClamped = true
2689
+ }
2690
+ muteTarget = wanted
2691
+ }
2692
+ const muteWasActive = isMuted(state.data)
2508
2693
  const saved = await commit(() => {
2509
2694
  if (patch !== null && typeof patch === 'object') {
2510
- if (typeof patch.enabled === 'boolean') state.data.enabled = patch.enabled
2695
+ if (typeof patch.enabled === 'boolean') {
2696
+ state.data.enabled = patch.enabled
2697
+ // An explicit switch outranks the timer: flipping the master switch
2698
+ // ends any pause immediately.
2699
+ state.data.disabledUntil = 0
2700
+ }
2511
2701
  if (typeof patch.budgetChars === 'number' && Number.isFinite(patch.budgetChars)) {
2512
2702
  state.data.budgetChars = clampBudget(patch.budgetChars)
2513
2703
  }
2514
2704
  if (typeof patch.backupDir === 'string') state.data.backupDir = patch.backupDir.trim()
2705
+ if (muteTarget !== null && typeof patch.enabled !== 'boolean') {
2706
+ state.data.disabledUntil = muteTarget
2707
+ }
2515
2708
  }
2516
2709
  })
2517
2710
  // A changed backup location moves where history snapshots and manual
2518
2711
  // exports go, so it is reported rather than left to be discovered.
2519
- let note = null
2712
+ const notes = []
2520
2713
  if (patch !== null && typeof patch === 'object' && typeof patch.backupDir === 'string') {
2521
2714
  const wanted = patch.backupDir.trim()
2522
- note = wanted === ''
2715
+ notes.push(wanted === ''
2523
2716
  ? '已恢复默认备份位置(版本历史与导出与存储文件同目录)'
2524
- : '备份位置已更新:' + wanted + '(版本历史与「导出到备份目录」随之改变;存储文件本身不移动)'
2717
+ : '备份位置已更新:' + wanted + '(版本历史与「导出到备份目录」随之改变;存储文件本身不移动)')
2718
+ }
2719
+ if (muteTarget !== null && !(patch !== null && typeof patch === 'object' && typeof patch.enabled === 'boolean')) {
2720
+ if (muteTarget === 0) {
2721
+ if (muteWasActive) notes.push('已恢复注入。')
2722
+ } else {
2723
+ notes.push('已暂停注入至 ' + new Date(muteTarget).toLocaleString()
2724
+ + '(到期自动恢复;暂停期间不注入任何指令或用户形象)'
2725
+ + (muteClamped ? ';暂停时长上限为 7 天,已按上限记录' : ''))
2726
+ }
2525
2727
  }
2526
- return settle(note, saved)
2728
+ if (patch !== null && typeof patch === 'object' && typeof patch.enabled === 'boolean' && muteWasActive) {
2729
+ notes.push('总开关已切换,暂停注入状态已一并清除')
2730
+ }
2731
+ return settle(notes.length > 0 ? notes.join(';') : null, saved)
2527
2732
  })
2528
2733
 
2529
2734
  /**
@@ -3235,16 +3440,6 @@ export function apply(ctx) {
3235
3440
  seenProfileIds.add(wanted + '\u0000' + profile.id)
3236
3441
  seenProfileBodies.add(wanted + '\u0000' + profile.name + '\u0000' + profile.content)
3237
3442
  }
3238
- if (accepted.length === 0 && acceptedProfiles.length === 0) {
3239
- // Nothing was written to disk, so claiming saved:true would be a lie the
3240
- // panel cannot distinguish from a real save.
3241
- return {
3242
- ok: true,
3243
- saved: false,
3244
- message: '没有新增内容:' + skipped + ' 条条目、' + skippedProfiles + ' 个用户形象已存在或无效',
3245
- snapshot: snapshot(),
3246
- }
3247
- }
3248
3443
  // Create only the declared sets that the accepted items actually reference,
3249
3444
  // in declaration order, while there is room.
3250
3445
  const wantedIds = []
@@ -3261,21 +3456,90 @@ export function apply(ctx) {
3261
3456
  createdIds.add(declared.id)
3262
3457
  }
3263
3458
  let fellBack = 0
3264
- const finalIdOf = (wanted) => {
3265
- if (localSetIds.has(wanted) || createdIds.has(wanted)) return wanted
3266
- fellBack += 1
3267
- return activeId
3268
- }
3269
- const saved = await commit(() => {
3270
- state.data.sets.push(...createdSets)
3459
+ const finalIdOf = (wanted) => (localSetIds.has(wanted) || createdIds.has(wanted) ? wanted : activeId)
3460
+ /**
3461
+ * Fallback de-duplication (review fix). The plan phase de-duplicated
3462
+ * against the set each item DECLARED, but finalIdOf() may retarget an item
3463
+ * into the active set (its set is unknown here, or the MAX_SETS cap blocked
3464
+ * creating it). Every retargeted item is therefore re-checked against the
3465
+ * set it REALLY lands in — pre-existing content, same-batch items already
3466
+ * staged there, and same-batch items that reached it through the normal
3467
+ * path — before anything is appended. The normal path (set exists or was
3468
+ * created) keeps the plan-phase decision untouched, which is what keeps
3469
+ * re-importing the same export file a no-op.
3470
+ */
3471
+ const stagedEntries = []
3472
+ const stagedProfiles = []
3473
+ let dedupedOnFallback = 0
3474
+ {
3475
+ const finalSeenIds = new Set(existing.map((entry) => entry.setId + '\u0000' + entry.id))
3476
+ const finalSeenBodies = new Set(existing.map((entry) => bodyKey(entry.setId, entry.title, entry.content)))
3477
+ // Items the plan phase accepted under their declared set occupy that
3478
+ // set's id/body space even when the item itself later falls back (a
3479
+ // fallback item's declared set is neither local nor created, so the keys
3480
+ // never collide with these — they only guard the normal path).
3481
+ for (const item of accepted) {
3482
+ finalSeenIds.add(item.wanted + '\u0000' + item.entry.id)
3483
+ finalSeenBodies.add(bodyKey(item.wanted, item.entry.title, item.entry.content))
3484
+ }
3271
3485
  for (const item of accepted) {
3272
- item.entry.setId = finalIdOf(item.wanted)
3273
- state.data.entries.push(item.entry)
3486
+ const finalId = finalIdOf(item.wanted)
3487
+ if (finalId !== item.wanted) {
3488
+ if (finalSeenBodies.has(bodyKey(finalId, item.entry.title, item.entry.content))) {
3489
+ dedupedOnFallback += 1
3490
+ continue
3491
+ }
3492
+ if (finalSeenIds.has(finalId + '\u0000' + item.entry.id)) {
3493
+ // The body is new to the fallback target but the id is taken
3494
+ // there: re-mint instead of writing a duplicate id into one set.
3495
+ item.entry.id = makeId()
3496
+ }
3497
+ fellBack += 1
3498
+ finalSeenIds.add(finalId + '\u0000' + item.entry.id)
3499
+ finalSeenBodies.add(bodyKey(finalId, item.entry.title, item.entry.content))
3500
+ }
3501
+ item.entry.setId = finalId
3502
+ stagedEntries.push(item.entry)
3503
+ }
3504
+ const finalSeenProfileIds = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.id))
3505
+ const finalSeenProfileBodies = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.name + '\u0000' + profile.content))
3506
+ for (const item of acceptedProfiles) {
3507
+ finalSeenProfileIds.add(item.wanted + '\u0000' + item.profile.id)
3508
+ finalSeenProfileBodies.add(item.wanted + '\u0000' + item.profile.name + '\u0000' + item.profile.content)
3274
3509
  }
3275
3510
  for (const item of acceptedProfiles) {
3276
- item.profile.setId = finalIdOf(item.wanted)
3277
- state.data.profiles.push(item.profile)
3511
+ const finalId = finalIdOf(item.wanted)
3512
+ if (finalId !== item.wanted) {
3513
+ if (finalSeenProfileBodies.has(finalId + '\u0000' + item.profile.name + '\u0000' + item.profile.content)) {
3514
+ dedupedOnFallback += 1
3515
+ continue
3516
+ }
3517
+ if (finalSeenProfileIds.has(finalId + '\u0000' + item.profile.id)) {
3518
+ item.profile.id = makeId()
3519
+ }
3520
+ fellBack += 1
3521
+ finalSeenProfileIds.add(finalId + '\u0000' + item.profile.id)
3522
+ finalSeenProfileBodies.add(finalId + '\u0000' + item.profile.name + '\u0000' + item.profile.content)
3523
+ }
3524
+ item.profile.setId = finalId
3525
+ stagedProfiles.push(item.profile)
3526
+ }
3527
+ }
3528
+ if (stagedEntries.length === 0 && stagedProfiles.length === 0) {
3529
+ // Nothing was written to disk, so claiming saved:true would be a lie the
3530
+ // panel cannot distinguish from a real save.
3531
+ return {
3532
+ ok: true,
3533
+ saved: false,
3534
+ message: '没有新增内容:' + skipped + ' 条条目、' + skippedProfiles + ' 个用户形象已存在或无效'
3535
+ + (dedupedOnFallback > 0 ? ';另有 ' + dedupedOnFallback + ' 项因回退到当前配置集后与已有内容重复被跳过' : ''),
3536
+ snapshot: snapshot(),
3278
3537
  }
3538
+ }
3539
+ const saved = await commit(() => {
3540
+ state.data.sets.push(...createdSets)
3541
+ for (const entry of stagedEntries) state.data.entries.push(entry)
3542
+ for (const profile of stagedProfiles) state.data.profiles.push(profile)
3279
3543
  })
3280
3544
  const parts = []
3281
3545
  if (createdSets.length > 0) {
@@ -3284,6 +3548,9 @@ export function apply(ctx) {
3284
3548
  if (fellBack > 0) {
3285
3549
  parts.push('有 ' + fellBack + ' 项因原配置集不存在或已达 ' + MAX_SETS + ' 个上限,已归入当前配置集')
3286
3550
  }
3551
+ if (dedupedOnFallback > 0) {
3552
+ parts.push('另有 ' + dedupedOnFallback + ' 项因回退到当前配置集后与已有内容重复被跳过')
3553
+ }
3287
3554
  if (accepted.length > 0) {
3288
3555
  const truncated = truncatedCount > 0
3289
3556
  ? ';其中 ' + truncatedCount + ' 条超过长度上限,' + [...cutFields].join('、') + '已被截断'
@@ -3395,11 +3662,8 @@ export function apply(ctx) {
3395
3662
 
3396
3663
  /* ---------------- same-origin JSON route for the Client half ---------------- */
3397
3664
 
3398
- // Which Host values this route accepts. Set when the route is registered,
3399
- // from the address the web server is actually bound to (see the long note on
3400
- // requestOriginVerdict). Until then the route does not exist at all, so the
3401
- // default only matters if something registers it out of band.
3402
- let guardHost = true
3665
+ // `guardHost` is declared next to `state` (F12 publishes it as
3666
+ // `security.hostGuarded`); only `routeDispose` lives here.
3403
3667
 
3404
3668
  let routeDispose = null
3405
3669
 
@@ -3574,6 +3838,9 @@ export function apply(ctx) {
3574
3838
  // the README.
3575
3839
  const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : ''
3576
3840
  guardHost = boundHost === '' || isLoopbackName(boundHost)
3841
+ // F12: the settings page can only warn about a non-loopback bind if the
3842
+ // snapshot carries what the route actually saw at registration time.
3843
+ state.boundHost = boundHost
3577
3844
  try {
3578
3845
  routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
3579
3846
  logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')
@@ -27,6 +27,12 @@
27
27
  "description": "总开关。为 false 时指令与用户形象都不注入。",
28
28
  "default": true
29
29
  },
30
+ "disabledUntil": {
31
+ "type": "integer",
32
+ "minimum": 0,
33
+ "description": "临时静音(F7):暂停注入的到期时刻(epoch 毫秒)。0 或缺失 = 未暂停;未来值 = 暂停中,到期后由插件自动恢复注入(无需用户操作,也不产生写盘,rev 不变)。它与总开关相互独立:手动切换 enabled 会把它清零(显式开关优先于定时)。字段缺失时按未暂停处理,不触发任何迁移。",
34
+ "default": 0
35
+ },
30
36
  "budgetChars": {
31
37
  "type": "integer",
32
38
  "minimum": 800,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the model has no write access. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。",
5
5
  "license": "MIT",
6
6
  "type": "module",