dsh-agent-persona 0.1.8 → 0.1.9

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,30 @@ 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.9] - 2026-10-23
8
+
9
+ 版本历史从「只能看和恢复」补成「能管理」,并且不再记录重复的正文。
10
+
11
+ ### Added
12
+
13
+ - **可以删除单个版本。** 选中一条,点「删除此版本」(连点两次确认)。删掉中间一条后,它后面的版本会
14
+ 按新的前一条重新算 diff,链不会断:剩下的每一条仍然能逐条打开、恢复。删掉最后一条时这条人设的历史
15
+ 记录一起清掉。
16
+ - **正文哈希去重。** 每条版本原本就存了 sha256 前 16 位,现在保存时先比对:如果这次要存的内容和已有
17
+ 某条版本完全相同(撤销、恢复之后再保存、导入同一份正文都会遇到),就不新增记录,只提示「正文与第 N
18
+ 个版本相同,没有新增版本」。列表行和对比窗口头部都显示这个哈希。
19
+
20
+ ### Fixed
21
+
22
+ - **`savePersona({ id, name })` 仍会清空正文。** 0.1.8 声称修好了部分更新,但入口把整个 store 当成
23
+ 「已有值」传下去,store 顶层没有 name/text 这些字段,于是没传的正文按默认值覆盖。现在按 id 取到那条
24
+ 人设再合并:只改名,正文、范围、启用状态都不动。
25
+
26
+ ### Tests
27
+
28
+ - 新增去重、删除(删中间/删首条/删最后一条/越界)与哈希格式的断言;测试总数 254。
29
+ - 删除后的链式重放、重算后的 `+n/−m` 统计、以及被删版本之后的 diff 都在测试里逐条验证。
30
+
7
31
  ## [0.1.8] - 2026-10-23
8
32
 
9
33
  这一版几乎全部来自一次逐行读代码的审查:不改功能,修的是「它看起来在做 A,实际在做 B」的地方。
package/README.md CHANGED
@@ -111,6 +111,10 @@ dsh plugin --profile web remove dsh-agent-persona # 卸载
111
111
  - **只记正文**:名称、启用状态、适用范围不参与版本,改这些不会产生一条记录。
112
112
  - **每条都是 diff**:相等行只记行数,只有真正改动的行才存原文,所以一百条版本也远小于一百份正文。
113
113
  - **恢复**:选中任意一条点「恢复到此版本」写回正文;恢复动作本身也记一条,恢复错了还能再回滚。
114
+ - **删除**:选中任意一条点「删除此版本」(连点两次确认)就把这条记录删掉;它后面的版本会重新计算
115
+ diff,仍然可以逐条打开,链不会断。
116
+ - **同一份正文只存一条**:每条版本带正文的 sha256 前 16 位,保存时若和已有版本的内容哈希相同,就不
117
+ 再新增记录(撤销、恢复后再保存、导入同样的正文都属于这种情况),列表和对比窗口里都能看到哈希。
114
118
  - **链式重放**:第一条版本作为基准快照,之后逐条重放;超过 200 条时把最旧的几条折进基准,
115
119
  保留下来的版本仍可逐条打开。每条记录带内容摘要,历史文件被手改坏时重放会报错,不会给出别的正文。
116
120
  - 关掉开关只是停止记录,已有历史仍可查看与恢复。
package/README_EN.md CHANGED
@@ -121,6 +121,11 @@ Turn on **Save persona version history** in Settings and every save records the
121
121
  - **Text only**: name, enabled flag and targets are not versioned.
122
122
  - **Diffs, not snapshots**: equal runs are stored as line counts; only changed lines keep their text.
123
123
  - **Restore**: pick any version and restore it; the restore itself is recorded, so it can be undone too.
124
+ - **Delete**: pick a version and press Delete (twice: the first press arms it). The versions after it are
125
+ re-diffed against its predecessor, so the chain stays replayable.
126
+ - **One row per text**: each record carries the first 16 hex characters of its text's sha256. A save whose
127
+ text hashes to a version that is already stored adds nothing — undo, restore-then-save and importing the
128
+ same body all land on the existing row. The hash is shown in the row and in the diff header.
124
129
  - **Chained replay**: the first text is the base snapshot, later versions replay on top of it; past 200
125
130
  versions the oldest are folded into the base. A content hash per record makes a hand-edited history fail
126
131
  loudly instead of restoring different text.
package/lib/client.js CHANGED
@@ -47,7 +47,8 @@
47
47
  method("savePrefs", withInput()),
48
48
  method("listVersions", withInput()),
49
49
  method("versionDiff", withInput()),
50
- method("restoreVersion", withInput())
50
+ method("restoreVersion", withInput()),
51
+ method("deleteVersion", withInput())
51
52
  ]
52
53
  };
53
54
 
@@ -750,6 +751,7 @@
750
751
  .wsp-version-title { font-size: 13px; font-weight: 600; }
751
752
  .wsp-version-meta { font-size: 11px; opacity: 0.72; font-variant-numeric: tabular-nums; }
752
753
  .wsp-version-preview { font-size: 11px; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
754
+ .wsp-version-hash { font-family: var(--wsp-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 11px; opacity: 0.55; }
753
755
  .wsp-versions-diff { display: flex; flex-direction: column; gap: 8px; min-width: 0; overflow: hidden; }
754
756
  .wsp-diff-head { font-size: 12px; opacity: 0.75; font-variant-numeric: tabular-nums; }
755
757
  .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; }
@@ -1202,6 +1204,7 @@
1202
1204
  versionsError: "",
1203
1205
  versionDiff: void 0,
1204
1206
  versionIndex: void 0,
1207
+ versionConfirm: false,
1205
1208
  models: [],
1206
1209
  model: "",
1207
1210
  tuneOpen: false,
@@ -1400,7 +1403,7 @@
1400
1403
  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" });
1401
1404
  return;
1402
1405
  }
1403
- this.onChange({ versionsBusy: "list", versionsError: "", versions: { id }, versionDiff: void 0, versionIndex: void 0 });
1406
+ this.onChange({ versionsBusy: "list", versionsError: "", versions: { id }, versionDiff: void 0, versionIndex: void 0, versionConfirm: false });
1404
1407
  try {
1405
1408
  const data = this.unwrap(await api.listVersions({ id }));
1406
1409
  this.onChange({ versionsBusy: "", versions: data });
@@ -1415,7 +1418,7 @@
1415
1418
  const versions = this.state.versions;
1416
1419
  if (api === void 0 || versions === void 0 || index < 0) return;
1417
1420
  const seq = this.versionSeq += 1;
1418
- this.onChange({ versionsBusy: "diff", versionIndex: index, versionsError: "" });
1421
+ this.onChange({ versionsBusy: "diff", versionIndex: index, versionsError: "", versionConfirm: false });
1419
1422
  try {
1420
1423
  const diff = this.unwrap(await api.versionDiff({ id: versions.id, index }));
1421
1424
  if (seq !== this.versionSeq) return;
@@ -1448,6 +1451,36 @@
1448
1451
  this.onChange({ versionsBusy: "", versionsError: String(error && error.message || error) });
1449
1452
  }
1450
1453
  }
1454
+ /**
1455
+ * Delete one stored version. Two clicks: the first arms the footer button, the second deletes — a
1456
+ * history entry is the only copy of a text that may not exist anywhere else.
1457
+ */
1458
+ async deleteVersion(index) {
1459
+ const api = this.api();
1460
+ const versions = this.state.versions;
1461
+ if (api === void 0 || versions === void 0) return;
1462
+ if (this.state.versionConfirm !== true) {
1463
+ this.onChange({ versionConfirm: true });
1464
+ return;
1465
+ }
1466
+ clearTimeout(this.autosaveTimer);
1467
+ this.autosaveTimer = void 0;
1468
+ this.saveSeq += 1;
1469
+ this.onChange({ versionsBusy: "delete", versionsError: "", versionConfirm: false });
1470
+ try {
1471
+ const view = this.unwrap(await api.deleteVersion({ id: versions.id, index }));
1472
+ const found = (view?.personas ?? []).find((item) => item.id === versions.id);
1473
+ this.onChange({
1474
+ view,
1475
+ versionsBusy: "",
1476
+ status: view?.notice ?? "\u5DF2\u5220\u9664\u8BE5\u7248\u672C",
1477
+ draft: this.state.openId === versions.id && found !== void 0 ? this.draftFrom(found) : this.state.draft
1478
+ });
1479
+ await this.openVersions(versions.id);
1480
+ } catch (error) {
1481
+ this.onChange({ versionsBusy: "", versionsError: String(error && error.message || error) });
1482
+ }
1483
+ }
1451
1484
  /** Read a session's recent messages so the user can recognise it. */
1452
1485
  async openHistory(sessionId) {
1453
1486
  const api = this.api();
@@ -2689,6 +2722,13 @@ ${state.proposal.trim()}
2689
2722
  size: "sm",
2690
2723
  onClick: () => this.onChange({ versions: void 0, versionDiff: void 0, versionsError: "", versionIndex: void 0 })
2691
2724
  }, "\u5173\u95ED"),
2725
+ h(Button, {
2726
+ key: "delete",
2727
+ variant: "outline",
2728
+ size: "sm",
2729
+ disabled: state.versionsBusy !== "" || state.versionIndex === void 0 || diffState === void 0,
2730
+ onClick: () => void this.deleteVersion(state.versionIndex)
2731
+ }, state.versionsBusy === "delete" ? "\u5220\u9664\u4E2D\u2026" : state.versionConfirm === true ? "\u518D\u70B9\u4E00\u6B21\uFF1A\u5220\u9664" : "\u5220\u9664\u6B64\u7248\u672C"),
2692
2732
  h(Button, {
2693
2733
  key: "restore",
2694
2734
  variant: "primary",
@@ -2710,13 +2750,14 @@ ${state.proposal.trim()}
2710
2750
  { className: "wsp-version-meta", key: "m" },
2711
2751
  `${String(version.at).replace("T", " ").slice(5, 19)}\u3000+${version.added} / \u2212${version.removed}`
2712
2752
  ),
2753
+ h("span", { className: "wsp-version-hash", key: "h" }, `${version.hash ?? ""}`),
2713
2754
  h("span", { className: "wsp-version-preview", key: "p" }, version.preview || "\uFF08\u65E0\u53EF\u663E\u793A\u7684\u6539\u52A8\u884C\uFF09")
2714
2755
  ]))),
2715
2756
  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") : [
2716
2757
  h(
2717
2758
  "div",
2718
2759
  { className: "wsp-diff-head", key: "h" },
2719
- `${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}`
2760
+ `${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}\u3000\xB7\u3000${diffState.hash ?? ""}`
2720
2761
  ),
2721
2762
  h("div", { className: "wsp-diff-body", key: "b" }, diffLines)
2722
2763
  ])
@@ -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', 'listVersions', 'versionDiff', 'restoreVersion'];
14
+ export declare const RPC_ENDPOINTS: readonly ['listPersonas', 'savePersona', 'deletePersona', 'movePersona', 'duplicatePersona', 'reorderPersona', 'listTargets', 'sessionHistory', 'sessionPrompt', 'tunePersona', 'listModels', 'savePrefs', 'listVersions', 'versionDiff', 'restoreVersion', 'deleteVersion'];
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
@@ -27,6 +27,7 @@ export const RPC_ENDPOINTS = [
27
27
  'listVersions',
28
28
  'versionDiff',
29
29
  'restoreVersion',
30
+ 'deleteVersion',
30
31
  ];
31
32
  /** The three reads that take no argument. Everything else receives one JSON object. */
32
33
  export const RPC_NO_INPUT = ['listPersonas', 'listTargets', 'listModels'];
package/lib/index.d.ts CHANGED
@@ -150,11 +150,28 @@ export declare const diffStats: (ops: any) => {
150
150
  };
151
151
  /** Current text of one stored version (index 0 is the oldest kept version). */
152
152
  export declare const versionTextAt: (entryState: any, index: any) => any;
153
+ /** Content hash of a version's text. Two versions that hash the same are the same text. */
154
+ export declare const hashText: (text: any) => any;
153
155
  /**
154
156
  * Record one new version. `before` is the text the persona had, `after` the text it now has.
155
- * Returns false when the history is off or nothing changed.
157
+ *
158
+ * A save whose text already exists in the chain is not a new version: edits that walk back to an earlier
159
+ * text (undo, restore then save, an import of the same body) would otherwise pile up entries that differ
160
+ * only in their timestamp. Returns `{ recorded: false, duplicateOf }` in that case.
161
+ */
162
+ export declare const recordVersion: (history: any, personaId: any, before: any, after: any, action: any, now?: string) => {
163
+ recorded: boolean;
164
+ duplicateOf?: undefined;
165
+ } | {
166
+ recorded: boolean;
167
+ duplicateOf: any;
168
+ };
169
+ /**
170
+ * Drop one version and re-diff what is left, so every remaining version still replays from the base.
171
+ * The dropped entry's text stops being reachable; the others keep their timestamp, action and text.
172
+ * Returns how many versions remain.
156
173
  */
157
- export declare const recordVersion: (history: any, personaId: any, before: any, after: any, action: any, now?: string) => boolean;
174
+ export declare const deleteVersionAt: (state: any, index: any) => any;
158
175
  /**
159
176
  * Does one target claim this subject?
160
177
  * @param target - normalized target record.
package/lib/index.js CHANGED
@@ -723,15 +723,20 @@ const chainMatches = (state, beforeText) => {
723
723
  return false;
724
724
  }
725
725
  };
726
+ /** Content hash of a version's text. Two versions that hash the same are the same text. */
727
+ export const hashText = (text) => createHash('sha256').update(String(text ?? ''), 'utf8').digest('hex').slice(0, 16);
726
728
  /**
727
729
  * Record one new version. `before` is the text the persona had, `after` the text it now has.
728
- * Returns false when the history is off or nothing changed.
730
+ *
731
+ * A save whose text already exists in the chain is not a new version: edits that walk back to an earlier
732
+ * text (undo, restore then save, an import of the same body) would otherwise pile up entries that differ
733
+ * only in their timestamp. Returns `{ recorded: false, duplicateOf }` in that case.
729
734
  */
730
735
  export const recordVersion = (history, personaId, before, after, action, now = new Date().toISOString()) => {
731
736
  const beforeText = String(before ?? '');
732
737
  const afterText = String(after ?? '');
733
738
  if (beforeText === afterText)
734
- return false;
739
+ return { recorded: false };
735
740
  const ops = diffLines(beforeText, afterText);
736
741
  const stats = diffStats(ops);
737
742
  let state = history.personas[personaId];
@@ -746,13 +751,18 @@ export const recordVersion = (history, personaId, before, after, action, now = n
746
751
  state.baseAt = now;
747
752
  state.versions = [];
748
753
  }
754
+ const hash = hashText(afterText);
755
+ // Checked after the re-base above: a re-based chain has no versions left to collide with.
756
+ const duplicateOf = state.versions.findIndex((version) => version?.hash === hash);
757
+ if (duplicateOf !== -1)
758
+ return { recorded: false, duplicateOf };
749
759
  state.versions.push({
750
760
  at: now,
751
761
  action,
752
762
  ops,
753
763
  added: stats.added,
754
764
  removed: stats.removed,
755
- hash: createHash('sha256').update(afterText, 'utf8').digest('hex').slice(0, 16),
765
+ hash,
756
766
  size: Buffer.byteLength(afterText, 'utf8'),
757
767
  });
758
768
  // Fold the oldest versions into the base once the cap is hit, so every kept version stays replayable.
@@ -761,7 +771,33 @@ export const recordVersion = (history, personaId, before, after, action, now = n
761
771
  state.base = applyDiff(state.base, oldest.ops);
762
772
  state.baseAt = oldest.at;
763
773
  }
764
- return true;
774
+ return { recorded: true };
775
+ };
776
+ /**
777
+ * Drop one version and re-diff what is left, so every remaining version still replays from the base.
778
+ * The dropped entry's text stops being reachable; the others keep their timestamp, action and text.
779
+ * Returns how many versions remain.
780
+ */
781
+ export const deleteVersionAt = (state, index) => {
782
+ const versions = Array.isArray(state?.versions) ? state.versions : [];
783
+ if (!Number.isInteger(index) || index < 0 || index >= versions.length)
784
+ throw new Error('版本不存在');
785
+ const texts = versions.map((_, at) => versionTextAt(state, at));
786
+ const kept = versions.filter((_, at) => at !== index);
787
+ const keptTexts = texts.filter((_, at) => at !== index);
788
+ let previous = state.base;
789
+ kept.forEach((version, at) => {
790
+ const ops = diffLines(previous, keptTexts[at]);
791
+ const stats = diffStats(ops);
792
+ version.ops = ops;
793
+ version.added = stats.added;
794
+ version.removed = stats.removed;
795
+ version.hash = hashText(keptTexts[at]);
796
+ version.size = Buffer.byteLength(keptTexts[at], 'utf8');
797
+ previous = keptTexts[at];
798
+ });
799
+ state.versions = kept;
800
+ return kept.length;
765
801
  };
766
802
  // ─────────────────────────────────────────────────────────── matching
767
803
  const regexCache = new Map();
@@ -1593,6 +1629,15 @@ class WorkspacePersonaService extends TypertRemoteService {
1593
1629
  return { error: `restoreVersion failed: ${errorMessage(error)}` };
1594
1630
  }
1595
1631
  }
1632
+ /** Drop one stored version (the chain is re-diffed afterwards). */
1633
+ async deleteVersion(input) {
1634
+ try {
1635
+ return this.runtime.deleteVersion(input);
1636
+ }
1637
+ catch (error) {
1638
+ return { error: `deleteVersion failed: ${errorMessage(error)}` };
1639
+ }
1640
+ }
1596
1641
  /** Remove one persona. */
1597
1642
  async deletePersona(input) {
1598
1643
  try {
@@ -1943,11 +1988,12 @@ export function apply(ctx, config = {}) {
1943
1988
  const historyEnabled = () => currentStore().prefs?.history === true;
1944
1989
  const recordIfEnabled = (personaId, before, after, action) => {
1945
1990
  if (!historyEnabled() || personaId === undefined)
1946
- return;
1991
+ return { recorded: false };
1947
1992
  const history = readHistory(storePath, warn);
1948
- if (!recordVersion(history, personaId, before, after, action))
1949
- return;
1950
- writeHistory(storePath, history, warn);
1993
+ const result = recordVersion(history, personaId, before, after, action);
1994
+ if (result.recorded)
1995
+ writeHistory(storePath, history, warn);
1996
+ return result;
1951
1997
  };
1952
1998
  /** Forget a deleted persona's versions; the history of something that no longer exists is just growth. */
1953
1999
  const dropHistory = (personaId) => {
@@ -1987,6 +2033,7 @@ export function apply(ctx, config = {}) {
1987
2033
  added: version.added,
1988
2034
  removed: version.removed,
1989
2035
  size: version.size,
2036
+ hash: version.hash ?? '',
1990
2037
  preview: (version.ops ?? []).find((op) => op.t === '+')?.lines?.[0] ?? '',
1991
2038
  })),
1992
2039
  };
@@ -2013,6 +2060,7 @@ export function apply(ctx, config = {}) {
2013
2060
  added: version.added,
2014
2061
  removed: version.removed,
2015
2062
  ops: version.ops,
2063
+ hash: version.hash ?? '',
2016
2064
  before,
2017
2065
  after,
2018
2066
  current: persona.text,
@@ -2030,10 +2078,33 @@ export function apply(ctx, config = {}) {
2030
2078
  const before = persona.text;
2031
2079
  persona.text = text;
2032
2080
  commit(current);
2033
- recordIfEnabled(id, before, text, 'restore');
2081
+ const recorded = recordIfEnabled(id, before, text, 'restore');
2034
2082
  info(`restored persona "${persona.name}" to version ${String(input?.index)}`);
2035
2083
  const result = view();
2036
- result.notice = `已恢复到第 ${Number(input?.index) + 1} 个版本`;
2084
+ result.notice = recorded.duplicateOf === undefined
2085
+ ? `已恢复到第 ${Number(input?.index) + 1} 个版本`
2086
+ : `已恢复到第 ${Number(input?.index) + 1} 个版本(正文与第 ${recorded.duplicateOf + 1} 个版本相同,未新增记录)`;
2087
+ return result;
2088
+ },
2089
+ /** Drop one stored version; the remaining versions are re-diffed so the chain stays replayable. */
2090
+ deleteVersion(input) {
2091
+ const current = editableStore();
2092
+ const id = typeof input?.id === 'string' ? input.id : '';
2093
+ const persona = current.personas.find((item) => item.id === id);
2094
+ if (persona === undefined)
2095
+ throw new Error('人设不存在(可能已被删除)');
2096
+ const history = readHistory(storePath, warn);
2097
+ const state = history.personas[id];
2098
+ const index = Number(input?.index);
2099
+ const removed = versionTextAt(state, index);
2100
+ const remaining = deleteVersionAt(state, index);
2101
+ if (remaining === 0)
2102
+ delete history.personas[id];
2103
+ writeHistory(storePath, history, warn);
2104
+ info(`deleted version ${String(index)} of persona "${persona.name}" (${String(remaining)} left)`);
2105
+ const result = view();
2106
+ result.notice = `已删除第 ${index + 1} 个版本`;
2107
+ result.deleted = { index, size: Buffer.byteLength(removed, 'utf8') };
2037
2108
  return result;
2038
2109
  },
2039
2110
  /** Merge preferences into the store; every field is optional so one switch can be saved. */
@@ -2046,7 +2117,11 @@ export function apply(ctx, config = {}) {
2046
2117
  },
2047
2118
  save(input) {
2048
2119
  const current = editableStore();
2049
- const next = normalizeInput(input, current);
2120
+ // Fall back to the stored persona, not to the store: a payload that names an id and one field must
2121
+ // keep everything it did not mention, and `{ ...store }` has none of those fields at the top level.
2122
+ const requestedId = input !== null && typeof input?.id === 'string' && input.id !== '' ? input.id : undefined;
2123
+ const existing = requestedId === undefined ? undefined : current.personas.find((item) => item.id === requestedId);
2124
+ const next = normalizeInput(input, existing);
2050
2125
  const beforeText = next.id === undefined ? '' : (current.personas.find((item) => item.id === next.id)?.text ?? '');
2051
2126
  const created = next.id === undefined;
2052
2127
  let persona;
@@ -2081,9 +2156,11 @@ export function apply(ctx, config = {}) {
2081
2156
  }
2082
2157
  }
2083
2158
  commit(current);
2084
- if (next.text !== beforeText)
2085
- recordIfEnabled(persona.id, beforeText, next.text, created ? 'create' : 'update');
2159
+ const recorded = next.text === beforeText ? { recorded: false } : recordIfEnabled(persona.id, beforeText, next.text, created ? 'create' : 'update');
2086
2160
  const result = view();
2161
+ if (recorded.recorded === false && recorded.duplicateOf !== undefined) {
2162
+ result.notice = `正文与第 ${recorded.duplicateOf + 1} 个版本相同,没有新增版本`;
2163
+ }
2087
2164
  if (released.length > 0) {
2088
2165
  result.notice = `「${persona.name}」现在是默认人设;「${released.join('」「')}」已取消默认`;
2089
2166
  info(`default persona moved from: ${released.join(', ')}`);
package/lib/remote.js CHANGED
@@ -54,5 +54,6 @@ export const RPC_REMOTE = {
54
54
  method("listVersions", withInput()),
55
55
  method("versionDiff", withInput()),
56
56
  method("restoreVersion", withInput()),
57
+ method("deleteVersion", withInput()),
57
58
  ],
58
59
  };
package/lib/typert.js CHANGED
@@ -43,6 +43,7 @@ export const TYPERT = {
43
43
  { name: "listVersions", kind: "method", signature: "(input: unknown): Promise<unknown>" },
44
44
  { name: "versionDiff", kind: "method", signature: "(input: unknown): Promise<unknown>" },
45
45
  { name: "restoreVersion", kind: "method", signature: "(input: unknown): Promise<unknown>" },
46
+ { name: "deleteVersion", kind: "method", signature: "(input: unknown): Promise<unknown>" },
46
47
  ],
47
48
  types: [],
48
49
  },
@@ -270,5 +271,21 @@ export const TYPERT = {
270
271
  ],
271
272
  result: result("Json"),
272
273
  },
274
+ {
275
+ id: `dsh-agent-persona#agentPersona/deleteVersion`,
276
+ service: "agentPersona",
277
+ namespace: "agentPersona",
278
+ method: "deleteVersion",
279
+ invocation: direct,
280
+ parameters: [
281
+ {
282
+ name: "input",
283
+ wire: "input",
284
+ source: "json",
285
+ codec: jsonCodec("Json"),
286
+ },
287
+ ],
288
+ result: result("Json"),
289
+ },
273
290
  ],
274
291
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-agent-persona",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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",
package/src/client.ts CHANGED
@@ -833,6 +833,7 @@ const readPrefs = () => {
833
833
  .wsp-version-title { font-size: 13px; font-weight: 600; }
834
834
  .wsp-version-meta { font-size: 11px; opacity: 0.72; font-variant-numeric: tabular-nums; }
835
835
  .wsp-version-preview { font-size: 11px; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
836
+ .wsp-version-hash { font-family: var(--wsp-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 11px; opacity: 0.55; }
836
837
  .wsp-versions-diff { display: flex; flex-direction: column; gap: 8px; min-width: 0; overflow: hidden; }
837
838
  .wsp-diff-head { font-size: 12px; opacity: 0.75; font-variant-numeric: tabular-nums; }
838
839
  .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; }
@@ -1359,6 +1360,7 @@ const since = (timestamp) => {
1359
1360
  versionsError: '',
1360
1361
  versionDiff: undefined,
1361
1362
  versionIndex: undefined,
1363
+ versionConfirm: false,
1362
1364
  models: [],
1363
1365
  model: '',
1364
1366
  tuneOpen: false,
@@ -1595,7 +1597,7 @@ const since = (timestamp) => {
1595
1597
  this.onChange({ versionsError: '这个版本还没有版本历史接口:请更新插件后重启 dsh web' })
1596
1598
  return
1597
1599
  }
1598
- this.onChange({ versionsBusy: 'list', versionsError: '', versions: { id }, versionDiff: undefined, versionIndex: undefined })
1600
+ this.onChange({ versionsBusy: 'list', versionsError: '', versions: { id }, versionDiff: undefined, versionIndex: undefined, versionConfirm: false })
1599
1601
  try {
1600
1602
  const data = this.unwrap(await api.listVersions({ id }))
1601
1603
  this.onChange({ versionsBusy: '', versions: data })
@@ -1611,7 +1613,7 @@ const since = (timestamp) => {
1611
1613
  const versions = this.state.versions
1612
1614
  if (api === undefined || versions === undefined || index < 0) return
1613
1615
  const seq = (this.versionSeq += 1)
1614
- this.onChange({ versionsBusy: 'diff', versionIndex: index, versionsError: '' })
1616
+ this.onChange({ versionsBusy: 'diff', versionIndex: index, versionsError: '', versionConfirm: false })
1615
1617
  try {
1616
1618
  const diff = this.unwrap(await api.versionDiff({ id: versions.id, index }))
1617
1619
  // Rows are clickable while a diff is loading: without this token two clicks in a row can show the
@@ -1650,6 +1652,37 @@ const since = (timestamp) => {
1650
1652
  }
1651
1653
  }
1652
1654
 
1655
+ /**
1656
+ * Delete one stored version. Two clicks: the first arms the footer button, the second deletes — a
1657
+ * history entry is the only copy of a text that may not exist anywhere else.
1658
+ */
1659
+ async deleteVersion(index) {
1660
+ const api = this.api()
1661
+ const versions = this.state.versions
1662
+ if (api === undefined || versions === undefined) return
1663
+ if (this.state.versionConfirm !== true) {
1664
+ this.onChange({ versionConfirm: true })
1665
+ return
1666
+ }
1667
+ clearTimeout(this.autosaveTimer)
1668
+ this.autosaveTimer = undefined
1669
+ this.saveSeq += 1
1670
+ this.onChange({ versionsBusy: 'delete', versionsError: '', versionConfirm: false })
1671
+ try {
1672
+ const view = this.unwrap(await api.deleteVersion({ id: versions.id, index }))
1673
+ const found = (view?.personas ?? []).find((item) => item.id === versions.id)
1674
+ this.onChange({
1675
+ view,
1676
+ versionsBusy: '',
1677
+ status: view?.notice ?? '已删除该版本',
1678
+ draft: this.state.openId === versions.id && found !== undefined ? this.draftFrom(found) : this.state.draft,
1679
+ })
1680
+ await this.openVersions(versions.id)
1681
+ } catch (error) {
1682
+ this.onChange({ versionsBusy: '', versionsError: String((error && error.message) || error) })
1683
+ }
1684
+ }
1685
+
1653
1686
  /** Read a session's recent messages so the user can recognise it. */
1654
1687
  async openHistory(sessionId) {
1655
1688
  const api = this.api()
@@ -3034,6 +3067,13 @@ const since = (timestamp) => {
3034
3067
  size: 'sm',
3035
3068
  onClick: () => this.onChange({ versions: undefined, versionDiff: undefined, versionsError: '', versionIndex: undefined }),
3036
3069
  }, '关闭'),
3070
+ h(Button, {
3071
+ key: 'delete',
3072
+ variant: 'outline',
3073
+ size: 'sm',
3074
+ disabled: state.versionsBusy !== '' || state.versionIndex === undefined || diffState === undefined,
3075
+ onClick: () => void this.deleteVersion(state.versionIndex),
3076
+ }, state.versionsBusy === 'delete' ? '删除中…' : state.versionConfirm === true ? '再点一次:删除' : '删除此版本'),
3037
3077
  h(Button, {
3038
3078
  key: 'restore',
3039
3079
  variant: 'primary',
@@ -3056,13 +3096,14 @@ const since = (timestamp) => {
3056
3096
  h('span', { className: 'wsp-version-title', key: 't' }, `第 ${version.index + 1} 版`),
3057
3097
  h('span', { className: 'wsp-version-meta', key: 'm' },
3058
3098
  `${String(version.at).replace('T', ' ').slice(5, 19)} +${version.added} / −${version.removed}`),
3099
+ h('span', { className: 'wsp-version-hash', key: 'h' }, `${version.hash ?? ''}`),
3059
3100
  h('span', { className: 'wsp-version-preview', key: 'p' }, version.preview || '(无可显示的改动行)'),
3060
3101
  ]))),
3061
3102
  h('div', { className: 'wsp-versions-diff', key: 'd' }, diffState === undefined
3062
3103
  ? h('div', { className: 'wsp-card-note' }, '左侧选一个版本,这里显示它相对上一版的改动。')
3063
3104
  : [
3064
3105
  h('div', { className: 'wsp-diff-head', key: 'h' },
3065
- `${String(diffState.at).replace('T', ' ').slice(0, 19)} · ${diffState.action === 'restore' ? '恢复' : diffState.action === 'create' ? '新建' : '修改'} · +${diffState.added} / −${diffState.removed}`),
3106
+ `${String(diffState.at).replace('T', ' ').slice(0, 19)} · ${diffState.action === 'restore' ? '恢复' : diffState.action === 'create' ? '新建' : '修改'} · +${diffState.added} / −${diffState.removed} · ${diffState.hash ?? ''}`),
3066
3107
  h('div', { className: 'wsp-diff-body', key: 'b' }, diffLines),
3067
3108
  ]),
3068
3109
  ]))
package/src/endpoints.ts CHANGED
@@ -27,6 +27,7 @@ export const RPC_ENDPOINTS = [
27
27
  'listVersions',
28
28
  'versionDiff',
29
29
  'restoreVersion',
30
+ 'deleteVersion',
30
31
  ] as const
31
32
 
32
33
  /** The three reads that take no argument. Everything else receives one JSON object. */
package/src/index.ts CHANGED
@@ -725,14 +725,20 @@ const chainMatches = (state, beforeText) => {
725
725
  }
726
726
  }
727
727
 
728
+ /** Content hash of a version's text. Two versions that hash the same are the same text. */
729
+ export const hashText = (text) => createHash('sha256').update(String(text ?? ''), 'utf8').digest('hex').slice(0, 16)
730
+
728
731
  /**
729
732
  * Record one new version. `before` is the text the persona had, `after` the text it now has.
730
- * Returns false when the history is off or nothing changed.
733
+ *
734
+ * A save whose text already exists in the chain is not a new version: edits that walk back to an earlier
735
+ * text (undo, restore then save, an import of the same body) would otherwise pile up entries that differ
736
+ * only in their timestamp. Returns `{ recorded: false, duplicateOf }` in that case.
731
737
  */
732
738
  export const recordVersion = (history, personaId, before, after, action, now = new Date().toISOString()) => {
733
739
  const beforeText = String(before ?? '')
734
740
  const afterText = String(after ?? '')
735
- if (beforeText === afterText) return false
741
+ if (beforeText === afterText) return { recorded: false }
736
742
  const ops = diffLines(beforeText, afterText)
737
743
  const stats = diffStats(ops)
738
744
  let state = history.personas[personaId]
@@ -746,13 +752,17 @@ export const recordVersion = (history, personaId, before, after, action, now = n
746
752
  state.baseAt = now
747
753
  state.versions = []
748
754
  }
755
+ const hash = hashText(afterText)
756
+ // Checked after the re-base above: a re-based chain has no versions left to collide with.
757
+ const duplicateOf = state.versions.findIndex((version) => version?.hash === hash)
758
+ if (duplicateOf !== -1) return { recorded: false, duplicateOf }
749
759
  state.versions.push({
750
760
  at: now,
751
761
  action,
752
762
  ops,
753
763
  added: stats.added,
754
764
  removed: stats.removed,
755
- hash: createHash('sha256').update(afterText, 'utf8').digest('hex').slice(0, 16),
765
+ hash,
756
766
  size: Buffer.byteLength(afterText, 'utf8'),
757
767
  })
758
768
  // Fold the oldest versions into the base once the cap is hit, so every kept version stays replayable.
@@ -761,7 +771,33 @@ export const recordVersion = (history, personaId, before, after, action, now = n
761
771
  state.base = applyDiff(state.base, oldest.ops)
762
772
  state.baseAt = oldest.at
763
773
  }
764
- return true
774
+ return { recorded: true }
775
+ }
776
+
777
+ /**
778
+ * Drop one version and re-diff what is left, so every remaining version still replays from the base.
779
+ * The dropped entry's text stops being reachable; the others keep their timestamp, action and text.
780
+ * Returns how many versions remain.
781
+ */
782
+ export const deleteVersionAt = (state, index) => {
783
+ const versions = Array.isArray(state?.versions) ? state.versions : []
784
+ if (!Number.isInteger(index) || index < 0 || index >= versions.length) throw new Error('版本不存在')
785
+ const texts = versions.map((_, at) => versionTextAt(state, at))
786
+ const kept = versions.filter((_, at) => at !== index)
787
+ const keptTexts = texts.filter((_, at) => at !== index)
788
+ let previous = state.base
789
+ kept.forEach((version, at) => {
790
+ const ops = diffLines(previous, keptTexts[at])
791
+ const stats = diffStats(ops)
792
+ version.ops = ops
793
+ version.added = stats.added
794
+ version.removed = stats.removed
795
+ version.hash = hashText(keptTexts[at])
796
+ version.size = Buffer.byteLength(keptTexts[at], 'utf8')
797
+ previous = keptTexts[at]
798
+ })
799
+ state.versions = kept
800
+ return kept.length
765
801
  }
766
802
 
767
803
  // ─────────────────────────────────────────────────────────── matching
@@ -1554,6 +1590,15 @@ class WorkspacePersonaService extends TypertRemoteService {
1554
1590
  }
1555
1591
  }
1556
1592
 
1593
+ /** Drop one stored version (the chain is re-diffed afterwards). */
1594
+ async deleteVersion(input) {
1595
+ try {
1596
+ return this.runtime.deleteVersion(input)
1597
+ } catch (error) {
1598
+ return { error: `deleteVersion failed: ${errorMessage(error)}` }
1599
+ }
1600
+ }
1601
+
1557
1602
  /** Remove one persona. */
1558
1603
  async deletePersona(input) {
1559
1604
  try {
@@ -1908,10 +1953,11 @@ export function apply(ctx, config = {}) {
1908
1953
  /** Read + write the history file around one mutation; a disabled switch costs nothing. */
1909
1954
  const historyEnabled = () => currentStore().prefs?.history === true
1910
1955
  const recordIfEnabled = (personaId, before, after, action) => {
1911
- if (!historyEnabled() || personaId === undefined) return
1956
+ if (!historyEnabled() || personaId === undefined) return { recorded: false }
1912
1957
  const history = readHistory(storePath, warn)
1913
- if (!recordVersion(history, personaId, before, after, action)) return
1914
- writeHistory(storePath, history, warn)
1958
+ const result = recordVersion(history, personaId, before, after, action)
1959
+ if (result.recorded) writeHistory(storePath, history, warn)
1960
+ return result
1915
1961
  }
1916
1962
 
1917
1963
  /** Forget a deleted persona's versions; the history of something that no longer exists is just growth. */
@@ -1950,6 +1996,7 @@ export function apply(ctx, config = {}) {
1950
1996
  added: version.added,
1951
1997
  removed: version.removed,
1952
1998
  size: version.size,
1999
+ hash: version.hash ?? '',
1953
2000
  preview: (version.ops ?? []).find((op) => op.t === '+')?.lines?.[0] ?? '',
1954
2001
  })),
1955
2002
  }
@@ -1974,6 +2021,7 @@ export function apply(ctx, config = {}) {
1974
2021
  added: version.added,
1975
2022
  removed: version.removed,
1976
2023
  ops: version.ops,
2024
+ hash: version.hash ?? '',
1977
2025
  before,
1978
2026
  after,
1979
2027
  current: persona.text,
@@ -1990,10 +2038,31 @@ export function apply(ctx, config = {}) {
1990
2038
  const before = persona.text
1991
2039
  persona.text = text
1992
2040
  commit(current)
1993
- recordIfEnabled(id, before, text, 'restore')
2041
+ const recorded = recordIfEnabled(id, before, text, 'restore')
1994
2042
  info(`restored persona "${persona.name}" to version ${String(input?.index)}`)
1995
2043
  const result = view()
1996
- result.notice = `已恢复到第 ${Number(input?.index) + 1} 个版本`
2044
+ result.notice = recorded.duplicateOf === undefined
2045
+ ? `已恢复到第 ${Number(input?.index) + 1} 个版本`
2046
+ : `已恢复到第 ${Number(input?.index) + 1} 个版本(正文与第 ${recorded.duplicateOf + 1} 个版本相同,未新增记录)`
2047
+ return result
2048
+ },
2049
+ /** Drop one stored version; the remaining versions are re-diffed so the chain stays replayable. */
2050
+ deleteVersion(input) {
2051
+ const current = editableStore()
2052
+ const id = typeof input?.id === 'string' ? input.id : ''
2053
+ const persona = current.personas.find((item) => item.id === id)
2054
+ if (persona === undefined) throw new Error('人设不存在(可能已被删除)')
2055
+ const history = readHistory(storePath, warn)
2056
+ const state = history.personas[id]
2057
+ const index = Number(input?.index)
2058
+ const removed = versionTextAt(state, index)
2059
+ const remaining = deleteVersionAt(state, index)
2060
+ if (remaining === 0) delete history.personas[id]
2061
+ writeHistory(storePath, history, warn)
2062
+ info(`deleted version ${String(index)} of persona "${persona.name}" (${String(remaining)} left)`)
2063
+ const result = view()
2064
+ result.notice = `已删除第 ${index + 1} 个版本`
2065
+ result.deleted = { index, size: Buffer.byteLength(removed, 'utf8') }
1997
2066
  return result
1998
2067
  },
1999
2068
  /** Merge preferences into the store; every field is optional so one switch can be saved. */
@@ -2006,7 +2075,11 @@ export function apply(ctx, config = {}) {
2006
2075
  },
2007
2076
  save(input) {
2008
2077
  const current = editableStore()
2009
- const next = normalizeInput(input, current)
2078
+ // Fall back to the stored persona, not to the store: a payload that names an id and one field must
2079
+ // keep everything it did not mention, and `{ ...store }` has none of those fields at the top level.
2080
+ const requestedId = input !== null && typeof input?.id === 'string' && input.id !== '' ? input.id : undefined
2081
+ const existing = requestedId === undefined ? undefined : current.personas.find((item) => item.id === requestedId)
2082
+ const next = normalizeInput(input, existing)
2010
2083
  const beforeText = next.id === undefined ? '' : (current.personas.find((item) => item.id === next.id)?.text ?? '')
2011
2084
  const created = next.id === undefined
2012
2085
  let persona
@@ -2038,8 +2111,11 @@ export function apply(ctx, config = {}) {
2038
2111
  }
2039
2112
  }
2040
2113
  commit(current)
2041
- if (next.text !== beforeText) recordIfEnabled(persona.id, beforeText, next.text, created ? 'create' : 'update')
2114
+ const recorded = next.text === beforeText ? { recorded: false } : recordIfEnabled(persona.id, beforeText, next.text, created ? 'create' : 'update')
2042
2115
  const result = view()
2116
+ if (recorded.recorded === false && recorded.duplicateOf !== undefined) {
2117
+ result.notice = `正文与第 ${recorded.duplicateOf + 1} 个版本相同,没有新增版本`
2118
+ }
2043
2119
  if (released.length > 0) {
2044
2120
  result.notice = `「${persona.name}」现在是默认人设;「${released.join('」「')}」已取消默认`
2045
2121
  info(`default persona moved from: ${released.join(', ')}`)
package/src/remote.ts CHANGED
@@ -54,5 +54,6 @@ export const RPC_REMOTE = {
54
54
  method("listVersions", withInput()),
55
55
  method("versionDiff", withInput()),
56
56
  method("restoreVersion", withInput()),
57
+ method("deleteVersion", withInput()),
57
58
  ],
58
59
  }
package/src/typert.ts CHANGED
@@ -45,6 +45,7 @@ export const TYPERT = {
45
45
  { name: "listVersions", kind: "method", signature: "(input: unknown): Promise<unknown>" },
46
46
  { name: "versionDiff", kind: "method", signature: "(input: unknown): Promise<unknown>" },
47
47
  { name: "restoreVersion", kind: "method", signature: "(input: unknown): Promise<unknown>" },
48
+ { name: "deleteVersion", kind: "method", signature: "(input: unknown): Promise<unknown>" },
48
49
  ],
49
50
  types: [],
50
51
  },
@@ -272,5 +273,21 @@ export const TYPERT = {
272
273
  ],
273
274
  result: result("Json"),
274
275
  },
276
+ {
277
+ id: `dsh-agent-persona#agentPersona/deleteVersion`,
278
+ service: "agentPersona",
279
+ namespace: "agentPersona",
280
+ method: "deleteVersion",
281
+ invocation: direct,
282
+ parameters: [
283
+ {
284
+ name: "input",
285
+ wire: "input",
286
+ source: "json",
287
+ codec: jsonCodec("Json"),
288
+ },
289
+ ],
290
+ result: result("Json"),
291
+ },
275
292
  ],
276
293
  }