dsh-retrace 0.4.11 → 0.4.12

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) {
@@ -931,6 +973,10 @@ var CSS = `
931
973
  .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
974
  .dsh-rt-fork-lineage-arrow{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);flex:none}
933
975
  .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}
976
+ .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}
977
+ .dsh-rt-fork-badge-tag{font-size:11px;font-weight:500;line-height:16px;color:var(--dsw-alias-label-secondary);flex:none}
978
+ .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}
979
+ .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
980
  `;
935
981
  var JUMP_PAGE_BUDGET = 24;
936
982
  function switchToViewTab(viewId) {
@@ -1316,6 +1362,12 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1316
1362
  onClick: refresh
1317
1363
  }, t("fork.refresh"))
1318
1364
  ]),
1365
+ // 会话铭牌(2026-08-31):短码 = 从 session id 确定性导出,永不变(标题会变)。
1366
+ sessionId && (0, import_react.createElement)("div", { key: "badge", className: "dsh-rt-fork-badge", title: t("fork.badgeHint") }, [
1367
+ (0, import_react.createElement)("span", { key: "tag", className: "dsh-rt-fork-badge-tag" }, t("fork.badge")),
1368
+ (0, import_react.createElement)("code", { key: "code", className: "dsh-rt-fork-badge-code" }, `[${sessionBadge(sessionId)}]`),
1369
+ (0, import_react.createElement)("span", { key: "id", className: "dsh-rt-fork-badge-id" }, sessionId)
1370
+ ]),
1319
1371
  Array.isArray(lineage) && lineage.length > 1 && (0, import_react.createElement)("div", { key: "lineage", className: "dsh-rt-fork-lineage" }, [
1320
1372
  (0, import_react.createElement)("div", { key: "lt", className: "dsh-rt-fork-spine-label" }, t("fork.lineage")),
1321
1373
  lineage.map((hop, i) => (0, import_react.createElement)("div", {
@@ -1327,6 +1379,7 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1327
1379
  { key: "tag", className: "dsh-rt-fork-lineage-tag" },
1328
1380
  i === 0 ? t("fork.lineageThis") : i === lineage.length - 1 ? t("fork.lineageRoot") : t("fork.lineageParent")
1329
1381
  ),
1382
+ (0, import_react.createElement)("code", { key: "badge", className: "dsh-rt-fork-badge-code" }, `[${sessionBadge(hop.id)}]`),
1330
1383
  (0, import_react.createElement)("span", { key: "id", className: "dsh-rt-fork-lineage-id" }, hop.id),
1331
1384
  i < lineage.length - 1 && (0, import_react.createElement)("span", { key: "arrow", className: "dsh-rt-fork-lineage-arrow" }, "\u2190")
1332
1385
  ]))
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
  // ---------------------------------------------------------------------------
@@ -1164,6 +1169,10 @@ const CSS = `
1164
1169
  .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
1170
  .dsh-rt-fork-lineage-arrow{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);flex:none}
1166
1171
  .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}
1172
+ .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}
1173
+ .dsh-rt-fork-badge-tag{font-size:11px;font-weight:500;line-height:16px;color:var(--dsw-alias-label-secondary);flex:none}
1174
+ .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}
1175
+ .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
1176
  `
1168
1177
 
1169
1178
  const JUMP_PAGE_BUDGET = 24
@@ -1726,6 +1735,13 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1726
1735
  }, t('fork.refresh')),
1727
1736
  ]),
1728
1737
 
1738
+ // 会话铭牌(2026-08-31):短码 = 从 session id 确定性导出,永不变(标题会变)。
1739
+ sessionId && createElement('div', { key: 'badge', className: 'dsh-rt-fork-badge', title: t('fork.badgeHint') }, [
1740
+ createElement('span', { key: 'tag', className: 'dsh-rt-fork-badge-tag' }, t('fork.badge')),
1741
+ createElement('code', { key: 'code', className: 'dsh-rt-fork-badge-code' }, `[${sessionBadge(sessionId)}]`),
1742
+ createElement('span', { key: 'id', className: 'dsh-rt-fork-badge-id' }, sessionId),
1743
+ ]),
1744
+
1729
1745
  Array.isArray(lineage) && lineage.length > 1 && createElement('div', { key: 'lineage', className: 'dsh-rt-fork-lineage' }, [
1730
1746
  createElement('div', { key: 'lt', className: 'dsh-rt-fork-spine-label' }, t('fork.lineage')),
1731
1747
  lineage.map((hop, i) => createElement('div', {
@@ -1734,6 +1750,7 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
1734
1750
  }, [
1735
1751
  createElement('span', { key: 'tag', className: 'dsh-rt-fork-lineage-tag' },
1736
1752
  i === 0 ? t('fork.lineageThis') : (i === lineage.length - 1 ? t('fork.lineageRoot') : t('fork.lineageParent'))),
1753
+ createElement('code', { key: 'badge', className: 'dsh-rt-fork-badge-code' }, `[${sessionBadge(hop.id)}]`),
1737
1754
  createElement('span', { key: 'id', className: 'dsh-rt-fork-lineage-id' }, hop.id),
1738
1755
  i < lineage.length - 1 && createElement('span', { key: 'arrow', className: 'dsh-rt-fork-lineage-arrow' }, '←'),
1739
1756
  ])),
@@ -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) {
@@ -937,6 +979,10 @@ return {
937
979
  .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
980
  .dsh-rt-fork-lineage-arrow{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);flex:none}
939
981
  .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}
982
+ .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}
983
+ .dsh-rt-fork-badge-tag{font-size:11px;font-weight:500;line-height:16px;color:var(--dsw-alias-label-secondary);flex:none}
984
+ .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}
985
+ .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
986
  `;
941
987
  var JUMP_PAGE_BUDGET = 24;
942
988
  function switchToViewTab(viewId) {
@@ -1322,6 +1368,12 @@ return {
1322
1368
  onClick: refresh
1323
1369
  }, t("fork.refresh"))
1324
1370
  ]),
1371
+ // 会话铭牌(2026-08-31):短码 = 从 session id 确定性导出,永不变(标题会变)。
1372
+ sessionId && (0, import_react.createElement)("div", { key: "badge", className: "dsh-rt-fork-badge", title: t("fork.badgeHint") }, [
1373
+ (0, import_react.createElement)("span", { key: "tag", className: "dsh-rt-fork-badge-tag" }, t("fork.badge")),
1374
+ (0, import_react.createElement)("code", { key: "code", className: "dsh-rt-fork-badge-code" }, `[${sessionBadge(sessionId)}]`),
1375
+ (0, import_react.createElement)("span", { key: "id", className: "dsh-rt-fork-badge-id" }, sessionId)
1376
+ ]),
1325
1377
  Array.isArray(lineage) && lineage.length > 1 && (0, import_react.createElement)("div", { key: "lineage", className: "dsh-rt-fork-lineage" }, [
1326
1378
  (0, import_react.createElement)("div", { key: "lt", className: "dsh-rt-fork-spine-label" }, t("fork.lineage")),
1327
1379
  lineage.map((hop, i) => (0, import_react.createElement)("div", {
@@ -1333,6 +1385,7 @@ return {
1333
1385
  { key: "tag", className: "dsh-rt-fork-lineage-tag" },
1334
1386
  i === 0 ? t("fork.lineageThis") : i === lineage.length - 1 ? t("fork.lineageRoot") : t("fork.lineageParent")
1335
1387
  ),
1388
+ (0, import_react.createElement)("code", { key: "badge", className: "dsh-rt-fork-badge-code" }, `[${sessionBadge(hop.id)}]`),
1336
1389
  (0, import_react.createElement)("span", { key: "id", className: "dsh-rt-fork-lineage-id" }, hop.id),
1337
1390
  i < lineage.length - 1 && (0, import_react.createElement)("span", { key: "arrow", className: "dsh-rt-fork-lineage-arrow" }, "\u2190")
1338
1391
  ]))
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.12",
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"
@@ -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
+ }