dsh-agent-persona 0.1.4 → 0.1.6

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,57 @@ 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.6] - 2026-10-22
8
+
9
+ ### Added
10
+
11
+ - **人设版本历史(可选开关)。** 设置页新增「保存人设版本历史」,默认关闭。打开后每次保存正文都会
12
+ 追加一条 diff 到 `personas.history.json`(与主存储同目录、0600):相等行只记行数,变更行原文保留,
13
+ 因此一条记录的成本约等于这次改动本身,而不是整份正文。
14
+ - 编辑器底部新增「版本历史」按钮:左侧列出版本(时间、`+a / −r`、首个改动行),右侧显示该版相对上一版的
15
+ 逐行 diff(红删绿增,未改动段折叠成行数)。「恢复到此版本」把选中版本写回当前人设,并且恢复本身也记成
16
+ 一条新记录,所以恢复错了还能再回滚。
17
+ - 新 RPC:`listVersions` / `versionDiff` / `restoreVersion`(远程产物由 `npm run generate:remote` 生成)。
18
+ - 链式存储:第一条版本作为 base 快照,之后每版只在上一版上重放 diff;超过 `MAX_VERSIONS`(200)时把最旧的
19
+ 几条折进 base,保证保留下来的版本仍可逐条重放。每条记录带内容 sha 摘要,历史被手改或损坏时重放会报错
20
+ 而不是静默给出别的正文。
21
+
22
+ ### Changed
23
+
24
+ - README 里的截图全部重作为动图(设置列表、卡片编辑、版本历史演示、会话页「人设」标签),
25
+ 都取自 0.1.6 的实机操作:版本历史那张演示「打开 → 选较早版本 → 恢复」的完整回路。
26
+ 同时去掉了与动图重复的静态 `versions.png`。
27
+ - 兼容性不变:仍按 0.1.5 的适配结论跑在 DSH `0.2.1-rc.2` 上(`dshReleases` 里的
28
+ `0.1.5-rc.1` / `0.1.5-rc.2` / `0.2.0-rc.2` / `0.2.1-rc.2` 标记不需要改动)。
29
+
30
+ ### Notes
31
+
32
+ - 历史只记正文,不记名称、启用状态与适用范围;关掉开关后不再写新记录,已有历史仍可查看与恢复。
33
+ - 版本历史是独立于守卫的开关:守卫管的是「工具不许改存储」,历史管的是「改了就留痕」,两者互不依赖。
34
+
35
+ ## [0.1.5] - 2026-10-20
36
+
37
+ ### Changed
38
+
39
+ - **适配最新版 DSH `0.2.1-rc.2`。** 在 0.2.1-rc.2 上实测通过:设置页的人设列表、新建、保存
40
+ (`listPersonas` / `savePersona`,正文按 `MAX_TEXT_BYTES` 的 64 KiB 上限测了 6 KB 长文)与
41
+ system prompt 注入都正常;0.1.4 为 0.2.0-rc.2 补的 strict codec 契约(`create`/`decode`/`encode`)
42
+ 与图标命名在 0.2.1-rc.2 上没有变化,所以那次适配继续有效。
43
+ `dsh.compatibility.dshReleases` 补上 `0.2.1-rc.2`(与 `0.1.5-rc.1`、`0.1.5-rc.2`、`0.2.0-rc.2`
44
+ 并列 `compatible`);宿主代码并不读取这个字段,它服务于插件市场/商店的版本提示,补上是为了让
45
+ "跑过哪些版本"与事实一致。
46
+ - 宿主对**每一个**工具调用都会跑 `tools.guard`(0.2.1-rc.2 上实测 `compress` 也被拦),于是下面那条
47
+ 守卫的误判从"偶尔"变成"常态"。
48
+
49
+ ### Fixed
50
+
51
+ - **只读工具不再因为参数里出现人设存储路径而被拒绝。** `personaStoreGuardReason` 原来只看参数里有没有那个
52
+ 路径,于是 `read`、`grep`、`compress` 这类根本不能改写存储的调用也会被拒绝,并回一条"工具不能写入或删除它",
53
+ 等于让会话连解释这个文件都做不到(同一个会话里 `compress` 就被挡过一次)。现在按工具名判断写意图:
54
+ `write`/`edit`/`multiedit`/`apply_patch` 等"参数即目标路径"的工具,路径指向存储才拒绝;`bash`/`pwsh`
55
+ 等命令类工具仍要求同一个字符串里同时出现写特征(`rm`、`>`、`cp` 等);其余只读工具一律放行。
56
+ 存储保护本身没有放松:直接写存储、命令里删/覆盖存储,仍然照旧拒绝。
57
+
7
58
  ## [0.1.4] - 2026-10-08
8
59
 
9
60
  ### Fixed
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
 
@@ -51,9 +51,9 @@ DSH 会把工作区指令(`AGENTS.md` 之类)变成一条 **user 角色**的
51
51
 
52
52
  ## 装
53
53
 
54
- **实测过的 DSH 版本:`0.1.5-rc.1`(作者的开发环境)与 `0.1.5-rc.2`。** `package.json` 的 `dsh.compatibility`
55
- 声明的就是这两项 —— 含义是"我们在这些版本上跑过",**不是**对更早或更新版本做过兼容工作的承诺;
56
- 其它版本请自行验证。
54
+ **实测过的 DSH 版本:`0.1.5-rc.1`(作者的开发环境)、`0.1.5-rc.2`、`0.2.0-rc.2` 与最新版
55
+ `0.2.1-rc.2`。** `package.json` 的 `dsh.compatibility` 声明的就是这四项 —— 含义是"我们在这些版本上跑过",
56
+ **不是**对更早或更新版本做过兼容工作的承诺;其它版本请自行验证。
57
57
 
58
58
  侧边栏卡片还需要 **`dsh-better-sidebar`**:没装它时,「注册到 dsh-better-sidebar」这个开关不会出现
59
59
  (没有可注册的地方),插件其余功能照常。
@@ -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
@@ -61,9 +61,9 @@ workspace cannot touch this layer.
61
61
 
62
62
  ## Install
63
63
 
64
- **Tested against DSH `0.1.5-rc.1` (the author's development environment) and `0.1.5-rc.2`.** That is what
65
- `package.json`'s `dsh.compatibility` declares — it means "we ran on these", not a promise of compatibility work
66
- for older or newer releases; please verify on yours.
64
+ **Tested against DSH `0.1.5-rc.1` (the author's development environment), `0.1.5-rc.2`, `0.2.0-rc.2` and
65
+ the current `0.2.1-rc.2`.** That is what `package.json`'s `dsh.compatibility` declares — it means "we ran on
66
+ these", not a promise of compatibility work for older or newer releases; please verify on yours.
67
67
 
68
68
  The sidebar card additionally needs **`dsh-better-sidebar`**: without it the `注册到 dsh-better-sidebar` switch is not
69
69
  shown at all (there is nothing to register into), and everything else works as usual.
@@ -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
 
@@ -723,6 +726,22 @@
723
726
  @media (prefers-reduced-motion: reduce) {
724
727
  [data-plugin="dsh-agent-persona"] * { transition: none !important; }
725
728
  }
729
+
730
+ .wsp-versions { display: flex; flex-direction: column; gap: 12px; max-height: 58vh; }
731
+ .wsp-versions-list { display: flex; flex-direction: row; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
732
+ .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; }
733
+ .wsp-version-row:hover { border-color: var(--wsp-line-strong); }
734
+ .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)); }
735
+ .wsp-version-title { font-size: 13px; font-weight: 600; }
736
+ .wsp-version-meta { font-size: 11px; opacity: 0.72; font-variant-numeric: tabular-nums; }
737
+ .wsp-version-preview { font-size: 11px; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
738
+ .wsp-versions-diff { display: flex; flex-direction: column; gap: 8px; min-width: 0; overflow: hidden; }
739
+ .wsp-diff-head { font-size: 12px; opacity: 0.75; font-variant-numeric: tabular-nums; }
740
+ .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; }
741
+ .wsp-diff-line { padding: 0 10px; white-space: pre-wrap; word-break: break-word; }
742
+ .wsp-diff-add { background: rgba(46, 160, 67, 0.16); }
743
+ .wsp-diff-del { background: rgba(248, 81, 73, 0.14); }
744
+ .wsp-diff-skip { padding: 2px 10px; opacity: 0.5; font-size: 11px; }
726
745
  `;
727
746
  let styleElement;
728
747
  const injectStyles = () => {
@@ -1153,6 +1172,11 @@
1153
1172
  dropIndex: void 0,
1154
1173
  history: void 0,
1155
1174
  historyBusy: void 0,
1175
+ versions: void 0,
1176
+ versionsBusy: "",
1177
+ versionsError: "",
1178
+ versionDiff: void 0,
1179
+ versionIndex: void 0,
1156
1180
  models: [],
1157
1181
  model: "",
1158
1182
  tuneOpen: false,
@@ -1323,6 +1347,55 @@
1323
1347
  }
1324
1348
  return items;
1325
1349
  }
1350
+ /** Version history: list, diff, restore. Opt-in through the settings switch. */
1351
+ async openVersions(id) {
1352
+ const api = this.api();
1353
+ if (api === void 0 || typeof api.listVersions !== "function") {
1354
+ 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" });
1355
+ return;
1356
+ }
1357
+ this.onChange({ versionsBusy: "list", versionsError: "", versions: { id }, versionDiff: void 0, versionIndex: void 0 });
1358
+ try {
1359
+ const data = this.unwrap(await api.listVersions({ id }));
1360
+ this.onChange({ versionsBusy: "", versions: data });
1361
+ await this.readVersion((data?.versions?.length ?? 0) - 1);
1362
+ } catch (error) {
1363
+ this.onChange({ versionsBusy: "", versions: void 0, versionsError: String(error && error.message || error) });
1364
+ }
1365
+ }
1366
+ /** Load one version's diff; the modal swaps its right pane. */
1367
+ async readVersion(index) {
1368
+ const api = this.api();
1369
+ const versions = this.state.versions;
1370
+ if (api === void 0 || versions === void 0 || index < 0) return;
1371
+ this.onChange({ versionsBusy: "diff", versionIndex: index, versionsError: "" });
1372
+ try {
1373
+ const diff = this.unwrap(await api.versionDiff({ id: versions.id, index }));
1374
+ this.onChange({ versionsBusy: "", versionDiff: diff });
1375
+ } catch (error) {
1376
+ this.onChange({ versionsBusy: "", versionDiff: void 0, versionsError: String(error && error.message || error) });
1377
+ }
1378
+ }
1379
+ /** Put one version back as the persona text (itself recorded as a new version). */
1380
+ async restoreVersion(index) {
1381
+ const api = this.api();
1382
+ const versions = this.state.versions;
1383
+ if (api === void 0 || versions === void 0) return;
1384
+ this.onChange({ versionsBusy: "restore", versionsError: "" });
1385
+ try {
1386
+ const view = this.unwrap(await api.restoreVersion({ id: versions.id, index }));
1387
+ const found = (view?.personas ?? []).find((item) => item.id === versions.id);
1388
+ this.onChange({
1389
+ view,
1390
+ versionsBusy: "",
1391
+ status: view?.notice ?? "\u5DF2\u6062\u590D",
1392
+ draft: this.state.openId === versions.id && found !== void 0 ? this.draftFrom(found) : this.state.draft
1393
+ });
1394
+ await this.openVersions(versions.id);
1395
+ } catch (error) {
1396
+ this.onChange({ versionsBusy: "", versionsError: String(error && error.message || error) });
1397
+ }
1398
+ }
1326
1399
  /** Read a session's recent messages so the user can recognise it. */
1327
1400
  async openHistory(sessionId) {
1328
1401
  const api = this.api();
@@ -2267,6 +2340,14 @@ ${state.proposal.trim()}
2267
2340
  h("div", { className: "wsp-foot", key: "foot" }, [
2268
2341
  h("span", { className: "wsp-caption", key: "c" }, `${draft2.text.length} \u5B57 \xB7 \u2318/Ctrl+S \u4FDD\u5B58`),
2269
2342
  h("div", { className: "wsp-actions", key: "a" }, [
2343
+ this.state.prefs?.history === true ? h(Button, {
2344
+ key: "ver",
2345
+ variant: "ghost",
2346
+ size: "sm",
2347
+ disabled: busy,
2348
+ title: "\u67E5\u770B\u8FD9\u6761\u4EBA\u8BBE\u7684\u7248\u672C\u5386\u53F2",
2349
+ onClick: () => void this.openVersions(draft2.id)
2350
+ }, "\u7248\u672C\u5386\u53F2") : null,
2270
2351
  h(Button, { key: "dup", variant: "ghost", size: "sm", disabled: busy, onClick: () => void this.duplicate(draft2.id) }, "\u590D\u5236"),
2271
2352
  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
2353
  key: "del",
@@ -2501,6 +2582,13 @@ ${state.proposal.trim()}
2501
2582
  label: "\u7981\u6B62\u6A21\u578B\u6539\u5199\u4EBA\u8BBE\u6587\u4EF6",
2502
2583
  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
2584
  onChange: (next) => this.setPref({ guard: next })
2585
+ }),
2586
+ h(Switch, {
2587
+ key: "history",
2588
+ checked: prefs.history === true,
2589
+ label: "\u4FDD\u5B58\u4EBA\u8BBE\u7248\u672C\u5386\u53F2",
2590
+ 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",
2591
+ onChange: (next) => this.setPref({ history: next })
2504
2592
  })
2505
2593
  ]);
2506
2594
  const pendingDelete = state.confirmDelete === void 0 || state.confirmDelete === null ? void 0 : personas.find((item) => item.id === state.confirmDelete);
@@ -2515,6 +2603,69 @@ ${state.proposal.trim()}
2515
2603
  h(Button, { key: "ok", variant: "primary", size: "sm", disabled: busy, onClick: () => void this.remove(pendingDelete.id) }, "\u5220\u9664")
2516
2604
  ])
2517
2605
  }, 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`));
2606
+ const versionsState = state.versions;
2607
+ const diffState = state.versionDiff;
2608
+ const versionRows = versionsState === void 0 ? [] : [...versionsState.versions ?? []].reverse();
2609
+ const diffLines = [];
2610
+ if (diffState !== void 0) {
2611
+ for (const op of diffState.ops ?? []) {
2612
+ if (op.t === "=") {
2613
+ diffLines.push(h("div", { className: "wsp-diff-skip", key: `s${diffLines.length}` }, `\u22EF ${op.n} \u884C\u672A\u6539\u52A8`));
2614
+ continue;
2615
+ }
2616
+ for (const line of op.lines ?? []) {
2617
+ diffLines.push(h("div", {
2618
+ className: `wsp-diff-line ${op.t === "+" ? "wsp-diff-add" : "wsp-diff-del"}`,
2619
+ key: `d${diffLines.length}`
2620
+ }, `${op.t === "+" ? "+" : "\u2212"} ${line === "" ? " " : line}`));
2621
+ }
2622
+ }
2623
+ }
2624
+ const versionsModal = versionsState === void 0 && state.versionsError === "" ? null : h(Modal, {
2625
+ key: "versions",
2626
+ open: true,
2627
+ onClose: () => this.onChange({ versions: void 0, versionDiff: void 0, versionsError: "", versionIndex: void 0 }),
2628
+ title: `\u7248\u672C\u5386\u53F2${versionsState?.name === void 0 ? "" : ` \xB7 ${versionsState.name}`}`,
2629
+ 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",
2630
+ footer: h("div", { className: "wsp-form-actions" }, [
2631
+ h(Button, {
2632
+ key: "close",
2633
+ variant: "outline",
2634
+ size: "sm",
2635
+ onClick: () => this.onChange({ versions: void 0, versionDiff: void 0, versionsError: "", versionIndex: void 0 })
2636
+ }, "\u5173\u95ED"),
2637
+ h(Button, {
2638
+ key: "restore",
2639
+ variant: "primary",
2640
+ size: "sm",
2641
+ disabled: state.versionsBusy !== "" || state.versionIndex === void 0 || diffState === void 0,
2642
+ onClick: () => void this.restoreVersion(state.versionIndex)
2643
+ }, state.versionsBusy === "restore" ? "\u6062\u590D\u4E2D\u2026" : "\u6062\u590D\u5230\u6B64\u7248\u672C")
2644
+ ])
2645
+ }, h("div", { className: "wsp-versions" }, [
2646
+ 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", {
2647
+ type: "button",
2648
+ key: version.index,
2649
+ className: `wsp-version-row${state.versionIndex === version.index ? " is-active" : ""}`,
2650
+ onClick: () => void this.readVersion(version.index)
2651
+ }, [
2652
+ h("span", { className: "wsp-version-title", key: "t" }, `\u7B2C ${version.index + 1} \u7248`),
2653
+ h(
2654
+ "span",
2655
+ { className: "wsp-version-meta", key: "m" },
2656
+ `${String(version.at).replace("T", " ").slice(5, 19)}\u3000+${version.added} / \u2212${version.removed}`
2657
+ ),
2658
+ h("span", { className: "wsp-version-preview", key: "p" }, version.preview || "\uFF08\u65E0\u53EF\u663E\u793A\u7684\u6539\u52A8\u884C\uFF09")
2659
+ ]))),
2660
+ 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") : [
2661
+ h(
2662
+ "div",
2663
+ { className: "wsp-diff-head", key: "h" },
2664
+ `${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}`
2665
+ ),
2666
+ h("div", { className: "wsp-diff-body", key: "b" }, diffLines)
2667
+ ])
2668
+ ]));
2518
2669
  const health = view?.storeHealth;
2519
2670
  const healthNote = health === void 0 || health.status === "ok" ? null : h("div", {
2520
2671
  className: `wsp-alert wsp-alert-${health.status}`,
@@ -2621,7 +2772,7 @@ ${state.proposal.trim()}
2621
2772
  ])
2622
2773
  ])
2623
2774
  ]));
2624
- return h("div", rootProps, [header, healthNote, prefsRow, transferRow, searchRow, hiddenNote, conflictNotice, h("div", { className: "wsp-cards", key: "cards" }, cards), status, historyModal, confirmModal, importModal]);
2775
+ 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
2776
  }
2626
2777
  }
2627
2778
  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
@@ -221,6 +224,10 @@ export const backupStoreFile = (storePath, stamp) => {
221
224
  /** Write features that turn "mentions the store" into "writes the store" inside one shell command. */
222
225
  const STORE_WRITE_MARKERS = ['>', 'rm ', 'rm\t', 'mv ', 'cp ', 'chmod', 'chflags', 'truncate', 'sed -i', 'tee ', 'write(', 'unlink(', 'rename('];
223
226
  const STORE_REFUSAL = '人设存储由 agent-persona 插件管理:工具不能写入或删除它。需要修改人设时,请让用户在「设置 → Agent 人设」里操作。';
227
+ /** Tools whose own arguments name the file they write, so a store path in them is the target. */
228
+ const STORE_PATH_TOOLS = new Set(['write', 'edit', 'multiedit', 'apply_patch', 'create_file', 'delete_file', 'move_file']);
229
+ /** Tools that run a command line, where the store only matters next to a write feature. */
230
+ const STORE_SHELL_TOOLS = new Set(['bash', 'pwsh', 'shell', 'exec', 'terminal', 'run_command']);
224
231
  /** Every string nested anywhere in a tool call's arguments, to a bounded depth. */
225
232
  const stringsIn = (value, out = [], depth = 0) => {
226
233
  if (depth > 6 || value === null || value === undefined)
@@ -251,23 +258,32 @@ const stringsIn = (value, out = [], depth = 0) => {
251
258
  * whole command, so a read and a write of the store in one line is refused as a unit.
252
259
  */
253
260
  export const personaStoreGuardReason = (execution, storePath) => {
261
+ const name = String(execution?.name ?? '');
262
+ const shell = STORE_SHELL_TOOLS.has(name);
263
+ // A read-only call cannot rewrite the store, so a path that merely appears in its arguments is not a
264
+ // write. Refusing those (`read`, `grep`, `compress`, ...) turned the guard into a trap for honest work:
265
+ // any tool whose arguments quoted the store path was denied with a message about writing it.
266
+ if (!shell && !STORE_PATH_TOOLS.has(name))
267
+ return undefined;
254
268
  const values = stringsIn(execution?.arguments);
255
269
  const target = resolve(storePath);
256
- if (values.some((value) => {
270
+ const base = basename(storePath);
271
+ const dir = dirname(storePath);
272
+ const pointsAtStore = values.some((value) => {
257
273
  try {
258
274
  return resolve(value) === target;
259
275
  }
260
276
  catch {
261
277
  return false;
262
278
  }
263
- }))
264
- return STORE_REFUSAL;
265
- const base = basename(storePath);
266
- const dir = dirname(storePath);
267
- if (values.some((value) => (value.includes(base) || value.includes(dir))
268
- && STORE_WRITE_MARKERS.some((marker) => value.includes(marker))))
269
- return STORE_REFUSAL;
270
- return undefined;
279
+ });
280
+ const nearStore = values.some((value) => value.includes(base) || value.includes(dir));
281
+ const writes = values.some((value) => STORE_WRITE_MARKERS.some((marker) => value.includes(marker)));
282
+ // A tool that names its own target writes exactly what it points at; a shell command has to mention a
283
+ // write feature before the path inside it counts.
284
+ if (shell)
285
+ return nearStore && writes ? STORE_REFUSAL : undefined;
286
+ return pointsAtStore || (nearStore && writes) ? STORE_REFUSAL : undefined;
271
287
  };
272
288
  export const sanitizePersona = (text, warn = () => { }) => {
273
289
  const dropped = [];
@@ -287,6 +303,236 @@ export const sanitizePersona = (text, warn = () => { }) => {
287
303
  warn(`persona text references unsupported prompt variables (${[...new Set(dropped)].join(', ')}); rendered as plain text`);
288
304
  return out;
289
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
+ };
290
536
  // ─────────────────────────────────────────────────────────── matching
291
537
  const regexCache = new Map();
292
538
  const compile = (value) => {
@@ -1060,6 +1306,33 @@ class WorkspacePersonaService extends TypertRemoteService {
1060
1306
  return { error: `savePersona failed: ${String((error && error.stack) || error)}` };
1061
1307
  }
1062
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
+ }
1063
1336
  /** Remove one persona. */
1064
1337
  async deletePersona(input) {
1065
1338
  try {
@@ -1388,8 +1661,90 @@ export function apply(ctx, config = {}) {
1388
1661
  targets,
1389
1662
  };
1390
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
+ };
1391
1674
  const runtime = {
1392
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
+ },
1393
1748
  /** Merge preferences into the store; every field is optional so one switch can be saved. */
1394
1749
  savePrefs(input) {
1395
1750
  const current = reload();
@@ -1401,6 +1756,8 @@ export function apply(ctx, config = {}) {
1401
1756
  save(input) {
1402
1757
  const next = normalizeInput(input);
1403
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;
1404
1761
  let persona;
1405
1762
  if (next.id === undefined) {
1406
1763
  persona = { id: newId(), name: next.name, enabled: next.enabled, fallback: next.fallback, text: next.text, mode: next.mode, targets: next.targets };
@@ -1434,6 +1791,8 @@ export function apply(ctx, config = {}) {
1434
1791
  }
1435
1792
  }
1436
1793
  commit(current);
1794
+ if (next.text !== beforeText)
1795
+ recordIfEnabled(persona.id, beforeText, next.text, created ? 'create' : 'update');
1437
1796
  const result = view();
1438
1797
  if (released.length > 0) {
1439
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.4",
3
+ "version": "0.1.6",
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",
@@ -82,7 +82,8 @@
82
82
  "dshReleases": {
83
83
  "0.1.5-rc.1": "compatible",
84
84
  "0.1.5-rc.2": "compatible",
85
- "0.2.0-rc.2": "compatible"
85
+ "0.2.0-rc.2": "compatible",
86
+ "0.2.1-rc.2": "compatible"
86
87
  }
87
88
  }
88
89
  },