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 +61 -0
- package/README.md +37 -0
- package/lib/client.js +403 -8
- package/lib/index.js +313 -46
- package/memory.schema.json +6 -0
- package/package.json +1 -1
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('
|
|
1982
|
-
h('
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
2074
|
-
*
|
|
2075
|
-
*
|
|
2076
|
-
*
|
|
2077
|
-
* `state` call can come
|
|
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
|
-
|
|
2080
|
-
|
|
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')
|
|
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
|
-
|
|
2712
|
+
const notes = []
|
|
2520
2713
|
if (patch !== null && typeof patch === 'object' && typeof patch.backupDir === 'string') {
|
|
2521
2714
|
const wanted = patch.backupDir.trim()
|
|
2522
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
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
|
-
|
|
3273
|
-
|
|
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
|
-
|
|
3277
|
-
|
|
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
|
-
//
|
|
3399
|
-
//
|
|
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 + ')')
|
package/memory.schema.json
CHANGED
|
@@ -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.
|
|
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",
|