dsh-retrace 0.4.11 → 0.4.13

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/bin/retrace.mjs CHANGED
File without changes
package/lib/badge.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * dsh-retrace — lib/badge.js
3
+ *
4
+ * 会话短码(identity badge)——2026-08-31 用户指定的小功能。
5
+ *
6
+ * 需求:与人交互时需要稳定的会话识别符(标题会变、session id 太长)。
7
+ * 解法:**从 session id(唯一值)确定性导出短码**——不依赖全量扫描、
8
+ * 不依赖外部短码表、插件运行时自算即可。同一 session id 永远得到同一短码。
9
+ *
10
+ * 与 `会话短码表.json`(工作区+序号语义格式 dx023dx001)的关系:
11
+ * - 外部表 = 人工维护的语义短码(线名/父子关系可读),由修复线维护;
12
+ * - 本模块 = 插件自算的确定性短码(人机交互兜底),任何环境可复现;
13
+ * - 两者并存:铭牌优先显示外部语义短码(若可查),否则用本模块兜底。
14
+ *
15
+ * 算法:FNV-1a 64-bit(uuid 段)→ base36 → 固定 10 位(补 0)。
16
+ * - 10 位纯字母数字(36^10 ≈ 3.6e15 空间,会话规模下碰撞可忽略);
17
+ * - 只依赖 id 本身,无状态、无 IO、纯函数(可测试、可在 client/host 复用)。
18
+ */
19
+
20
+ /** 提取 id 中的 uuid 段(hex),无匹配则退回 hex 清洗。 */
21
+ export function uuidOf(id) {
22
+ const text = String(id ?? '')
23
+ const m = text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)
24
+ if (m) return m[0].replace(/-/g, '').toLowerCase()
25
+ const cleaned = text.replace(/[^0-9a-f]/gi, '').toLowerCase()
26
+ return cleaned.length >= 8 ? cleaned.slice(0, 32) : ''
27
+ }
28
+
29
+ /** FNV-1a 64-bit(BigInt,避免精度丢失)。 */
30
+ export function fnv1a64(text) {
31
+ let hash = 0xcbf29ce484222325n
32
+ const prime = 0x100000001b3n
33
+ const mask = 0xffffffffffffffffn
34
+ for (let i = 0; i < text.length; i++) {
35
+ hash ^= BigInt(text.charCodeAt(i))
36
+ hash = (hash * prime) & mask
37
+ }
38
+ return hash
39
+ }
40
+
41
+ const BASE36 = '0123456789abcdefghijklmnopqrstuvwxyz'
42
+
43
+ /** BigInt → base36 字符串。 */
44
+ export function toBase36(value) {
45
+ if (value < 0n) value = -value
46
+ if (value === 0n) return '0'
47
+ let out = ''
48
+ let v = value
49
+ while (v > 0n) {
50
+ out = BASE36[Number(v % 36n)] + out
51
+ v /= 36n
52
+ }
53
+ return out
54
+ }
55
+
56
+ /**
57
+ * 会话短码:uuid 段 → FNV-1a 64 → base36 → 固定 10 位(补 0 截断)。
58
+ * @param {string} sessionId - 会话 id(`session-<uuid>` 或裸 uuid)。
59
+ * @returns {string} 10 位短码;无法提取 uuid 时返回空串(调用方自行兜底)。
60
+ */
61
+ export function sessionBadge(sessionId) {
62
+ const uuid = uuidOf(sessionId)
63
+ if (uuid.length === 0) return ''
64
+ return toBase36(fnv1a64(uuid)).padStart(10, '0').slice(0, 10)
65
+ }
@@ -32,6 +32,44 @@ __export(client_exports, {
32
32
  });
33
33
  module.exports = __toCommonJS(client_exports);
34
34
  var import_react = require("react");
35
+
36
+ // lib/badge.js
37
+ function uuidOf(id) {
38
+ const text = String(id ?? "");
39
+ const m = text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
40
+ if (m) return m[0].replace(/-/g, "").toLowerCase();
41
+ const cleaned = text.replace(/[^0-9a-f]/gi, "").toLowerCase();
42
+ return cleaned.length >= 8 ? cleaned.slice(0, 32) : "";
43
+ }
44
+ function fnv1a64(text) {
45
+ let hash = 0xcbf29ce484222325n;
46
+ const prime = 0x100000001b3n;
47
+ const mask = 0xffffffffffffffffn;
48
+ for (let i = 0; i < text.length; i++) {
49
+ hash ^= BigInt(text.charCodeAt(i));
50
+ hash = hash * prime & mask;
51
+ }
52
+ return hash;
53
+ }
54
+ var BASE36 = "0123456789abcdefghijklmnopqrstuvwxyz";
55
+ function toBase36(value) {
56
+ if (value < 0n) value = -value;
57
+ if (value === 0n) return "0";
58
+ let out = "";
59
+ let v = value;
60
+ while (v > 0n) {
61
+ out = BASE36[Number(v % 36n)] + out;
62
+ v /= 36n;
63
+ }
64
+ return out;
65
+ }
66
+ function sessionBadge(sessionId) {
67
+ const uuid = uuidOf(sessionId);
68
+ if (uuid.length === 0) return "";
69
+ return toBase36(fnv1a64(uuid)).padStart(10, "0").slice(0, 10);
70
+ }
71
+
72
+ // lib/client.js
35
73
  var name = "dsh-retrace";
36
74
  var inject = ["slots", "locale", "conversationEvents"];
37
75
  var NS = "retrace";
@@ -139,7 +177,9 @@ var zh = {
139
177
  "fork.lineageThis": "\u5F53\u524D\u4F1A\u8BDD",
140
178
  "fork.lineageParent": "\u7236\u4F1A\u8BDD",
141
179
  "fork.lineageRoot": "\u6839\u4F1A\u8BDD",
142
- "fork.lineageEmpty": "\u672C\u4F1A\u8BDD\u6CA1\u6709\u7236\u4F1A\u8BDD\uFF08\u72EC\u7ACB\u6839\u4F1A\u8BDD\uFF09\u3002"
180
+ "fork.lineageEmpty": "\u672C\u4F1A\u8BDD\u6CA1\u6709\u7236\u4F1A\u8BDD\uFF08\u72EC\u7ACB\u6839\u4F1A\u8BDD\uFF09\u3002",
181
+ "fork.badge": "\u4F1A\u8BDD\u77ED\u7801",
182
+ "fork.badgeHint": "\u77ED\u7801\u7531\u4F1A\u8BDD\u552F\u4E00 id \u786E\u5B9A\u6027\u5BFC\u51FA\uFF0C\u6C38\u4E0D\u53D8\uFF08\u6807\u9898\u4F1A\u53D8\uFF09\u3002"
143
183
  };
144
184
  var en = {
145
185
  "action.edit": "Edit",
@@ -233,7 +273,9 @@ var en = {
233
273
  "fork.lineageThis": "This session",
234
274
  "fork.lineageParent": "Parent session",
235
275
  "fork.lineageRoot": "Root session",
236
- "fork.lineageEmpty": "This session has no parent (standalone root)."
276
+ "fork.lineageEmpty": "This session has no parent (standalone root).",
277
+ "fork.badge": "Session badge",
278
+ "fork.badgeHint": "A stable shortcode derived from the session id (titles change, badges never do)."
237
279
  };
238
280
  var SURFACE_TYPES = /* @__PURE__ */ new Set(["user/message", "assistant/message", "tool/result"]);
239
281
  function isReplacementSurfaceEvent(event) {
@@ -415,7 +457,9 @@ var recallMarkerDefinition = {
415
457
  // the action-row suppression check (useShadowed) but empty for legacy
416
458
  // so no row is hidden and no action row is suppressed for legacy
417
459
  // markers (rename must never make visible content disappear).
418
- shadowedSeqs: legacy ? [] : Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs.slice() : [],
460
+ // 2026-09-01:优先读 data.shadowedSeqs(host 冗余写入),fallback 顶层
461
+ // sourceEventSeqs——部分客户端事件管道剥离顶层字段导致隐藏失效。
462
+ shadowedSeqs: legacy ? [] : Array.isArray(event.data?.shadowedSeqs) ? event.data.shadowedSeqs.slice() : Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs.slice() : [],
419
463
  targetSeq: event.data?.editor?.targetSeq,
420
464
  text: event.data?.editor?.text
421
465
  };
@@ -931,6 +975,10 @@ var CSS = `
931
975
  .dsh-rt-fork-lineage-id{font-family:var(--dsw-font-mono);font-size:11px;line-height:16px;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
932
976
  .dsh-rt-fork-lineage-arrow{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);flex:none}
933
977
  .dsh-rt-fork-lineage-empty{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;flex:none}
978
+ .dsh-rt-fork-badge{display:flex;align-items:center;gap:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated);border-radius:8px;padding:6px 8px;flex:none;min-width:0}
979
+ .dsh-rt-fork-badge-tag{font-size:11px;font-weight:500;line-height:16px;color:var(--dsw-alias-label-secondary);flex:none}
980
+ .dsh-rt-fork-badge-code{font-family:var(--dsw-font-mono);font-size:12px;font-weight:600;line-height:16px;color:var(--dsw-alias-state-info-primary);flex:none}
981
+ .dsh-rt-fork-badge-id{font-family:var(--dsw-font-mono);font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
934
982
  `;
935
983
  var JUMP_PAGE_BUDGET = 24;
936
984
  function switchToViewTab(viewId) {
@@ -1316,6 +1364,12 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1316
1364
  onClick: refresh
1317
1365
  }, t("fork.refresh"))
1318
1366
  ]),
1367
+ // 会话铭牌(2026-08-31):短码 = 从 session id 确定性导出,永不变(标题会变)。
1368
+ sessionId && (0, import_react.createElement)("div", { key: "badge", className: "dsh-rt-fork-badge", title: t("fork.badgeHint") }, [
1369
+ (0, import_react.createElement)("span", { key: "tag", className: "dsh-rt-fork-badge-tag" }, t("fork.badge")),
1370
+ (0, import_react.createElement)("code", { key: "code", className: "dsh-rt-fork-badge-code" }, `[${sessionBadge(sessionId)}]`),
1371
+ (0, import_react.createElement)("span", { key: "id", className: "dsh-rt-fork-badge-id" }, sessionId)
1372
+ ]),
1319
1373
  Array.isArray(lineage) && lineage.length > 1 && (0, import_react.createElement)("div", { key: "lineage", className: "dsh-rt-fork-lineage" }, [
1320
1374
  (0, import_react.createElement)("div", { key: "lt", className: "dsh-rt-fork-spine-label" }, t("fork.lineage")),
1321
1375
  lineage.map((hop, i) => (0, import_react.createElement)("div", {
@@ -1327,6 +1381,7 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1327
1381
  { key: "tag", className: "dsh-rt-fork-lineage-tag" },
1328
1382
  i === 0 ? t("fork.lineageThis") : i === lineage.length - 1 ? t("fork.lineageRoot") : t("fork.lineageParent")
1329
1383
  ),
1384
+ (0, import_react.createElement)("code", { key: "badge", className: "dsh-rt-fork-badge-code" }, `[${sessionBadge(hop.id)}]`),
1330
1385
  (0, import_react.createElement)("span", { key: "id", className: "dsh-rt-fork-lineage-id" }, hop.id),
1331
1386
  i < lineage.length - 1 && (0, import_react.createElement)("span", { key: "arrow", className: "dsh-rt-fork-lineage-arrow" }, "\u2190")
1332
1387
  ]))
package/lib/client.js CHANGED
@@ -16,6 +16,7 @@
16
16
  * `/api/plugins/retrace/*` registered by the Host half.
17
17
  */
18
18
  import { createElement, useEffect, useState } from 'react'
19
+ import { sessionBadge } from './badge.js'
19
20
 
20
21
  export const name = 'dsh-retrace'
21
22
  export const inject = ['slots', 'locale', 'conversationEvents']
@@ -148,6 +149,8 @@ const zh = {
148
149
  'fork.lineageParent': '父会话',
149
150
  'fork.lineageRoot': '根会话',
150
151
  'fork.lineageEmpty': '本会话没有父会话(独立根会话)。',
152
+ 'fork.badge': '会话短码',
153
+ 'fork.badgeHint': '短码由会话唯一 id 确定性导出,永不变(标题会变)。',
151
154
  }
152
155
  /** English dictionary, checked complete against the zh key set. */
153
156
  const en = {
@@ -243,6 +246,8 @@ const en = {
243
246
  'fork.lineageParent': 'Parent session',
244
247
  'fork.lineageRoot': 'Root session',
245
248
  'fork.lineageEmpty': 'This session has no parent (standalone root).',
249
+ 'fork.badge': 'Session badge',
250
+ 'fork.badgeHint': 'A stable shortcode derived from the session id (titles change, badges never do).',
246
251
  }
247
252
 
248
253
  // ---------------------------------------------------------------------------
@@ -503,7 +508,9 @@ const recallMarkerDefinition = {
503
508
  // the action-row suppression check (useShadowed) but empty for legacy
504
509
  // so no row is hidden and no action row is suppressed for legacy
505
510
  // markers (rename must never make visible content disappear).
506
- shadowedSeqs: legacy ? [] : (Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs.slice() : []),
511
+ // 2026-09-01:优先读 data.shadowedSeqs(host 冗余写入),fallback 顶层
512
+ // sourceEventSeqs——部分客户端事件管道剥离顶层字段导致隐藏失效。
513
+ shadowedSeqs: legacy ? [] : (Array.isArray(event.data?.shadowedSeqs) ? event.data.shadowedSeqs.slice() : (Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs.slice() : [])),
507
514
  targetSeq: event.data?.editor?.targetSeq,
508
515
  text: event.data?.editor?.text,
509
516
  }
@@ -1164,6 +1171,10 @@ const CSS = `
1164
1171
  .dsh-rt-fork-lineage-id{font-family:var(--dsw-font-mono);font-size:11px;line-height:16px;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1165
1172
  .dsh-rt-fork-lineage-arrow{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);flex:none}
1166
1173
  .dsh-rt-fork-lineage-empty{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;flex:none}
1174
+ .dsh-rt-fork-badge{display:flex;align-items:center;gap:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated);border-radius:8px;padding:6px 8px;flex:none;min-width:0}
1175
+ .dsh-rt-fork-badge-tag{font-size:11px;font-weight:500;line-height:16px;color:var(--dsw-alias-label-secondary);flex:none}
1176
+ .dsh-rt-fork-badge-code{font-family:var(--dsw-font-mono);font-size:12px;font-weight:600;line-height:16px;color:var(--dsw-alias-state-info-primary);flex:none}
1177
+ .dsh-rt-fork-badge-id{font-family:var(--dsw-font-mono);font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1167
1178
  `
1168
1179
 
1169
1180
  const JUMP_PAGE_BUDGET = 24
@@ -1726,6 +1737,13 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1726
1737
  }, t('fork.refresh')),
1727
1738
  ]),
1728
1739
 
1740
+ // 会话铭牌(2026-08-31):短码 = 从 session id 确定性导出,永不变(标题会变)。
1741
+ sessionId && createElement('div', { key: 'badge', className: 'dsh-rt-fork-badge', title: t('fork.badgeHint') }, [
1742
+ createElement('span', { key: 'tag', className: 'dsh-rt-fork-badge-tag' }, t('fork.badge')),
1743
+ createElement('code', { key: 'code', className: 'dsh-rt-fork-badge-code' }, `[${sessionBadge(sessionId)}]`),
1744
+ createElement('span', { key: 'id', className: 'dsh-rt-fork-badge-id' }, sessionId),
1745
+ ]),
1746
+
1729
1747
  Array.isArray(lineage) && lineage.length > 1 && createElement('div', { key: 'lineage', className: 'dsh-rt-fork-lineage' }, [
1730
1748
  createElement('div', { key: 'lt', className: 'dsh-rt-fork-spine-label' }, t('fork.lineage')),
1731
1749
  lineage.map((hop, i) => createElement('div', {
@@ -1734,6 +1752,7 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1734
1752
  }, [
1735
1753
  createElement('span', { key: 'tag', className: 'dsh-rt-fork-lineage-tag' },
1736
1754
  i === 0 ? t('fork.lineageThis') : (i === lineage.length - 1 ? t('fork.lineageRoot') : t('fork.lineageParent'))),
1755
+ createElement('code', { key: 'badge', className: 'dsh-rt-fork-badge-code' }, `[${sessionBadge(hop.id)}]`),
1737
1756
  createElement('span', { key: 'id', className: 'dsh-rt-fork-lineage-id' }, hop.id),
1738
1757
  i < lineage.length - 1 && createElement('span', { key: 'arrow', className: 'dsh-rt-fork-lineage-arrow' }, '←'),
1739
1758
  ])),
@@ -38,6 +38,44 @@ return {
38
38
  });
39
39
  module.exports = __toCommonJS(client_exports);
40
40
  var import_react = require("react");
41
+
42
+ // lib/badge.js
43
+ function uuidOf(id) {
44
+ const text = String(id ?? "");
45
+ const m = text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
46
+ if (m) return m[0].replace(/-/g, "").toLowerCase();
47
+ const cleaned = text.replace(/[^0-9a-f]/gi, "").toLowerCase();
48
+ return cleaned.length >= 8 ? cleaned.slice(0, 32) : "";
49
+ }
50
+ function fnv1a64(text) {
51
+ let hash = 0xcbf29ce484222325n;
52
+ const prime = 0x100000001b3n;
53
+ const mask = 0xffffffffffffffffn;
54
+ for (let i = 0; i < text.length; i++) {
55
+ hash ^= BigInt(text.charCodeAt(i));
56
+ hash = hash * prime & mask;
57
+ }
58
+ return hash;
59
+ }
60
+ var BASE36 = "0123456789abcdefghijklmnopqrstuvwxyz";
61
+ function toBase36(value) {
62
+ if (value < 0n) value = -value;
63
+ if (value === 0n) return "0";
64
+ let out = "";
65
+ let v = value;
66
+ while (v > 0n) {
67
+ out = BASE36[Number(v % 36n)] + out;
68
+ v /= 36n;
69
+ }
70
+ return out;
71
+ }
72
+ function sessionBadge(sessionId) {
73
+ const uuid = uuidOf(sessionId);
74
+ if (uuid.length === 0) return "";
75
+ return toBase36(fnv1a64(uuid)).padStart(10, "0").slice(0, 10);
76
+ }
77
+
78
+ // lib/client.js
41
79
  var name = "dsh-retrace";
42
80
  var inject = ["slots", "locale", "conversationEvents"];
43
81
  var NS = "retrace";
@@ -145,7 +183,9 @@ return {
145
183
  "fork.lineageThis": "\u5F53\u524D\u4F1A\u8BDD",
146
184
  "fork.lineageParent": "\u7236\u4F1A\u8BDD",
147
185
  "fork.lineageRoot": "\u6839\u4F1A\u8BDD",
148
- "fork.lineageEmpty": "\u672C\u4F1A\u8BDD\u6CA1\u6709\u7236\u4F1A\u8BDD\uFF08\u72EC\u7ACB\u6839\u4F1A\u8BDD\uFF09\u3002"
186
+ "fork.lineageEmpty": "\u672C\u4F1A\u8BDD\u6CA1\u6709\u7236\u4F1A\u8BDD\uFF08\u72EC\u7ACB\u6839\u4F1A\u8BDD\uFF09\u3002",
187
+ "fork.badge": "\u4F1A\u8BDD\u77ED\u7801",
188
+ "fork.badgeHint": "\u77ED\u7801\u7531\u4F1A\u8BDD\u552F\u4E00 id \u786E\u5B9A\u6027\u5BFC\u51FA\uFF0C\u6C38\u4E0D\u53D8\uFF08\u6807\u9898\u4F1A\u53D8\uFF09\u3002"
149
189
  };
150
190
  var en = {
151
191
  "action.edit": "Edit",
@@ -239,7 +279,9 @@ return {
239
279
  "fork.lineageThis": "This session",
240
280
  "fork.lineageParent": "Parent session",
241
281
  "fork.lineageRoot": "Root session",
242
- "fork.lineageEmpty": "This session has no parent (standalone root)."
282
+ "fork.lineageEmpty": "This session has no parent (standalone root).",
283
+ "fork.badge": "Session badge",
284
+ "fork.badgeHint": "A stable shortcode derived from the session id (titles change, badges never do)."
243
285
  };
244
286
  var SURFACE_TYPES = /* @__PURE__ */ new Set(["user/message", "assistant/message", "tool/result"]);
245
287
  function isReplacementSurfaceEvent(event) {
@@ -421,7 +463,9 @@ return {
421
463
  // the action-row suppression check (useShadowed) but empty for legacy
422
464
  // so no row is hidden and no action row is suppressed for legacy
423
465
  // markers (rename must never make visible content disappear).
424
- shadowedSeqs: legacy ? [] : Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs.slice() : [],
466
+ // 2026-09-01:优先读 data.shadowedSeqs(host 冗余写入),fallback 顶层
467
+ // sourceEventSeqs——部分客户端事件管道剥离顶层字段导致隐藏失效。
468
+ shadowedSeqs: legacy ? [] : Array.isArray(event.data?.shadowedSeqs) ? event.data.shadowedSeqs.slice() : Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs.slice() : [],
425
469
  targetSeq: event.data?.editor?.targetSeq,
426
470
  text: event.data?.editor?.text
427
471
  };
@@ -937,6 +981,10 @@ return {
937
981
  .dsh-rt-fork-lineage-id{font-family:var(--dsw-font-mono);font-size:11px;line-height:16px;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
938
982
  .dsh-rt-fork-lineage-arrow{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);flex:none}
939
983
  .dsh-rt-fork-lineage-empty{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;flex:none}
984
+ .dsh-rt-fork-badge{display:flex;align-items:center;gap:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated);border-radius:8px;padding:6px 8px;flex:none;min-width:0}
985
+ .dsh-rt-fork-badge-tag{font-size:11px;font-weight:500;line-height:16px;color:var(--dsw-alias-label-secondary);flex:none}
986
+ .dsh-rt-fork-badge-code{font-family:var(--dsw-font-mono);font-size:12px;font-weight:600;line-height:16px;color:var(--dsw-alias-state-info-primary);flex:none}
987
+ .dsh-rt-fork-badge-id{font-family:var(--dsw-font-mono);font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
940
988
  `;
941
989
  var JUMP_PAGE_BUDGET = 24;
942
990
  function switchToViewTab(viewId) {
@@ -1322,6 +1370,12 @@ return {
1322
1370
  onClick: refresh
1323
1371
  }, t("fork.refresh"))
1324
1372
  ]),
1373
+ // 会话铭牌(2026-08-31):短码 = 从 session id 确定性导出,永不变(标题会变)。
1374
+ sessionId && (0, import_react.createElement)("div", { key: "badge", className: "dsh-rt-fork-badge", title: t("fork.badgeHint") }, [
1375
+ (0, import_react.createElement)("span", { key: "tag", className: "dsh-rt-fork-badge-tag" }, t("fork.badge")),
1376
+ (0, import_react.createElement)("code", { key: "code", className: "dsh-rt-fork-badge-code" }, `[${sessionBadge(sessionId)}]`),
1377
+ (0, import_react.createElement)("span", { key: "id", className: "dsh-rt-fork-badge-id" }, sessionId)
1378
+ ]),
1325
1379
  Array.isArray(lineage) && lineage.length > 1 && (0, import_react.createElement)("div", { key: "lineage", className: "dsh-rt-fork-lineage" }, [
1326
1380
  (0, import_react.createElement)("div", { key: "lt", className: "dsh-rt-fork-spine-label" }, t("fork.lineage")),
1327
1381
  lineage.map((hop, i) => (0, import_react.createElement)("div", {
@@ -1333,6 +1387,7 @@ return {
1333
1387
  { key: "tag", className: "dsh-rt-fork-lineage-tag" },
1334
1388
  i === 0 ? t("fork.lineageThis") : i === lineage.length - 1 ? t("fork.lineageRoot") : t("fork.lineageParent")
1335
1389
  ),
1390
+ (0, import_react.createElement)("code", { key: "badge", className: "dsh-rt-fork-badge-code" }, `[${sessionBadge(hop.id)}]`),
1336
1391
  (0, import_react.createElement)("span", { key: "id", className: "dsh-rt-fork-lineage-id" }, hop.id),
1337
1392
  i < lineage.length - 1 && (0, import_react.createElement)("span", { key: "arrow", className: "dsh-rt-fork-lineage-arrow" }, "\u2190")
1338
1393
  ]))
@@ -142,6 +142,10 @@ return {
142
142
  }
143
143
  const surfaceOp = { op: 'replace', start: span.start, end: span.end }
144
144
  const sourceEventSeqs = span.shadowedSeqs.slice()
145
+ // 2026-09-01:把遮蔽 seq 冗余进 data.shadowedSeqs——部分客户端事件管道会剥离
146
+ // event 顶层 sourceEventSeqs(0.4.12 实测:撤回/编辑后消息不隐藏 = client 拿不到
147
+ // shadowedSeqs)。data 里的字段随事件体持久化,client 读 data 更稳(fallback 顶层)。
148
+ data.shadowedSeqs = sourceEventSeqs
145
149
  if (typeof validate === 'function') {
146
150
  const result = await validate(session, { type: 'assistant/message', data, surfaceOp, sourceEventSeqs })
147
151
  if (result && result.t1Ok === false) {
package/lib/host-core.js CHANGED
@@ -133,6 +133,10 @@ export async function appendEditorMarker(session, span, op, targetSeq, originalT
133
133
  }
134
134
  const surfaceOp = { op: 'replace', start: span.start, end: span.end }
135
135
  const sourceEventSeqs = span.shadowedSeqs.slice()
136
+ // 2026-09-01:把遮蔽 seq 冗余进 data.shadowedSeqs——部分客户端事件管道会剥离
137
+ // event 顶层 sourceEventSeqs(0.4.12 实测:撤回/编辑后消息不隐藏 = client 拿不到
138
+ // shadowedSeqs)。data 里的字段随事件体持久化,client 读 data 更稳(fallback 顶层)。
139
+ data.shadowedSeqs = sourceEventSeqs
136
140
  if (typeof validate === 'function') {
137
141
  const result = await validate(session, { type: 'assistant/message', data, surfaceOp, sourceEventSeqs })
138
142
  if (result && result.t1Ok === false) {
package/lib/http.js CHANGED
@@ -81,8 +81,8 @@ function sendError(res, error) {
81
81
  }
82
82
 
83
83
  /** Route one request. `seam` is the versioning seam (lib/versioning.js). */
84
- export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback, log = () => {} }) {
85
- const api = createEditorApi(ctx, sessions, agents, log)
84
+ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback, hooks = {}, log = () => {} }) {
85
+ const api = createEditorApi(ctx, sessions, agents, log, hooks)
86
86
 
87
87
  function handleVersions(req, res, sessionId, config) {
88
88
  seam.setConfig(sessionId, config)
package/lib/index.js CHANGED
@@ -25,6 +25,8 @@ import { createVersioningSeam } from './versioning.js'
25
25
  import { createRollbackExecutor } from './rollback.js'
26
26
  import { createMarkerGuard } from './prewrite-guard.js'
27
27
  import { createWatchdog } from './watchdog.js'
28
+ import { attachExitWarning } from './interrupt-guard.js'
29
+ import { sessionBadge } from './badge.js'
28
30
 
29
31
  export const name = 'dsh-retrace'
30
32
  export const inject = ['sessions', 'agents', 'webServer', 'fs', 'subprocess', 'sandboxPolicy']
@@ -52,6 +54,7 @@ export function apply(ctx) {
52
54
  agents: ctx.agents,
53
55
  seam,
54
56
  rollback,
57
+ hooks,
55
58
  log,
56
59
  })
57
60
 
@@ -78,6 +81,11 @@ export function apply(ctx) {
78
81
  harness.handle('retrace.recall', (args) => api.recall(args)),
79
82
  harness.handle('retrace.editAndResend', (args) => api.editAndResend(args)),
80
83
  harness.handle('retrace.regenerate', (args) => api.regenerate(args)),
84
+ // 会话短码(2026-08-31):session id → 确定性 10 位短码,人机交互识别用。
85
+ harness.handle('retrace.sessionBadge', (args) => {
86
+ const sessionId = String(args?.sessionId ?? '')
87
+ return { ok: true, value: { sessionId, badge: sessionBadge(sessionId) } }
88
+ }),
81
89
  ]
82
90
  return () => disposers.forEach((dispose) => dispose())
83
91
  })()
@@ -85,9 +93,13 @@ export function apply(ctx) {
85
93
  // R1 实时看门狗:轮询文件尾部 seq vs 内存长度,捕获并发写入/旧光标回放现场。
86
94
  const watchdog = createWatchdog(ctx, log)
87
95
 
96
+ // R4 中断轮次治理:退出/重载时对未闭合 turn 会话提示(只检测不写事件)。
97
+ const warnUnclosed = attachExitWarning(ctx, log)
98
+
88
99
  ctx.effect(() => () => {
89
100
  disposeRoute()
90
101
  disposeHarness()
91
102
  watchdog.dispose()
103
+ warnUnclosed()
92
104
  }, 'dsh-retrace: transports')
93
105
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * dsh-retrace · lib/interrupt-guard.js
3
+ *
4
+ * R4 中断轮次治理(2026-08-30 任务书 §4)。
5
+ *
6
+ * 目标:退出/关闭会话前对「未闭合 turn」的会话提示用户,避免误以为任务
7
+ * 已干净结束。
8
+ *
9
+ * 设计(按实现方案 §R4,铁律「先核实官方 turn 关闭路径」):
10
+ * - 官方 `dsh-agent-loop/lib/index.js:620` 在 finally 块保证 `turn/end`
11
+ * 必然写入(含 reason.kind='error' 的中断)——正常退出/中断路径不会
12
+ * 留下未闭合 turn;
13
+ * - 未闭合 turn 只出现在「崩溃 / 强杀 / 断电」场景:有 turn/start 无
14
+ * turn/end(interrupted/aborted 是官方正常闭合,不在此列);
15
+ * - 因此本守卫**只检测 + 提示,绝不自动写 turn/end**(与官方持久化竞态
16
+ * 会引入双写;且插件不应在运行期补写持久化事件——铁律「插件内不要
17
+ * 删除/重编号持久化事件」的同类约束)。
18
+ *
19
+ * 检测函数:
20
+ * unclosedTurns(session) → { turn, state: 'open' }[]
21
+ * 扫描 session.events:turn/start 开、turn/end 关;尾部仍开 = 未闭合。
22
+ */
23
+
24
+ /**
25
+ * 扫描会话,找未闭合的轮次。
26
+ * @param {object} session - DSH session 对象(session.events 为事件数组)。
27
+ * @returns {Array<{turn: number, state: 'open'}>}
28
+ *
29
+ * ⚠️ 判定(2026-08-31 独立审查修正):只报**真正未闭合**的轮次——有
30
+ * turn/start 且尾部没有对应 turn/end(崩溃/强杀现场)。
31
+ * turn/end 的 reason.kind 为 'interrupted'/'aborted' 是官方**正常闭合**
32
+ * (用户主动中断,agent-loop finally 保证写入),archive 实测 69 会话中
33
+ * 666 aborted + 126 interrupted 全是正常闭合、真 open 只 10 个——把它们
34
+ * 当"未闭合"会在每次退出对 91% 会话打误导性 warning。
35
+ */
36
+ export function unclosedTurns(session) {
37
+ const events = Array.isArray(session?.events) ? session.events : []
38
+ const found = []
39
+ let openTurn = null
40
+ for (const event of events) {
41
+ if (!event || typeof event !== 'object') continue
42
+ if (event.type === 'turn/start') {
43
+ openTurn = event.data?.turn
44
+ } else if (event.type === 'turn/end') {
45
+ openTurn = null
46
+ }
47
+ }
48
+ // 尾部仍有未闭合 turn(崩溃/强杀现场)
49
+ if (openTurn !== null) {
50
+ found.push({ turn: openTurn, state: 'open' })
51
+ }
52
+ return found
53
+ }
54
+
55
+ /**
56
+ * 挂载退出前提示:在插件 dispose 时对「当前 attach 且有未闭合轮次」的会话
57
+ * 记录一条 warning(非阻断)。调用方(apply 的 ctx.effect 清理)负责在
58
+ * 退出/重载时触发。
59
+ *
60
+ * 不做客户端弹窗(非阻断提示由宿主 UI 在会话内呈现——本插件只负责检测与
61
+ * 日志;客户端时间线渲染未闭合轮次的提示属 UI 层,后续可扩展)。
62
+ * @param {object} ctx - cordis ctx。
63
+ * @param {(line: string) => void} log - 日志函数。
64
+ * @param {() => Array<object>} [sessionList] - 活跃会话列表(默认 ctx.sessions)。
65
+ */
66
+ export function attachExitWarning(ctx, log, sessionList) {
67
+ return () => {
68
+ const sessions = typeof sessionList === 'function' ? sessionList() : ctx?.sessions
69
+ if (!sessions || typeof sessions !== 'object') return
70
+ const ids = typeof sessions.keys === 'function' ? [...sessions.keys()] : Object.keys(sessions)
71
+ for (const id of ids) {
72
+ const session = typeof sessions.get === 'function' ? sessions.get(id) : sessions[id]
73
+ if (!session) continue
74
+ const unclosed = unclosedTurns(session)
75
+ if (unclosed.length === 0) continue
76
+ const summary = unclosed.map((u) => `turn ${u.turn} (${u.state})`).join(', ')
77
+ log(`retrace-interrupt-guard: 会话 ${id} 存在未闭合轮次 ${summary}——中断未干净收尾,重启后可能触发旧光标回放;建议退出前先体检(dsh-log-contract check)`)
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * 在给定会话上运行检测(供 CLI/测试直接调用)。
84
+ * @param {object} session - 会话对象或 { events: [...] }。
85
+ * @returns {Array<{turn: number, state: string}>}
86
+ */
87
+ export function detectUnclosed(session) {
88
+ return unclosedTurns(session)
89
+ }
@@ -32,6 +32,45 @@
32
32
  */
33
33
  import { editorError } from './host-core.js'
34
34
 
35
+ /**
36
+ * 回档幅度阈值(快照点守卫,2026-08-31 c2d05ce9 事故闭环)。
37
+ *
38
+ * 业务语义(生产级运行保障,见 工程-生产级运行时/快照点与回档机制-设计-20260831.md):
39
+ * - 一次编辑/撤回/重发遮蔽的 surface 节点占比 > 本阈值 = **回档请求**——
40
+ * 等于把生产基线大幅改写 → 拒绝落盘,引导从快照点创建官方分支;
41
+ * - 与 client 侧 SHADOW_SAFETY_RATIO(0.4)同源:UI 降级判定与写前拦截共用同一阈值;
42
+ * - 阈值可通过 createMarkerGuard({ rollbackRatio }) 覆盖(测试/未来配置)。
43
+ */
44
+ export const ROLLBACK_RATIO = 0.4
45
+
46
+ /**
47
+ * 短会话豁免阈值(2026-08-31 独立审查问题 B):surface 节点 ≤ 6(≈ 3 轮)
48
+ * 的会话不做回档幅度拦截——否则 1-2 轮新会话编辑/撤回/重发 100% 被拒,
49
+ * 现有功能回归。事故防御针对大会话的「编辑早期消息遮蔽几十轮」,短会话无此风险。
50
+ */
51
+ export const ROLLBACK_MIN_SURFACE = 6
52
+
53
+ /**
54
+ * 该 marker 是否来自 rollback(restore op)——rollback 本身就是「从快照点
55
+ * 回档」的官方机制,不应再被回档幅度守卫拦截(否则大范围回档永远不可用,
56
+ * 且错误文案引导「打分支」而插件内无此操作 = 死路)。2026-08-31 独立审查问题 A。
57
+ */
58
+ export function isRestoreMarker(envelope) {
59
+ const id = envelope?.data?.message?.id
60
+ return typeof id === 'string' && id.startsWith('retrace-restore-')
61
+ }
62
+
63
+ /** 计算一次 replace 的遮蔽占比(0..1)。无 surface/无 sourceEventSeqs/短会话 → 0(不拦截)。 */
64
+ export function rollbackShareOf(session, envelope) {
65
+ if (!session || !envelope) return 0
66
+ const shadowed = Array.isArray(envelope.sourceEventSeqs) ? envelope.sourceEventSeqs.length : 0
67
+ if (shadowed <= 0) return 0
68
+ const surfaceNodes = session.surface?.nodes
69
+ const total = Array.isArray(surfaceNodes) ? surfaceNodes.length : 0
70
+ if (total <= ROLLBACK_MIN_SURFACE) return 0 // 短会话豁免(问题 B)
71
+ return shadowed / total
72
+ }
73
+
35
74
  /** R2 —— T1 折叠自检:与 dsh-token-meter/_foldEvent 及 checks.js T1 同语义。 */
36
75
  export function tokenMeterFoldOk(events) {
37
76
  // 与 checks.js:372 同语义:无任何 step/start 的日志(极早期格式/简化夹具)
@@ -61,7 +100,7 @@ export function tokenMeterFoldOk(events) {
61
100
  * @param {(sessionId: string) => boolean} [options.enabled] 门控(默认恒 true)。
62
101
  * @returns {{ validateMarkerAppend(session, envelope): Promise<void> }}
63
102
  */
64
- export function createMarkerGuard({ log = () => {}, prewriterFactory, enabled = () => true } = {}) {
103
+ export function createMarkerGuard({ log = () => {}, prewriterFactory, enabled = () => true, rollbackRatio = ROLLBACK_RATIO } = {}) {
65
104
  let factory = prewriterFactory ?? null
66
105
  return {
67
106
  /**
@@ -72,6 +111,27 @@ export function createMarkerGuard({ log = () => {}, prewriterFactory, enabled =
72
111
  */
73
112
  async validateMarkerAppend(session, envelope) {
74
113
  if (typeof enabled === 'function' && enabled(session?.id) === false) return { t1Ok: true }
114
+ // 快照点守卫(2026-08-31 c2d05ce9 事故闭环):回档请求(遮蔽 > 阈值)
115
+ // 拒绝落盘——生产基线不支持原地大幅改写,引导从快照点创建官方分支。
116
+ // 在契约校验之前独立检查(业务规则,与合法性正交);host 层强制,UI 绕过也拦。
117
+ // 豁免(2026-08-31 独立审查):restore(rollback 回档本身合法)与短会话(≤3 轮)。
118
+ if (
119
+ envelope?.surfaceOp?.op === 'replace' &&
120
+ Array.isArray(envelope.sourceEventSeqs) &&
121
+ !isRestoreMarker(envelope)
122
+ ) {
123
+ const share = rollbackShareOf(session, envelope)
124
+ if (share > rollbackRatio) {
125
+ const pct = Math.round(share * 100)
126
+ log(
127
+ `retrace: rollback guard — replace shadows ${envelope.sourceEventSeqs.length}/${session?.surface?.nodes?.length ?? '?'} surface nodes (${pct}% > ${Math.round(rollbackRatio * 100)}%); refusing in-place rewrite; guide user to fork a branch from the snapshot point`,
128
+ )
129
+ throw editorError(
130
+ 'rollback-guide',
131
+ `此操作将回档到较早的快照点(遮蔽 ${pct}% 的对话)。生产基线不支持原地大幅改写;请从快照点创建会话分支,在新分支中继续——原会话完整保留。`,
132
+ )
133
+ }
134
+ }
75
135
  if (factory === null) {
76
136
  try {
77
137
  factory = (await import('dsh-log-contract')).createPreWriter
package/package.json CHANGED
@@ -1,10 +1,17 @@
1
1
  {
2
2
  "name": "dsh-retrace",
3
3
  "description": "Retrace · 回溯 — Recall, edit-and-resend, regenerate, and conversation/artifact versioning (timeline, rollback, fork map) for DeepSeek Harness — Web and Desktop",
4
- "version": "0.4.11",
4
+ "version": "0.4.13",
5
+ "packageManager": "pnpm@11.7.0",
5
6
  "type": "module",
6
7
  "main": "lib/index.js",
7
8
  "types": "lib/types/index.d.ts",
9
+ "scripts": {
10
+ "build": "node scripts/generate-dynamic.mjs && node scripts/build-client.mjs",
11
+ "check": "node scripts/check-syntax.mjs && node scripts/check-dynamic.mjs",
12
+ "test": "vitest run",
13
+ "prepublishOnly": "pnpm check && pnpm build && pnpm test"
14
+ },
8
15
  "exports": {
9
16
  ".": {
10
17
  "default": "./lib/index.js"
@@ -78,7 +85,7 @@
78
85
  "react": "^18.2.0"
79
86
  },
80
87
  "dependencies": {
81
- "dsh-log-contract": "^0.3.0",
88
+ "dsh-log-contract": "^0.3.7",
82
89
  "zod": "^4.4.3"
83
90
  },
84
91
  "repository": {
@@ -95,10 +102,5 @@
95
102
  },
96
103
  "bin": {
97
104
  "retrace": "bin/retrace.mjs"
98
- },
99
- "scripts": {
100
- "build": "node scripts/generate-dynamic.mjs && node scripts/build-client.mjs",
101
- "check": "node scripts/check-syntax.mjs && node scripts/check-dynamic.mjs",
102
- "test": "vitest run"
103
105
  }
104
- }
106
+ }