dsh-agent-persona 0.1.5 → 0.1.7

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
@@ -4,6 +4,49 @@ All notable changes to this project are documented here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.1.7] - 2026-10-23
8
+
9
+ ### Fixed
10
+
11
+ - **开关的 toast 只讲自己那件事。** 提示文案原来只对两个显示开关按 `patch` 取,其余一律按
12
+ `next.autosave` 计算,于是拨动「禁止模型改写人设文件」或「保存人设版本历史」时弹出来的是
13
+ 「编辑后自动保存」——一个这次根本没碰的开关的话。现在每个偏好各有一条按方向分岔的句子
14
+ (`PREF_STATUS`),键从 `patch` 上读,未知键退化成「已保存」。
15
+ - **同一句提示连拨两次也会再弹一次。** toast 原来用固定 `key`,第二次拨回同一状态时 React 复用
16
+ 同一个组件实例:文案一样、key 一样,定时器继续走,屏幕上看不到新提示。现在 `status` 每次写入都
17
+ 推进一个 `statusSeq`,toast 按它做 key,重复的句子也会重新挂载。这也是「开启没反应、关闭才弹」
18
+ 的成因之一:两个方向的文案本来就相同,第二次写入被吞掉了。
19
+ - 顺带把「关闭自动保存」的文案从「已关闭:编辑后自动保存」改成「已关闭:改完要自己点保存」。原文案
20
+ 在关闭状态下仍然宣称会自动保存。
21
+
22
+ ## [0.1.6] - 2026-10-22
23
+
24
+ ### Added
25
+
26
+ - **人设版本历史(可选开关)。** 设置页新增「保存人设版本历史」,默认关闭。打开后每次保存正文都会
27
+ 追加一条 diff 到 `personas.history.json`(与主存储同目录、0600):相等行只记行数,变更行原文保留,
28
+ 因此一条记录的成本约等于这次改动本身,而不是整份正文。
29
+ - 编辑器底部新增「版本历史」按钮:左侧列出版本(时间、`+a / −r`、首个改动行),右侧显示该版相对上一版的
30
+ 逐行 diff(红删绿增,未改动段折叠成行数)。「恢复到此版本」把选中版本写回当前人设,并且恢复本身也记成
31
+ 一条新记录,所以恢复错了还能再回滚。
32
+ - 新 RPC:`listVersions` / `versionDiff` / `restoreVersion`(远程产物由 `npm run generate:remote` 生成)。
33
+ - 链式存储:第一条版本作为 base 快照,之后每版只在上一版上重放 diff;超过 `MAX_VERSIONS`(200)时把最旧的
34
+ 几条折进 base,保证保留下来的版本仍可逐条重放。每条记录带内容 sha 摘要,历史被手改或损坏时重放会报错
35
+ 而不是静默给出别的正文。
36
+
37
+ ### Changed
38
+
39
+ - README 里的截图全部重作为动图(设置列表、卡片编辑、版本历史演示、会话页「人设」标签),
40
+ 都取自 0.1.6 的实机操作:版本历史那张演示「打开 → 选较早版本 → 恢复」的完整回路。
41
+ 同时去掉了与动图重复的静态 `versions.png`。
42
+ - 兼容性不变:仍按 0.1.5 的适配结论跑在 DSH `0.2.1-rc.2` 上(`dshReleases` 里的
43
+ `0.1.5-rc.1` / `0.1.5-rc.2` / `0.2.0-rc.2` / `0.2.1-rc.2` 标记不需要改动)。
44
+
45
+ ### Notes
46
+
47
+ - 历史只记正文,不记名称、启用状态与适用范围;关掉开关后不再写新记录,已有历史仍可查看与恢复。
48
+ - 版本历史是独立于守卫的开关:守卫管的是「工具不许改存储」,历史管的是「改了就留痕」,两者互不依赖。
49
+
7
50
  ## [0.1.5] - 2026-10-20
8
51
 
9
52
  ### Changed
package/README.md CHANGED
@@ -37,7 +37,7 @@
37
37
  | **会话视图** | 对话页顶部的 **人设** 标签 | 看这条会话命中了哪条人设、**实际发出去的 system prompt**(渲染/源码两视图),以及就地管理全部人设 |
38
38
  | **侧边栏卡片** | dsh-better-sidebar 的右侧栏 | 把上面那个「人设」页面挂进右侧栏(需要装了该插件) |
39
39
 
40
- 四个偏好开关(`编辑后自动保存` / `在对话页显示「人设」标签` / `注册到 dsh-better-sidebar` / `禁止模型改写人设文件`)
40
+ 五个偏好开关(`编辑后自动保存` / `在对话页显示「人设」标签` / `注册到 dsh-better-sidebar` / `禁止模型改写人设文件` / `保存人设版本历史`)
41
41
  都在**设置 → Agent 人设**页里,并且这个页面**不受这些开关影响** —— 关掉「对话页显示」或「注册到 dsh-better-sidebar」后,
42
42
  回到这里就能重新打开。本插件不在**设置 → 通用设置**里添加任何条目;鼠标停在开关上会给出每个开关的完整说明。
43
43
 
@@ -83,7 +83,7 @@ dsh plugin --profile web remove dsh-agent-persona # 卸载
83
83
 
84
84
  ## 用起来
85
85
 
86
- ![人设列表](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/settings-list.png)
86
+ ![人设列表](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/settings-list.gif)
87
87
 
88
88
  点 **新建人设** → 在「适用范围」里加行,选工作区或会话 → 写正文 → 打开启用开关 → 保存。
89
89
 
@@ -99,7 +99,21 @@ dsh plugin --profile web remove dsh-agent-persona # 卸载
99
99
 
100
100
  展开一张卡是这样:
101
101
 
102
- ![展开编辑](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/settings-editor.png)
102
+ ![展开编辑](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/settings-editor.gif)
103
+
104
+ ### 版本历史(可选)
105
+
106
+ 打开设置页的 **保存人设版本历史**,之后每次保存都会把这次的正文改动记成一条 diff(写在
107
+ `personas.history.json`)。编辑器底部的 **版本历史** 按钮打开对比窗口:
108
+
109
+ ![版本历史演示](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/versions.gif)
110
+
111
+ - **只记正文**:名称、启用状态、适用范围不参与版本,改这些不会产生一条记录。
112
+ - **每条都是 diff**:相等行只记行数,只有真正改动的行才存原文,所以一百条版本也远小于一百份正文。
113
+ - **恢复**:选中任意一条点「恢复到此版本」写回正文;恢复动作本身也记一条,恢复错了还能再回滚。
114
+ - **链式重放**:第一条版本作为基准快照,之后逐条重放;超过 200 条时把最旧的几条折进基准,
115
+ 保留下来的版本仍可逐条打开。每条记录带内容摘要,历史文件被手改坏时重放会报错,不会给出别的正文。
116
+ - 关掉开关只是停止记录,已有历史仍可查看与恢复。
103
117
 
104
118
  ### 看这条会话聊了什么
105
119
 
@@ -113,7 +127,7 @@ dsh plugin --profile web remove dsh-agent-persona # 卸载
113
127
 
114
128
  ### 对话页的「人设」标签
115
129
 
116
- ![会话视图](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/view-persona.png)
130
+ ![会话视图](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/view-persona.gif)
117
131
 
118
132
  选中一条会话后,对话页顶部有 **人设 / 提示词 / 管理** 三个面板:
119
133
 
@@ -124,7 +138,7 @@ dsh plugin --profile web remove dsh-agent-persona # 卸载
124
138
  没有命中任何人设时,这里显示的就是 DSH 原生注入的 prompt —— 不做任何特殊处理。
125
139
  - **管理**:与设置页同一套卡片(新建、启用/停用、编辑、排序、复制、删除),就地管理。
126
140
 
127
- ### 四个偏好
141
+ ### 五个偏好
128
142
 
129
143
  | 开关 | 默认 | 含义 | 生效时机 |
130
144
  |---|---|---|---|
package/README_EN.md CHANGED
@@ -95,7 +95,7 @@ overwritten — uninstalling only removes the plugin.
95
95
 
96
96
  ## Using it
97
97
 
98
- ![persona list](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/settings-list.png)
98
+ ![persona list](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/settings-list.gif)
99
99
 
100
100
  **New persona** → add rows under reach, pick a workspace or a session → write the body → switch it on → save.
101
101
 
@@ -109,7 +109,22 @@ overwritten — uninstalling only removes the plugin.
109
109
 
110
110
  An expanded card — name, state, the default switch, injection mode, the reach rules, the body and the footer:
111
111
 
112
- ![expanded card](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/settings-editor.png)
112
+ ![expanded card](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/settings-editor.gif)
113
+
114
+ ### Version history (opt-in)
115
+
116
+ Turn on **Save persona version history** in Settings and every save records the text change as a diff in
117
+ `personas.history.json`. The version-history button in the editor toolbar opens the comparison:
118
+
119
+ ![version history demo](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/versions.gif)
120
+
121
+ - **Text only**: name, enabled flag and targets are not versioned.
122
+ - **Diffs, not snapshots**: equal runs are stored as line counts; only changed lines keep their text.
123
+ - **Restore**: pick any version and restore it; the restore itself is recorded, so it can be undone too.
124
+ - **Chained replay**: the first text is the base snapshot, later versions replay on top of it; past 200
125
+ versions the oldest are folded into the base. A content hash per record makes a hand-edited history fail
126
+ loudly instead of restoring different text.
127
+ - Turning the switch off stops recording; existing history stays readable and restorable.
113
128
 
114
129
  ### Reading what a session actually said
115
130
 
@@ -125,7 +140,7 @@ replies on the left.
125
140
 
126
141
  ### The 人设 tab on a conversation
127
142
 
128
- ![session view](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/view-persona.png)
143
+ ![session view](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/view-persona.gif)
129
144
 
130
145
  **人设 / 提示词 / 管理**:
131
146
 
@@ -136,7 +151,7 @@ replies on the left.
136
151
  persona matched it shows DSH's own prompt verbatim.
137
152
  - **管理** — the same cards as the settings page, in place.
138
153
 
139
- ### The four preferences
154
+ ### The five preferences
140
155
 
141
156
  | Switch | Default | Meaning | Takes effect |
142
157
  |---|---|---|---|
@@ -144,6 +159,7 @@ replies on the left.
144
159
  | 在对话页显示「人设」标签 | on | Register the conversation view or not | Immediately (registered/unregistered at runtime) |
145
160
  | 注册到 dsh-better-sidebar | on | Register the card with the sidebar plugin | Immediately; **not shown at all when that plugin is absent** |
146
161
  | 禁止模型改写人设文件 | on | Refuse the model's file and shell tools any write to or removal of the persona store (reads still work); with it off the model can edit it directly | Immediately, on the next tool call |
162
+ | 保存人设版本历史 | off | Record every text change as a diff in `personas.history.json`, so any version can be compared and restored | Immediately, on the next save |
147
163
 
148
164
  Preferences live in the persona file's `prefs` field — not in the browser — so they share the data's scope. The
149
165
  browser keeps only a mirror, used to decide at load whether to register the views.
package/lib/client.js CHANGED
@@ -44,7 +44,10 @@
44
44
  method("sessionPrompt", withInput()),
45
45
  method("tunePersona", withInput()),
46
46
  method("listModels", []),
47
- method("savePrefs", withInput())
47
+ method("savePrefs", withInput()),
48
+ method("listVersions", withInput()),
49
+ method("versionDiff", withInput()),
50
+ method("restoreVersion", withInput())
48
51
  ]
49
52
  };
50
53
 
@@ -226,10 +229,21 @@
226
229
  return { ...DEFAULT_PREFS };
227
230
  }
228
231
  };
229
- const prefStatus = (next, patch) => "showTab" in patch || "showSidebar" in patch ? "\u5DF2\u4FDD\u5B58\uFF1A\u5DF2\u7ACB\u5373\u751F\u6548" : next.autosave === true ? "\u5DF2\u5F00\u542F\uFF1A\u7F16\u8F91\u540E\u505C\u6B62\u8F93\u5165\u7EA6 1 \u79D2\u5373\u81EA\u52A8\u4FDD\u5B58" : "\u5DF2\u5173\u95ED\uFF1A\u7F16\u8F91\u540E\u81EA\u52A8\u4FDD\u5B58";
232
+ const PREF_STATUS = {
233
+ autosave: (on) => on ? "\u5DF2\u5F00\u542F\uFF1A\u7F16\u8F91\u540E\u505C\u6B62\u8F93\u5165\u7EA6 1 \u79D2\u5373\u81EA\u52A8\u4FDD\u5B58" : "\u5DF2\u5173\u95ED\uFF1A\u6539\u5B8C\u8981\u81EA\u5DF1\u70B9\u4FDD\u5B58",
234
+ showTab: (on) => on ? "\u5DF2\u663E\u793A\u300C\u4EBA\u8BBE\u300D\u6807\u7B7E\uFF08\u5237\u65B0\u9875\u9762\u540E\u51FA\u73B0\uFF09" : "\u5DF2\u9690\u85CF\u300C\u4EBA\u8BBE\u300D\u6807\u7B7E\uFF08\u5237\u65B0\u9875\u9762\u540E\u751F\u6548\uFF09",
235
+ showSidebar: (on) => on ? "\u5DF2\u6CE8\u518C\u5230 dsh-better-sidebar" : "\u5DF2\u4ECE dsh-better-sidebar \u6CE8\u9500",
236
+ guard: (on) => on ? "\u5DF2\u5F00\u542F\uFF1A\u6A21\u578B\u7684\u5199\u5165\u4E0E\u547D\u4EE4\u5DE5\u5177\u4E0D\u80FD\u518D\u6539\u4EBA\u8BBE\u5B58\u50A8" : "\u5DF2\u5173\u95ED\uFF1A\u6A21\u578B\u53EF\u4EE5\u76F4\u63A5\u6539\u5199\u4EBA\u8BBE\u5B58\u50A8",
237
+ history: (on) => on ? "\u5DF2\u5F00\u542F\uFF1A\u6BCF\u6B21\u4FDD\u5B58\u90FD\u4F1A\u8BB0\u4E00\u6761\u7248\u672C\u5386\u53F2" : "\u5DF2\u5173\u95ED\uFF1A\u4E0D\u518D\u8BB0\u65B0\u7248\u672C\uFF0C\u5DF2\u6709\u5386\u53F2\u4ECD\u53EF\u67E5\u770B\u4E0E\u6062\u590D"
238
+ };
239
+ const prefStatus = (patch) => {
240
+ const key = Object.keys(patch ?? {}).find((name) => Object.prototype.hasOwnProperty.call(PREF_STATUS, name));
241
+ if (key === void 0) return "\u5DF2\u4FDD\u5B58";
242
+ return PREF_STATUS[key](patch[key] === true);
243
+ };
230
244
  const persistPrefs = (api, unwrap, current, patch, report) => {
231
245
  const next = writePrefs({ ...current, ...patch });
232
- report({ prefs: next, status: prefStatus(next, patch) });
246
+ report({ prefs: next, status: prefStatus(patch) });
233
247
  syncSidebarModule();
234
248
  if (api === void 0 || typeof api.savePrefs !== "function") return next;
235
249
  void api.savePrefs(next).then((result) => {
@@ -723,6 +737,22 @@
723
737
  @media (prefers-reduced-motion: reduce) {
724
738
  [data-plugin="dsh-agent-persona"] * { transition: none !important; }
725
739
  }
740
+
741
+ .wsp-versions { display: flex; flex-direction: column; gap: 12px; max-height: 58vh; }
742
+ .wsp-versions-list { display: flex; flex-direction: row; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
743
+ .wsp-version-row { display: flex; flex-direction: column; gap: 2px; text-align: left; min-width: 150px; max-width: 150px; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--wsp-line); background: transparent; color: inherit; cursor: pointer; font: inherit; }
744
+ .wsp-version-row:hover { border-color: var(--wsp-line-strong); }
745
+ .wsp-version-row.is-active { border-color: var(--dsw-alias-brand-primary, #4f8cff); background: var(--dsw-alias-bg-l2, rgba(79, 140, 255, 0.08)); }
746
+ .wsp-version-title { font-size: 13px; font-weight: 600; }
747
+ .wsp-version-meta { font-size: 11px; opacity: 0.72; font-variant-numeric: tabular-nums; }
748
+ .wsp-version-preview { font-size: 11px; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
749
+ .wsp-versions-diff { display: flex; flex-direction: column; gap: 8px; min-width: 0; overflow: hidden; }
750
+ .wsp-diff-head { font-size: 12px; opacity: 0.75; font-variant-numeric: tabular-nums; }
751
+ .wsp-diff-body { overflow: auto; border: 1px solid var(--wsp-line); border-radius: 8px; padding: 8px 0; background: var(--wsp-line-soft); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.55; }
752
+ .wsp-diff-line { padding: 0 10px; white-space: pre-wrap; word-break: break-word; }
753
+ .wsp-diff-add { background: rgba(46, 160, 67, 0.16); }
754
+ .wsp-diff-del { background: rgba(248, 81, 73, 0.14); }
755
+ .wsp-diff-skip { padding: 2px 10px; opacity: 0.5; font-size: 11px; }
726
756
  `;
727
757
  let styleElement;
728
758
  const injectStyles = () => {
@@ -1153,6 +1183,11 @@
1153
1183
  dropIndex: void 0,
1154
1184
  history: void 0,
1155
1185
  historyBusy: void 0,
1186
+ versions: void 0,
1187
+ versionsBusy: "",
1188
+ versionsError: "",
1189
+ versionDiff: void 0,
1190
+ versionIndex: void 0,
1156
1191
  models: [],
1157
1192
  model: "",
1158
1193
  tuneOpen: false,
@@ -1163,6 +1198,10 @@
1163
1198
  busy: "",
1164
1199
  error: "",
1165
1200
  status: "",
1201
+ // Bumped on every status write. The toast is keyed on it, so the same sentence twice in a
1202
+ // row (toggling one switch off and on again) remounts instead of silently extending the
1203
+ // toast that is already on screen.
1204
+ statusSeq: 0,
1166
1205
  transfer: void 0
1167
1206
  };
1168
1207
  this.mounted = false;
@@ -1171,8 +1210,15 @@
1171
1210
  this.importTextRef = React.createRef();
1172
1211
  this.importHighlightRef = React.createRef();
1173
1212
  this.importTimer = void 0;
1213
+ this.statusSeq = 0;
1174
1214
  this.onChange = (patch) => {
1175
- if (this.mounted) this.setState(patch);
1215
+ if (!this.mounted) return;
1216
+ if (patch !== null && typeof patch === "object" && "status" in patch) {
1217
+ this.statusSeq += 1;
1218
+ this.setState({ ...patch, statusSeq: this.statusSeq });
1219
+ return;
1220
+ }
1221
+ this.setState(patch);
1176
1222
  };
1177
1223
  this.onKeyDown = (event) => {
1178
1224
  if (!(event.metaKey || event.ctrlKey) || String(event.key).toLowerCase() !== "s") return;
@@ -1323,6 +1369,55 @@
1323
1369
  }
1324
1370
  return items;
1325
1371
  }
1372
+ /** Version history: list, diff, restore. Opt-in through the settings switch. */
1373
+ async openVersions(id) {
1374
+ const api = this.api();
1375
+ if (api === void 0 || typeof api.listVersions !== "function") {
1376
+ this.onChange({ versionsError: "\u8FD9\u4E2A\u7248\u672C\u8FD8\u6CA1\u6709\u7248\u672C\u5386\u53F2\u63A5\u53E3\uFF1A\u8BF7\u66F4\u65B0\u63D2\u4EF6\u540E\u91CD\u542F dsh web" });
1377
+ return;
1378
+ }
1379
+ this.onChange({ versionsBusy: "list", versionsError: "", versions: { id }, versionDiff: void 0, versionIndex: void 0 });
1380
+ try {
1381
+ const data = this.unwrap(await api.listVersions({ id }));
1382
+ this.onChange({ versionsBusy: "", versions: data });
1383
+ await this.readVersion((data?.versions?.length ?? 0) - 1);
1384
+ } catch (error) {
1385
+ this.onChange({ versionsBusy: "", versions: void 0, versionsError: String(error && error.message || error) });
1386
+ }
1387
+ }
1388
+ /** Load one version's diff; the modal swaps its right pane. */
1389
+ async readVersion(index) {
1390
+ const api = this.api();
1391
+ const versions = this.state.versions;
1392
+ if (api === void 0 || versions === void 0 || index < 0) return;
1393
+ this.onChange({ versionsBusy: "diff", versionIndex: index, versionsError: "" });
1394
+ try {
1395
+ const diff = this.unwrap(await api.versionDiff({ id: versions.id, index }));
1396
+ this.onChange({ versionsBusy: "", versionDiff: diff });
1397
+ } catch (error) {
1398
+ this.onChange({ versionsBusy: "", versionDiff: void 0, versionsError: String(error && error.message || error) });
1399
+ }
1400
+ }
1401
+ /** Put one version back as the persona text (itself recorded as a new version). */
1402
+ async restoreVersion(index) {
1403
+ const api = this.api();
1404
+ const versions = this.state.versions;
1405
+ if (api === void 0 || versions === void 0) return;
1406
+ this.onChange({ versionsBusy: "restore", versionsError: "" });
1407
+ try {
1408
+ const view = this.unwrap(await api.restoreVersion({ id: versions.id, index }));
1409
+ const found = (view?.personas ?? []).find((item) => item.id === versions.id);
1410
+ this.onChange({
1411
+ view,
1412
+ versionsBusy: "",
1413
+ status: view?.notice ?? "\u5DF2\u6062\u590D",
1414
+ draft: this.state.openId === versions.id && found !== void 0 ? this.draftFrom(found) : this.state.draft
1415
+ });
1416
+ await this.openVersions(versions.id);
1417
+ } catch (error) {
1418
+ this.onChange({ versionsBusy: "", versionsError: String(error && error.message || error) });
1419
+ }
1420
+ }
1326
1421
  /** Read a session's recent messages so the user can recognise it. */
1327
1422
  async openHistory(sessionId) {
1328
1423
  const api = this.api();
@@ -2267,6 +2362,14 @@ ${state.proposal.trim()}
2267
2362
  h("div", { className: "wsp-foot", key: "foot" }, [
2268
2363
  h("span", { className: "wsp-caption", key: "c" }, `${draft2.text.length} \u5B57 \xB7 \u2318/Ctrl+S \u4FDD\u5B58`),
2269
2364
  h("div", { className: "wsp-actions", key: "a" }, [
2365
+ this.state.prefs?.history === true ? h(Button, {
2366
+ key: "ver",
2367
+ variant: "ghost",
2368
+ size: "sm",
2369
+ disabled: busy,
2370
+ title: "\u67E5\u770B\u8FD9\u6761\u4EBA\u8BBE\u7684\u7248\u672C\u5386\u53F2",
2371
+ onClick: () => void this.openVersions(draft2.id)
2372
+ }, "\u7248\u672C\u5386\u53F2") : null,
2270
2373
  h(Button, { key: "dup", variant: "ghost", size: "sm", disabled: busy, onClick: () => void this.duplicate(draft2.id) }, "\u590D\u5236"),
2271
2374
  state.confirmDelete === draft2.id ? h(Button, { key: "del2", variant: "ghost", size: "sm", className: "wsp-icon-btn-danger", disabled: busy, onClick: () => void this.remove(draft2.id) }, "\u786E\u8BA4\u5220\u9664") : h(Button, {
2272
2375
  key: "del",
@@ -2391,7 +2494,10 @@ ${state.proposal.trim()}
2391
2494
  }
2392
2495
  }, [h(IconPlusOutline16, { key: "i" }), h("span", { key: "t" }, busy ? "\u6B63\u5728\u65B0\u5EFA\u2026" : "\u65B0\u5EFA\u4EBA\u8BBE")])];
2393
2496
  const status = state.error !== "" ? h("div", { className: "wsp-status wsp-status-err", key: "st", "aria-live": "polite" }, state.error) : state.status === "" ? null : h(Toast, {
2394
- key: "st",
2497
+ // Keyed on the sequence, not on a constant: a repeated sentence has to mount a fresh
2498
+ // toast. With a fixed key React reuses the instance, its dismiss timer keeps running,
2499
+ // and the second flip of a switch produced no toast at all.
2500
+ key: `st-${String(state.statusSeq)}`,
2395
2501
  text: state.status,
2396
2502
  icon: h(IconCheckOutline14, {}),
2397
2503
  onDone: () => this.onChange({ status: "" })
@@ -2501,6 +2607,13 @@ ${state.proposal.trim()}
2501
2607
  label: "\u7981\u6B62\u6A21\u578B\u6539\u5199\u4EBA\u8BBE\u6587\u4EF6",
2502
2608
  hint: "\u6253\u5F00\u540E\uFF0C\u6A21\u578B\u7684\u6587\u4EF6\u4E0E\u547D\u4EE4\u5DE5\u5177\u4E0D\u80FD\u5199\u5165\u6216\u5220\u9664\u4EBA\u8BBE\u5B58\u50A8\uFF08personas.json \u53CA\u5176\u5907\u4EFD\uFF09\uFF1B\u8BFB\u53D6\u4E0D\u53D7\u5F71\u54CD\u3002\u5173\u6389\u540E\u6A21\u578B\u5C31\u80FD\u76F4\u63A5\u6539\u5B83\u3002\u8FD9\u662F\u884C\u4E3A\u5C42\u9762\u7684\u9650\u5236\uFF0C\u4E0D\u662F\u5B89\u5168\u8FB9\u754C\uFF1A\u672C\u673A\u4E0A\u7684\u5176\u5B83\u7A0B\u5E8F\u4ECD\u53EF\u4FEE\u6539\u3002",
2503
2609
  onChange: (next) => this.setPref({ guard: next })
2610
+ }),
2611
+ h(Switch, {
2612
+ key: "history",
2613
+ checked: prefs.history === true,
2614
+ label: "\u4FDD\u5B58\u4EBA\u8BBE\u7248\u672C\u5386\u53F2",
2615
+ hint: "\u6253\u5F00\u540E\u6BCF\u6B21\u4FDD\u5B58\u90FD\u4F1A\u628A\u8FD9\u6B21\u7684\u6B63\u6587\u6539\u52A8\u8BB0\u6210\u4E00\u6761 diff\uFF08\u53E6\u5B58 personas.history.json\uFF09\uFF0C\u53EF\u4EE5\u5728\u7F16\u8F91\u5668\u7684\u300C\u7248\u672C\u5386\u53F2\u300D\u91CC\u5BF9\u6BD4\uFF0C\u5E76\u628A\u4EFB\u610F\u4E00\u6761\u6062\u590D\u6210\u5F53\u524D\u6B63\u6587\u3002\u53EA\u8BB0\u6B63\u6587\uFF0C\u4E0D\u8BB0\u540D\u79F0\u4E0E\u8303\u56F4\u3002",
2616
+ onChange: (next) => this.setPref({ history: next })
2504
2617
  })
2505
2618
  ]);
2506
2619
  const pendingDelete = state.confirmDelete === void 0 || state.confirmDelete === null ? void 0 : personas.find((item) => item.id === state.confirmDelete);
@@ -2515,6 +2628,69 @@ ${state.proposal.trim()}
2515
2628
  h(Button, { key: "ok", variant: "primary", size: "sm", disabled: busy, onClick: () => void this.remove(pendingDelete.id) }, "\u5220\u9664")
2516
2629
  ])
2517
2630
  }, h("div", { className: "wsp-card-note" }, `\u540D\u79F0\uFF1A${pendingDelete.name}\u3000\xB7\u3000${pendingDelete.chars} \u5B57\u3000\xB7\u3000${pendingDelete.targets?.length ?? 0} \u6761\u9002\u7528\u8303\u56F4`));
2631
+ const versionsState = state.versions;
2632
+ const diffState = state.versionDiff;
2633
+ const versionRows = versionsState === void 0 ? [] : [...versionsState.versions ?? []].reverse();
2634
+ const diffLines = [];
2635
+ if (diffState !== void 0) {
2636
+ for (const op of diffState.ops ?? []) {
2637
+ if (op.t === "=") {
2638
+ diffLines.push(h("div", { className: "wsp-diff-skip", key: `s${diffLines.length}` }, `\u22EF ${op.n} \u884C\u672A\u6539\u52A8`));
2639
+ continue;
2640
+ }
2641
+ for (const line of op.lines ?? []) {
2642
+ diffLines.push(h("div", {
2643
+ className: `wsp-diff-line ${op.t === "+" ? "wsp-diff-add" : "wsp-diff-del"}`,
2644
+ key: `d${diffLines.length}`
2645
+ }, `${op.t === "+" ? "+" : "\u2212"} ${line === "" ? " " : line}`));
2646
+ }
2647
+ }
2648
+ }
2649
+ const versionsModal = versionsState === void 0 && state.versionsError === "" ? null : h(Modal, {
2650
+ key: "versions",
2651
+ open: true,
2652
+ onClose: () => this.onChange({ versions: void 0, versionDiff: void 0, versionsError: "", versionIndex: void 0 }),
2653
+ title: `\u7248\u672C\u5386\u53F2${versionsState?.name === void 0 ? "" : ` \xB7 ${versionsState.name}`}`,
2654
+ description: "\u6BCF\u6B21\u4FDD\u5B58\u8BB0\u4E00\u6761\u6B63\u6587 diff\u3002\u6062\u590D\u4F1A\u628A\u9009\u4E2D\u7684\u7248\u672C\u5199\u56DE\u5F53\u524D\u4EBA\u8BBE\uFF0C\u5E76\u4F5C\u4E3A\u4E00\u6761\u65B0\u8BB0\u5F55\uFF0C\u6240\u4EE5\u6062\u590D\u672C\u8EAB\u4E5F\u80FD\u518D\u56DE\u6EDA\u3002",
2655
+ footer: h("div", { className: "wsp-form-actions" }, [
2656
+ h(Button, {
2657
+ key: "close",
2658
+ variant: "outline",
2659
+ size: "sm",
2660
+ onClick: () => this.onChange({ versions: void 0, versionDiff: void 0, versionsError: "", versionIndex: void 0 })
2661
+ }, "\u5173\u95ED"),
2662
+ h(Button, {
2663
+ key: "restore",
2664
+ variant: "primary",
2665
+ size: "sm",
2666
+ disabled: state.versionsBusy !== "" || state.versionIndex === void 0 || diffState === void 0,
2667
+ onClick: () => void this.restoreVersion(state.versionIndex)
2668
+ }, state.versionsBusy === "restore" ? "\u6062\u590D\u4E2D\u2026" : "\u6062\u590D\u5230\u6B64\u7248\u672C")
2669
+ ])
2670
+ }, h("div", { className: "wsp-versions" }, [
2671
+ h("div", { className: "wsp-versions-list", key: "l" }, versionRows.length === 0 ? h("div", { className: "wsp-card-note" }, state.versionsError !== "" ? state.versionsError : "\u8FD8\u6CA1\u6709\u5386\u53F2\u7248\u672C\uFF1A\u5148\u5728\u5F00\u5173\u91CC\u6253\u5F00\u300C\u4FDD\u5B58\u4EBA\u8BBE\u7248\u672C\u5386\u53F2\u300D\uFF0C\u7136\u540E\u4FDD\u5B58\u4E00\u6B21\u6B63\u6587\u3002") : versionRows.map((version) => h("button", {
2672
+ type: "button",
2673
+ key: version.index,
2674
+ className: `wsp-version-row${state.versionIndex === version.index ? " is-active" : ""}`,
2675
+ onClick: () => void this.readVersion(version.index)
2676
+ }, [
2677
+ h("span", { className: "wsp-version-title", key: "t" }, `\u7B2C ${version.index + 1} \u7248`),
2678
+ h(
2679
+ "span",
2680
+ { className: "wsp-version-meta", key: "m" },
2681
+ `${String(version.at).replace("T", " ").slice(5, 19)}\u3000+${version.added} / \u2212${version.removed}`
2682
+ ),
2683
+ h("span", { className: "wsp-version-preview", key: "p" }, version.preview || "\uFF08\u65E0\u53EF\u663E\u793A\u7684\u6539\u52A8\u884C\uFF09")
2684
+ ]))),
2685
+ h("div", { className: "wsp-versions-diff", key: "d" }, diffState === void 0 ? h("div", { className: "wsp-card-note" }, "\u5DE6\u4FA7\u9009\u4E00\u4E2A\u7248\u672C\uFF0C\u8FD9\u91CC\u663E\u793A\u5B83\u76F8\u5BF9\u4E0A\u4E00\u7248\u7684\u6539\u52A8\u3002") : [
2686
+ h(
2687
+ "div",
2688
+ { className: "wsp-diff-head", key: "h" },
2689
+ `${String(diffState.at).replace("T", " ").slice(0, 19)}\u3000\xB7\u3000${diffState.action === "restore" ? "\u6062\u590D" : diffState.action === "create" ? "\u65B0\u5EFA" : "\u4FEE\u6539"}\u3000\xB7\u3000+${diffState.added} / \u2212${diffState.removed}`
2690
+ ),
2691
+ h("div", { className: "wsp-diff-body", key: "b" }, diffLines)
2692
+ ])
2693
+ ]));
2518
2694
  const health = view?.storeHealth;
2519
2695
  const healthNote = health === void 0 || health.status === "ok" ? null : h("div", {
2520
2696
  className: `wsp-alert wsp-alert-${health.status}`,
@@ -2621,7 +2797,7 @@ ${state.proposal.trim()}
2621
2797
  ])
2622
2798
  ])
2623
2799
  ]));
2624
- return h("div", rootProps, [header, healthNote, prefsRow, transferRow, searchRow, hiddenNote, conflictNotice, h("div", { className: "wsp-cards", key: "cards" }, cards), status, historyModal, confirmModal, importModal]);
2800
+ return h("div", rootProps, [header, healthNote, prefsRow, transferRow, searchRow, hiddenNote, conflictNotice, h("div", { className: "wsp-cards", key: "cards" }, cards), status, historyModal, confirmModal, versionsModal, importModal]);
2625
2801
  }
2626
2802
  }
2627
2803
  const inject = ["slots", "remote", "connection"];
@@ -11,6 +11,6 @@ export declare const RPC_PACKAGE = "dsh-agent-persona";
11
11
  */
12
12
  export declare const RPC_SERVICE = "agentPersona";
13
13
  export declare const RPC_CHANNEL = "/agent-persona";
14
- export declare const RPC_ENDPOINTS: readonly ['listPersonas', 'savePersona', 'deletePersona', 'movePersona', 'duplicatePersona', 'reorderPersona', 'listTargets', 'sessionHistory', 'sessionPrompt', 'tunePersona', 'listModels', 'savePrefs'];
14
+ export declare const RPC_ENDPOINTS: readonly ['listPersonas', 'savePersona', 'deletePersona', 'movePersona', 'duplicatePersona', 'reorderPersona', 'listTargets', 'sessionHistory', 'sessionPrompt', 'tunePersona', 'listModels', 'savePrefs', 'listVersions', 'versionDiff', 'restoreVersion'];
15
15
  /** The three reads that take no argument. Everything else receives one JSON object. */
16
16
  export declare const RPC_NO_INPUT: readonly ['listPersonas', 'listTargets', 'listModels'];
package/lib/endpoints.js CHANGED
@@ -24,6 +24,9 @@ export const RPC_ENDPOINTS = [
24
24
  'tunePersona',
25
25
  'listModels',
26
26
  'savePrefs',
27
+ 'listVersions',
28
+ 'versionDiff',
29
+ 'restoreVersion',
27
30
  ];
28
31
  /** The three reads that take no argument. Everything else receives one JSON object. */
29
32
  export const RPC_NO_INPUT = ['listPersonas', 'listTargets', 'listModels'];
package/lib/index.d.ts CHANGED
@@ -89,6 +89,8 @@ export declare const normalizePrefs: (raw: any) => {
89
89
  showTab: boolean;
90
90
  showSidebar: boolean;
91
91
  guard: boolean;
92
+ /** Opt-in version history: off by default, because it writes a second file on every save. */
93
+ history: boolean;
92
94
  };
93
95
  /** Filename-safe local timestamp for a store backup. */
94
96
  export declare const backupStamp: (now?: Date) => string;
@@ -108,6 +110,45 @@ export declare const backupStoreFile: (storePath: any, stamp: any) => string;
108
110
  */
109
111
  export declare const personaStoreGuardReason: (execution: any, storePath: any) => string;
110
112
  export declare const sanitizePersona: (text: any, warn?: () => void) => string;
113
+ /**
114
+ * Opt-in per-persona version history.
115
+ *
116
+ * Every save stores the diff against the previous text (equal runs as counts, changed lines verbatim),
117
+ * so one record costs about as much as the edit rather than the whole document. The first version of a
118
+ * persona is kept as a base snapshot; every later version is reconstructed by replaying the diffs from
119
+ * that base. A hash per entry makes a corrupted or hand-edited history fail loudly instead of restoring
120
+ * the wrong text.
121
+ */
122
+ export declare const HISTORY_VERSION = 1;
123
+ /** Versions kept per persona; the oldest are folded into the base snapshot when the cap is hit. */
124
+ export declare const MAX_VERSIONS = 200;
125
+ export declare const historyPathFor: (storePath: any) => any;
126
+ /**
127
+ * Line diff between two texts, as a sequence of equal/removed/added runs.
128
+ *
129
+ * Longest-common-subsequence over lines, with the shared prefix and suffix stripped first. That keeps the
130
+ * usual "edit one paragraph" case cheap; a pathological rewrite falls back to one remove+add pair, which
131
+ * is still a correct (if coarse) diff.
132
+ */
133
+ export declare const diffLines: (beforeText: any, afterText: any) => any[];
134
+ /**
135
+ * Replay a diff onto a text. Equal runs count lines from the current text (their content is not stored, which
136
+ * is what keeps a record small); removals are checked against it so a mismatched history throws instead of
137
+ * silently producing a different document.
138
+ */
139
+ export declare const applyDiff: (text: any, ops: any) => string;
140
+ /** `+n / -m` summary used by the list row. */
141
+ export declare const diffStats: (ops: any) => {
142
+ added: number;
143
+ removed: number;
144
+ };
145
+ /** Current text of one stored version (index 0 is the oldest kept version). */
146
+ export declare const versionTextAt: (entryState: any, index: any) => any;
147
+ /**
148
+ * Record one new version. `before` is the text the persona had, `after` the text it now has.
149
+ * Returns false when the history is off or nothing changed.
150
+ */
151
+ export declare const recordVersion: (history: any, personaId: any, before: any, after: any, action: any, now?: string) => boolean;
111
152
  /**
112
153
  * Does one target claim this subject?
113
154
  * @param target - normalized target record.
package/lib/index.js CHANGED
@@ -50,6 +50,7 @@ import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
50
50
  import Schema from '@deepseek-ai/schemastery';
51
51
  import { RPC_ENDPOINTS } from './endpoints.js';
52
52
  import { chmodSync, copyFileSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
53
+ import { createHash } from 'node:crypto';
53
54
  import { readFile } from 'node:fs/promises';
54
55
  import { homedir } from 'node:os';
55
56
  import { basename, dirname, join, resolve } from 'node:path';
@@ -171,6 +172,8 @@ export const normalizePrefs = (raw) => ({
171
172
  showTab: raw?.showTab !== false,
172
173
  showSidebar: raw?.showSidebar !== false,
173
174
  guard: raw?.guard !== false,
175
+ /** Opt-in version history: off by default, because it writes a second file on every save. */
176
+ history: raw?.history === true,
174
177
  });
175
178
  /**
176
179
  * Read the store. A missing file is a normal first run and stays silent; a file that exists but cannot be
@@ -300,6 +303,236 @@ export const sanitizePersona = (text, warn = () => { }) => {
300
303
  warn(`persona text references unsupported prompt variables (${[...new Set(dropped)].join(', ')}); rendered as plain text`);
301
304
  return out;
302
305
  };
306
+ // ─────────────────────────────────────────────────────────── version history
307
+ /**
308
+ * Opt-in per-persona version history.
309
+ *
310
+ * Every save stores the diff against the previous text (equal runs as counts, changed lines verbatim),
311
+ * so one record costs about as much as the edit rather than the whole document. The first version of a
312
+ * persona is kept as a base snapshot; every later version is reconstructed by replaying the diffs from
313
+ * that base. A hash per entry makes a corrupted or hand-edited history fail loudly instead of restoring
314
+ * the wrong text.
315
+ */
316
+ export const HISTORY_VERSION = 1;
317
+ /** Versions kept per persona; the oldest are folded into the base snapshot when the cap is hit. */
318
+ export const MAX_VERSIONS = 200;
319
+ export const historyPathFor = (storePath) => join(dirname(storePath), 'personas.history.json');
320
+ const splitLines = (text) => String(text ?? '').split('\n');
321
+ /**
322
+ * Line diff between two texts, as a sequence of equal/removed/added runs.
323
+ *
324
+ * Longest-common-subsequence over lines, with the shared prefix and suffix stripped first. That keeps the
325
+ * usual "edit one paragraph" case cheap; a pathological rewrite falls back to one remove+add pair, which
326
+ * is still a correct (if coarse) diff.
327
+ */
328
+ export const diffLines = (beforeText, afterText) => {
329
+ const a = splitLines(beforeText);
330
+ const b = splitLines(afterText);
331
+ let head = 0;
332
+ while (head < a.length && head < b.length && a[head] === b[head])
333
+ head += 1;
334
+ let tail = 0;
335
+ while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail])
336
+ tail += 1;
337
+ const midA = a.slice(head, a.length - tail);
338
+ const midB = b.slice(head, b.length - tail);
339
+ const ops = [];
340
+ const pushEqual = (n) => {
341
+ if (n <= 0)
342
+ return;
343
+ const last = ops[ops.length - 1];
344
+ if (last?.t === '=')
345
+ last.n += n;
346
+ else
347
+ ops.push({ t: '=', n });
348
+ };
349
+ pushEqual(head);
350
+ const n = midA.length;
351
+ const m = midB.length;
352
+ if (n > 0 && m > 0 && n * m <= 4_000_000) {
353
+ // DP table over the changed middle only.
354
+ const width = m + 1;
355
+ const table = new Uint32Array((n + 1) * width);
356
+ for (let i = n - 1; i >= 0; i -= 1) {
357
+ for (let j = m - 1; j >= 0; j -= 1) {
358
+ table[i * width + j] = midA[i] === midB[j]
359
+ ? table[(i + 1) * width + j + 1] + 1
360
+ : Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
361
+ }
362
+ }
363
+ let i = 0;
364
+ let j = 0;
365
+ let equal = 0;
366
+ const flushEqual = () => {
367
+ pushEqual(equal);
368
+ equal = 0;
369
+ };
370
+ while (i < n && j < m) {
371
+ if (midA[i] === midB[j]) {
372
+ equal += 1;
373
+ i += 1;
374
+ j += 1;
375
+ continue;
376
+ }
377
+ flushEqual();
378
+ if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
379
+ const last = ops[ops.length - 1];
380
+ if (last?.t === '-')
381
+ last.lines.push(midA[i]);
382
+ else
383
+ ops.push({ t: '-', lines: [midA[i]] });
384
+ i += 1;
385
+ }
386
+ else {
387
+ const last = ops[ops.length - 1];
388
+ if (last?.t === '+')
389
+ last.lines.push(midB[j]);
390
+ else
391
+ ops.push({ t: '+', lines: [midB[j]] });
392
+ j += 1;
393
+ }
394
+ }
395
+ flushEqual();
396
+ if (i < n)
397
+ ops.push({ t: '-', lines: midA.slice(i) });
398
+ if (j < m)
399
+ ops.push({ t: '+', lines: midB.slice(j) });
400
+ }
401
+ else if (n > 0 || m > 0) {
402
+ if (n > 0)
403
+ ops.push({ t: '-', lines: midA.slice() });
404
+ if (m > 0)
405
+ ops.push({ t: '+', lines: midB.slice() });
406
+ }
407
+ pushEqual(tail);
408
+ return ops;
409
+ };
410
+ /**
411
+ * Replay a diff onto a text. Equal runs count lines from the current text (their content is not stored, which
412
+ * is what keeps a record small); removals are checked against it so a mismatched history throws instead of
413
+ * silently producing a different document.
414
+ */
415
+ export const applyDiff = (text, ops) => {
416
+ const lines = splitLines(text);
417
+ const out = [];
418
+ let at = 0;
419
+ for (const op of Array.isArray(ops) ? ops : []) {
420
+ if (op?.t === '=') {
421
+ const n = Math.max(0, Number(op.n) || 0);
422
+ for (let k = 0; k < n; k += 1) {
423
+ if (at >= lines.length)
424
+ throw new Error('版本历史损坏:相等的行超出当前文本');
425
+ out.push(lines[at]);
426
+ at += 1;
427
+ }
428
+ }
429
+ else if (op?.t === '-') {
430
+ const gone = Array.isArray(op.lines) ? op.lines : [];
431
+ for (const line of gone) {
432
+ if (lines[at] !== line)
433
+ throw new Error('版本历史损坏:删除的行与当前文本不一致');
434
+ at += 1;
435
+ }
436
+ }
437
+ else if (op?.t === '+') {
438
+ for (const line of Array.isArray(op.lines) ? op.lines : [])
439
+ out.push(line);
440
+ }
441
+ }
442
+ while (at < lines.length) {
443
+ out.push(lines[at]);
444
+ at += 1;
445
+ }
446
+ return out.join('\n');
447
+ };
448
+ /** `+n / -m` summary used by the list row. */
449
+ export const diffStats = (ops) => {
450
+ let added = 0;
451
+ let removed = 0;
452
+ for (const op of Array.isArray(ops) ? ops : []) {
453
+ if (op?.t === '+')
454
+ added += op.lines.length;
455
+ if (op?.t === '-')
456
+ removed += op.lines.length;
457
+ }
458
+ return { added, removed };
459
+ };
460
+ const readHistory = (storePath, warn) => {
461
+ const file = historyPathFor(storePath);
462
+ try {
463
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
464
+ if (parsed && typeof parsed === 'object' && parsed.personas && typeof parsed.personas === 'object') {
465
+ return { version: HISTORY_VERSION, personas: parsed.personas };
466
+ }
467
+ }
468
+ catch (error) {
469
+ if (error?.code !== 'ENOENT')
470
+ warn(`could not read the version history; starting a fresh one`, error);
471
+ }
472
+ return { version: HISTORY_VERSION, personas: {} };
473
+ };
474
+ const persistFile = (file, text, mode) => {
475
+ mkdirSync(dirname(file), { recursive: true });
476
+ const tmp = `${file}.tmp-${process.pid}`;
477
+ writeFileSync(tmp, text, { mode });
478
+ renameSync(tmp, file);
479
+ };
480
+ const writeHistory = (storePath, history, warn) => {
481
+ const file = historyPathFor(storePath);
482
+ try {
483
+ mkdirSync(dirname(file), { recursive: true });
484
+ persistFile(file, `${JSON.stringify(history)}\n`, 0o600);
485
+ }
486
+ catch (error) {
487
+ warn(`could not write the version history`, error);
488
+ }
489
+ };
490
+ /** Current text of one stored version (index 0 is the oldest kept version). */
491
+ export const versionTextAt = (entryState, index) => {
492
+ const state = entryState;
493
+ if (!state || typeof state.base !== 'string')
494
+ throw new Error('这条人设还没有版本历史');
495
+ const versions = Array.isArray(state.versions) ? state.versions : [];
496
+ if (!Number.isInteger(index) || index < 0 || index >= versions.length)
497
+ throw new Error('版本不存在');
498
+ let text = state.base;
499
+ for (let k = 0; k <= index; k += 1)
500
+ text = applyDiff(text, versions[k].ops);
501
+ return text;
502
+ };
503
+ /**
504
+ * Record one new version. `before` is the text the persona had, `after` the text it now has.
505
+ * Returns false when the history is off or nothing changed.
506
+ */
507
+ export const recordVersion = (history, personaId, before, after, action, now = new Date().toISOString()) => {
508
+ const beforeText = String(before ?? '');
509
+ const afterText = String(after ?? '');
510
+ if (beforeText === afterText)
511
+ return false;
512
+ const ops = diffLines(beforeText, afterText);
513
+ const stats = diffStats(ops);
514
+ let state = history.personas[personaId];
515
+ if (state === undefined || typeof state.base !== 'string') {
516
+ state = { base: beforeText, baseAt: now, versions: [] };
517
+ history.personas[personaId] = state;
518
+ }
519
+ state.versions.push({
520
+ at: now,
521
+ action,
522
+ ops,
523
+ added: stats.added,
524
+ removed: stats.removed,
525
+ hash: createHash('sha256').update(afterText, 'utf8').digest('hex').slice(0, 16),
526
+ size: Buffer.byteLength(afterText, 'utf8'),
527
+ });
528
+ // Fold the oldest versions into the base once the cap is hit, so every kept version stays replayable.
529
+ while (state.versions.length > MAX_VERSIONS) {
530
+ const [oldest] = state.versions.splice(0, 1);
531
+ state.base = applyDiff(state.base, oldest.ops);
532
+ state.baseAt = oldest.at;
533
+ }
534
+ return true;
535
+ };
303
536
  // ─────────────────────────────────────────────────────────── matching
304
537
  const regexCache = new Map();
305
538
  const compile = (value) => {
@@ -1073,6 +1306,33 @@ class WorkspacePersonaService extends TypertRemoteService {
1073
1306
  return { error: `savePersona failed: ${String((error && error.stack) || error)}` };
1074
1307
  }
1075
1308
  }
1309
+ /** Version list for one persona (opt-in history). */
1310
+ async listVersions(input) {
1311
+ try {
1312
+ return this.runtime.listVersions(input);
1313
+ }
1314
+ catch (error) {
1315
+ return { error: `listVersions failed: ${String((error && error.stack) || error)}` };
1316
+ }
1317
+ }
1318
+ /** One stored version with its diff. */
1319
+ async versionDiff(input) {
1320
+ try {
1321
+ return this.runtime.versionDiff(input);
1322
+ }
1323
+ catch (error) {
1324
+ return { error: `versionDiff failed: ${String((error && error.stack) || error)}` };
1325
+ }
1326
+ }
1327
+ /** Restore a stored version as the current persona text. */
1328
+ async restoreVersion(input) {
1329
+ try {
1330
+ return this.runtime.restoreVersion(input);
1331
+ }
1332
+ catch (error) {
1333
+ return { error: `restoreVersion failed: ${String((error && error.stack) || error)}` };
1334
+ }
1335
+ }
1076
1336
  /** Remove one persona. */
1077
1337
  async deletePersona(input) {
1078
1338
  try {
@@ -1401,8 +1661,90 @@ export function apply(ctx, config = {}) {
1401
1661
  targets,
1402
1662
  };
1403
1663
  };
1664
+ /** Read + write the history file around one mutation; a disabled switch costs nothing. */
1665
+ const historyEnabled = () => currentStore().prefs?.history === true;
1666
+ const recordIfEnabled = (personaId, before, after, action) => {
1667
+ if (!historyEnabled() || personaId === undefined)
1668
+ return;
1669
+ const history = readHistory(storePath, warn);
1670
+ if (!recordVersion(history, personaId, before, after, action))
1671
+ return;
1672
+ writeHistory(storePath, history, warn);
1673
+ };
1404
1674
  const runtime = {
1405
1675
  view,
1676
+ /** Version list for one persona (newest first for display, indices stay oldest-first). */
1677
+ listVersions(input) {
1678
+ const id = typeof input?.id === 'string' ? input.id : '';
1679
+ const persona = currentStore().personas.find((item) => item.id === id);
1680
+ if (persona === undefined)
1681
+ throw new Error('人设不存在(可能已被删除)');
1682
+ const history = readHistory(storePath, warn);
1683
+ const state = history.personas[id];
1684
+ const versions = Array.isArray(state?.versions) ? state.versions : [];
1685
+ return {
1686
+ id,
1687
+ name: persona.name,
1688
+ enabled: historyEnabled(),
1689
+ baseAt: state?.baseAt ?? '',
1690
+ max: MAX_VERSIONS,
1691
+ current: persona.text,
1692
+ versions: versions.map((version, index) => ({
1693
+ index,
1694
+ at: version.at,
1695
+ action: version.action,
1696
+ added: version.added,
1697
+ removed: version.removed,
1698
+ size: version.size,
1699
+ preview: (version.ops ?? []).find((op) => op.t === '+')?.lines?.[0] ?? '',
1700
+ })),
1701
+ };
1702
+ },
1703
+ /** One version with the text it produced and the diff against the version before it. */
1704
+ versionDiff(input) {
1705
+ const id = typeof input?.id === 'string' ? input.id : '';
1706
+ const persona = currentStore().personas.find((item) => item.id === id);
1707
+ if (persona === undefined)
1708
+ throw new Error('人设不存在(可能已被删除)');
1709
+ const history = readHistory(storePath, warn);
1710
+ const state = history.personas[id];
1711
+ const index = Number(input?.index);
1712
+ const version = state?.versions?.[index];
1713
+ if (version === undefined)
1714
+ throw new Error('版本不存在');
1715
+ const after = versionTextAt(state, index);
1716
+ const before = index === 0 ? state.base : versionTextAt(state, index - 1);
1717
+ return {
1718
+ id,
1719
+ index,
1720
+ at: version.at,
1721
+ action: version.action,
1722
+ added: version.added,
1723
+ removed: version.removed,
1724
+ ops: version.ops,
1725
+ before,
1726
+ after,
1727
+ current: persona.text,
1728
+ };
1729
+ },
1730
+ /** Put a stored version back as the persona text; the restore itself is recorded as a new version. */
1731
+ restoreVersion(input) {
1732
+ const current = reload();
1733
+ const id = typeof input?.id === 'string' ? input.id : '';
1734
+ const persona = current.personas.find((item) => item.id === id);
1735
+ if (persona === undefined)
1736
+ throw new Error('人设不存在(可能已被删除)');
1737
+ const history = readHistory(storePath, warn);
1738
+ const text = versionTextAt(history.personas[id], Number(input?.index));
1739
+ const before = persona.text;
1740
+ persona.text = text;
1741
+ commit(current);
1742
+ recordIfEnabled(id, before, text, 'restore');
1743
+ info(`restored persona "${persona.name}" to version ${String(input?.index)}`);
1744
+ const result = view();
1745
+ result.notice = `已恢复到第 ${Number(input?.index) + 1} 个版本`;
1746
+ return result;
1747
+ },
1406
1748
  /** Merge preferences into the store; every field is optional so one switch can be saved. */
1407
1749
  savePrefs(input) {
1408
1750
  const current = reload();
@@ -1414,6 +1756,8 @@ export function apply(ctx, config = {}) {
1414
1756
  save(input) {
1415
1757
  const next = normalizeInput(input);
1416
1758
  const current = reload();
1759
+ const beforeText = next.id === undefined ? '' : (current.personas.find((item) => item.id === next.id)?.text ?? '');
1760
+ const created = next.id === undefined;
1417
1761
  let persona;
1418
1762
  if (next.id === undefined) {
1419
1763
  persona = { id: newId(), name: next.name, enabled: next.enabled, fallback: next.fallback, text: next.text, mode: next.mode, targets: next.targets };
@@ -1447,6 +1791,8 @@ export function apply(ctx, config = {}) {
1447
1791
  }
1448
1792
  }
1449
1793
  commit(current);
1794
+ if (next.text !== beforeText)
1795
+ recordIfEnabled(persona.id, beforeText, next.text, created ? 'create' : 'update');
1450
1796
  const result = view();
1451
1797
  if (released.length > 0) {
1452
1798
  result.notice = `「${persona.name}」现在是默认人设;「${released.join('」「')}」已取消默认`;
package/lib/remote.js CHANGED
@@ -51,5 +51,8 @@ export const RPC_REMOTE = {
51
51
  method("tunePersona", withInput()),
52
52
  method("listModels", []),
53
53
  method("savePrefs", withInput()),
54
+ method("listVersions", withInput()),
55
+ method("versionDiff", withInput()),
56
+ method("restoreVersion", withInput()),
54
57
  ],
55
58
  };
package/lib/typert.js CHANGED
@@ -40,6 +40,9 @@ export const TYPERT = {
40
40
  { name: "tunePersona", kind: "method", signature: "(input: unknown): Promise<unknown>" },
41
41
  { name: "listModels", kind: "method", signature: "(): Promise<unknown>" },
42
42
  { name: "savePrefs", kind: "method", signature: "(input: unknown): Promise<unknown>" },
43
+ { name: "listVersions", kind: "method", signature: "(input: unknown): Promise<unknown>" },
44
+ { name: "versionDiff", kind: "method", signature: "(input: unknown): Promise<unknown>" },
45
+ { name: "restoreVersion", kind: "method", signature: "(input: unknown): Promise<unknown>" },
43
46
  ],
44
47
  types: [],
45
48
  },
@@ -219,5 +222,53 @@ export const TYPERT = {
219
222
  ],
220
223
  result: result("Json"),
221
224
  },
225
+ {
226
+ id: `dsh-agent-persona#agentPersona/listVersions`,
227
+ service: "agentPersona",
228
+ namespace: "agentPersona",
229
+ method: "listVersions",
230
+ invocation: direct,
231
+ parameters: [
232
+ {
233
+ name: "input",
234
+ wire: "input",
235
+ source: "json",
236
+ codec: jsonCodec("Json"),
237
+ },
238
+ ],
239
+ result: result("Json"),
240
+ },
241
+ {
242
+ id: `dsh-agent-persona#agentPersona/versionDiff`,
243
+ service: "agentPersona",
244
+ namespace: "agentPersona",
245
+ method: "versionDiff",
246
+ invocation: direct,
247
+ parameters: [
248
+ {
249
+ name: "input",
250
+ wire: "input",
251
+ source: "json",
252
+ codec: jsonCodec("Json"),
253
+ },
254
+ ],
255
+ result: result("Json"),
256
+ },
257
+ {
258
+ id: `dsh-agent-persona#agentPersona/restoreVersion`,
259
+ service: "agentPersona",
260
+ namespace: "agentPersona",
261
+ method: "restoreVersion",
262
+ invocation: direct,
263
+ parameters: [
264
+ {
265
+ name: "input",
266
+ wire: "input",
267
+ source: "json",
268
+ codec: jsonCodec("Json"),
269
+ },
270
+ ],
271
+ result: result("Json"),
272
+ },
222
273
  ],
223
274
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-agent-persona",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Workspace personas for DeepSeek Harness (DSH): create many system-prompt personas and scope each one by workspace path or session id (exact / prefix / regex / contains), managed from a Web settings page with Markdown editing and AI tuning.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",