dsh-edit-turn 0.3.0 → 0.3.1

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/README.md CHANGED
@@ -164,7 +164,7 @@ ctx.sessions.registerMessageProjection({
164
164
  | 运行中的实例是否真的挂载了本插件(只读路由守卫探针,无需 token) | `npm run probe:loaded [端口]` | 通过:`/state` 返 400、`/apply` 返 405 —— 这两个状态码只有本插件会返回 |
165
165
  | 浏览器半部到底问了什么、拿回了什么(只读诊断,含失败记录) | `GET /dsh-edit-turn/debug` | 通过:真实浏览器里确认客户端确实拿到 replies;并复现了旧 v3 会话的 session-not-found |
166
166
  | 官方 append **与读取**契约(真实校验器,进程内) | `npm run verify:contract` | 96 项通过(每段写入后都用加载器重读整份日志):替换事件被接受、派生历史真的收缩、日志 append-only、工具结果与调用同进同退、空 developer 载体不产生模型消息、**连续两次回退都被接受**、**编辑模型回答的完整机制被接受**、**回复编辑后的尾部重放(后续提问/回答/tool 调用与结果按新 turn 号回到修正回答之后,顺序正确、日志仍可加载)**、**跨插件依赖的不变量(单节点窗口 / 同事件类型 / 全窗口 sourceEventSeqs / 每个替换都带 `editedBy` 标记 / 改写后的 `source.kind` 不是 `user` / 修正回复不带 `sourceEventSeqs`)**,以及**就地补丁:一条 ignorable 记录、surface 不变、去掉投影后原文回来、后续轮次与 tool 结果不动、第二条记录可叠加** |
167
- | 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` | 205 项通过(含回复编辑的尾部重放用例:窗口/重放区间、新 turn 号与新 messageId、tool 调用与结果成对重映射、上一轮修正可再次编辑,以及与 `dsh-rerun-turn` builder 的逐字段对照;另含「文本没变 → 不写」的四条,以及块随消息一起改的 35 条:未动的块逐字节保留、删块少一块、文字位置不变、no-op 语义升级、附件服务缺失时降级且不写坏数据、自定义 `quote-card` 块的通用性、录用与拒绝、`/state` 的块描述与能力、附件字节路由、编辑器芯片/删除/添加/缩略图/降级警告) |
167
+ | 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` | 210 项通过(含回复编辑的尾部重放用例:窗口/重放区间、新 turn 号与新 messageId、tool 调用与结果成对重映射、上一轮修正可再次编辑,以及与 `dsh-rerun-turn` builder 的逐字段对照;另含「文本没变 → 不写」的四条,以及块随消息一起改的 35 条:未动的块逐字节保留、删块少一块、文字位置不变、no-op 语义升级、附件服务缺失时降级且不写坏数据、自定义 `quote-card` 块的通用性、录用与拒绝、`/state` 的块描述与能力、附件字节路由、编辑器芯片/删除/添加/缩略图/降级警告) |
168
168
  | 客户端半部静态检查(注册、i18n 完整性、样式、皮肤可读性、版本三处同步、线协议、块契约) | `npm run verify:client` | 184 项全部通过(含 12 条块契约检查:能力上报、服务现场探测、录用是唯一建块路径、录用先于 append、**两侧都不特判块类型**、块逐字节复制、文字归位、`dropped` 上报、只在无服务时警告、芯片靠 `error` 事件自证) |
169
169
  | 实机前端产物校验(运行中的 DSH 是否在下发当前代码) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | 全部通过(含**宿主节点形状锚点**) |
170
170
  | **真实浏览器渲染冒烟**(CDP 驱动已开着的标签页,只读) | `npm run verify:ui` | 全部通过:下发的字节就是刚改的字节、无槽位崩溃留下的空占位、回复铅笔在平台动作条里、被回滚的行保住动作条、**被回滚的用户行一定显示替代文本(消息不会凭空消失)**、**替代气泡一定排在时间/复制之上(顺序与正常消息一致)**、**改写后那颗铅笔一定在还活着的动作条末尾(已在原始会话、切走的会话、切回后三处现场确认)**、**回复铅笔一定排在动作条最左(复制之前,已现场确认)**、气泡上没有 `.dshet-floating` 压着文字、切走再切回入口仍在 |
@@ -250,6 +250,16 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
250
250
 
251
251
  ### 更新日志
252
252
 
253
+ **0.3.1** —— 修复 0.3.0 的显示与跨插件清理问题。
254
+
255
+ - **编辑后看不到变化**:`turn-process` 行和 `assistant-step` 行都覆盖同一个 seq,旧的 DOM 映射按文档顺序先拿到过程行,补丁落到没有正文的行上。现在优先 assistant 行;极短回答是裸文本节点时也有兜底。
256
+ - **Markdown 匹配**:原文有 `**1**`、渲染后是 `1。`,旧比较把 `**` 换成空格后变成 `1 。`,逐字比较失败。现在比较忽略全部空白;显示时去掉 `**`、反引号和标题标记,不把 Markdown 源码画在正文里。
257
+ - **React 重渲染**:会清掉补丁元素的属性和样式但保留文本;现在按 `seq:block` 记住元素,连续第二次编辑仍能更新并能在补丁消失时恢复原文。
258
+ - **过时补丁**:目标已被 rerun 退役的 patch 不再出现在 `/state.patches`。
259
+ - **轮次丢失**:prompt 替换载体在 turn 括号之外追加,`turnIndex` 取不到轮次;现在从 `sourceEventSeqs` 继承被替换的轮次。
260
+ - **诊断**:客户端在 `<html>` 上写 `data-dshet-version`,可确认页面实际跑的客户端版本。
261
+ - **测试**:`npm test` **210/210**、`npm run verify:contract` **96** 项、`npm run verify:client` **184** 项。
262
+
253
263
  **0.3.0** —— 回复编辑不再重放:用官方 plugin record + message projection 就地打补丁,正文与思维链都能改,日志每次只多一条小记录。
254
264
 
255
265
  - **为什么改**:0.2.21 的回复编辑虽然保住了后续轮次,但代价是「遮蔽到末尾 + 逐事件重放」——改一条中间回答,日志会多出整段副本,轮次号、messageId 全换,工具调用/思维链也留不住。用户要的是「像改提示词一样改回答」,而不是一次 infix splice。
package/lib/client.js CHANGED
@@ -42,7 +42,7 @@ window.__ModuleLoader__.load({
42
42
  const RERUN_PREFIX = '/dsh-rerun-turn'
43
43
 
44
44
  /** Keep in sync with package.json and lib/index.js. */
45
- const PLUGIN_VERSION = '0.3.0'
45
+ const PLUGIN_VERSION = '0.3.1'
46
46
 
47
47
  // --- copy -----------------------------------------------------------------
48
48
 
@@ -2452,6 +2452,11 @@ window.__ModuleLoader__.load({
2452
2452
  const PATCH_ATTR = 'data-dshet-patch'
2453
2453
  const NOTE_ATTR = 'data-dshet-patch-note'
2454
2454
  const patchOriginals = new WeakMap()
2455
+ // React may re-render the assistant container and reset its attributes and
2456
+ // style while leaving the text node we replaced. Remember the element by
2457
+ // seq:block so the next pass can restore the marker and, on a later edit,
2458
+ // update it without having to re-find the original text (which is gone).
2459
+ const patchedElements = new Map()
2455
2460
 
2456
2461
  function normalizedText(value) {
2457
2462
  return String(value ?? '').replace(/\s+/g, ' ').trim()
@@ -2464,6 +2469,27 @@ window.__ModuleLoader__.load({
2464
2469
  return normalizedText(String(value ?? '').replace(/[`*_#>~[\]()!|]/g, ' ').replace(/\s+/g, ' '))
2465
2470
  }
2466
2471
 
2472
+ // Identity, not display: markdown emphasis (`**1**`) renders as `1。`, and
2473
+ // the spaces left by stripping the markers must not defeat the comparison.
2474
+ // Whitespace carries no block identity here, so remove it entirely.
2475
+ function matchText(value) {
2476
+ return plainText(value).replace(/\s+/g, '')
2477
+ }
2478
+
2479
+ // What the reader sees for a patched block. The model context keeps the
2480
+ // raw Markdown the editor wrote; the transcript should not show the markers
2481
+ // as literal text (`**1**`), which is what made a successful edit look like
2482
+ // an unrendered draft. This is deliberately minimal: bold, inline code and
2483
+ // headings are unwrapped; everything else stays as written.
2484
+ function displayText(value) {
2485
+ return String(value ?? '')
2486
+ .replace(/```[\s\S]*?```/g, (fence) => fence.replace(/^```[^\n]*\n?/gm, '').replace(/```$/gm, ''))
2487
+ .replace(/`([^`]*)`/g, '$1')
2488
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
2489
+ .replace(/__([^_]+)__/g, '$1')
2490
+ .replace(/^#{1,6}\s+/gm, '')
2491
+ }
2492
+
2467
2493
  function isMarkdownContainer(el) {
2468
2494
  if (el === null || el === undefined || !el.children) return false
2469
2495
  const kids = el.children
@@ -2498,18 +2524,69 @@ window.__ModuleLoader__.load({
2498
2524
  el.style.whiteSpace = ''
2499
2525
  }
2500
2526
 
2527
+ // A short reply can be a bare text node (or a span) rather than the
2528
+ // block-level markdown container the picker looks for. Match the deepest
2529
+ // element in the owning row whose whole text is exactly the recorded text;
2530
+ // never a button, a timestamp, our own chrome, or an ancestor that merely
2531
+ // contains the text.
2532
+ function findPatchTextFallback(owner, wanted) {
2533
+ if (owner === null || owner === undefined || typeof owner.querySelectorAll !== 'function') return null
2534
+ const original = matchText(wanted.original)
2535
+ if (original === '') return null
2536
+ let scope = owner
2537
+ try {
2538
+ const node = owner.querySelector('[data-slot="conversation.chat.node"]')
2539
+ if (node !== null && node !== undefined) scope = node
2540
+ } catch {
2541
+ /* keep the row as the scope */
2542
+ }
2543
+ for (const el of scope.querySelectorAll('*')) {
2544
+ if (el === scope) continue
2545
+ if (el.getAttribute(PATCH_ATTR) !== null) continue
2546
+ try {
2547
+ if (el.closest('.dshet-editor, .dshet-action, .dshet-pick, .dshet-patch-note, button, time') !== null) continue
2548
+ } catch {
2549
+ continue
2550
+ }
2551
+ const text = matchText(el.textContent)
2552
+ if (text !== original) continue
2553
+ let deeper = false
2554
+ for (const child of el.children ?? []) {
2555
+ if (matchText(child.textContent) === text) {
2556
+ deeper = true
2557
+ break
2558
+ }
2559
+ }
2560
+ if (!deeper) return el
2561
+ }
2562
+ return null
2563
+ }
2564
+
2501
2565
  function applyPatchDisplay(snapshot, view, t) {
2502
2566
  // Map a reply seq to the host row that draws it, so a short answer like
2503
2567
  // "好的" cannot be matched in a different row. Fall back to a global
2504
2568
  // search when the row is not mounted (virtualised histories).
2505
2569
  const rowBySeq = new Map()
2570
+ // A turn-process row also covers the turn's assistant seq (seqsFor reads
2571
+ // its anchor), and it comes before the assistant row in document order.
2572
+ // First-win mapping therefore pointed the patch at the process row, whose
2573
+ // subtree has no message body: zero matches. Prefer the row that actually
2574
+ // draws the message; the process/tail rows are only a last resort.
2575
+ const rowRank = new Map()
2506
2576
  try {
2507
2577
  for (const row of document.querySelectorAll('[data-chat-flow-key]')) {
2508
2578
  const key = row.getAttribute('data-chat-flow-key')
2509
2579
  if (!key || !snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') continue
2510
2580
  const node = snapshot.nodes.get(key)
2511
2581
  if (!node) continue
2512
- for (const seq of seqsFor(node)) if (!rowBySeq.has(seq)) rowBySeq.set(seq, row)
2582
+ const rank = node.kind === 'assistant-step' ? 0 : node.kind === 'user' ? 1 : 2
2583
+ for (const seq of seqsFor(node)) {
2584
+ const previous = rowRank.get(seq)
2585
+ if (previous === undefined || rank < previous) {
2586
+ rowBySeq.set(seq, row)
2587
+ rowRank.set(seq, rank)
2588
+ }
2589
+ }
2513
2590
  }
2514
2591
  } catch {
2515
2592
  /* a host shape this pass cannot read falls back below */
@@ -2541,6 +2618,13 @@ window.__ModuleLoader__.load({
2541
2618
  }
2542
2619
  }
2543
2620
 
2621
+ // A patch that no longer exists must give the host's recorded text back,
2622
+ // even when a re-render already stripped our marker from the element.
2623
+ for (const [key, el] of [...patchedElements]) {
2624
+ if (wantedText.has(key)) continue
2625
+ patchedElements.delete(key)
2626
+ if (el.isConnected === true) restorePatchedBlock(el)
2627
+ }
2544
2628
  // Remove overrides whose patch is gone (or was superseded by a prompt
2545
2629
  // rollback). The original HTML is what the host rendered.
2546
2630
  for (const el of [...root.querySelectorAll(`[${PATCH_ATTR}]`)]) {
@@ -2550,8 +2634,9 @@ window.__ModuleLoader__.load({
2550
2634
  restorePatchedBlock(el)
2551
2635
  continue
2552
2636
  }
2553
- if (normalizedText(el.textContent) !== normalizedText(wanted.text)) {
2554
- el.textContent = wanted.text
2637
+ const shown = displayText(wanted.text)
2638
+ if (normalizedText(el.textContent) !== normalizedText(shown)) {
2639
+ el.textContent = shown
2555
2640
  }
2556
2641
  }
2557
2642
  for (const note of [...root.querySelectorAll(`[${NOTE_ATTR}]`)]) {
@@ -2572,13 +2657,33 @@ window.__ModuleLoader__.load({
2572
2657
  }
2573
2658
  }
2574
2659
  for (const [key, wanted] of wantedText) {
2660
+ const owner = rowBySeq.get(wanted.seq)
2661
+ const remembered = patchedElements.get(key)
2662
+ if (remembered !== undefined) {
2663
+ const stillThere = remembered.isConnected === true &&
2664
+ (owner === undefined || remembered.closest('[data-chat-flow-key]') === owner)
2665
+ if (!stillThere) {
2666
+ patchedElements.delete(key)
2667
+ } else {
2668
+ const shown = displayText(wanted.text)
2669
+ if (normalizedText(remembered.textContent) !== normalizedText(shown)) {
2670
+ remembered.textContent = shown
2671
+ }
2672
+ if (remembered.getAttribute(PATCH_ATTR) !== key) remembered.setAttribute(PATCH_ATTR, key)
2673
+ remembered.style.whiteSpace = 'pre-wrap'
2674
+ continue
2675
+ }
2676
+ }
2575
2677
  const done = [...root.querySelectorAll(`[${PATCH_ATTR}]`)].find((el) => el.getAttribute(PATCH_ATTR) === key)
2576
- if (done !== undefined) continue
2577
- const original = plainText(wanted.original)
2678
+ if (done !== undefined) {
2679
+ patchedElements.set(key, done)
2680
+ if (done.style.whiteSpace !== 'pre-wrap') done.style.whiteSpace = 'pre-wrap'
2681
+ continue
2682
+ }
2683
+ const original = matchText(wanted.original)
2578
2684
  if (original === '') continue
2579
2685
  let best = null
2580
2686
  let bestScore = Infinity
2581
- const owner = rowBySeq.get(wanted.seq)
2582
2687
  for (const el of containers) {
2583
2688
  if (el.getAttribute(PATCH_ATTR) !== null) continue
2584
2689
  if (owner !== undefined) {
@@ -2588,7 +2693,7 @@ window.__ModuleLoader__.load({
2588
2693
  /* no closest on this node: keep the global match */
2589
2694
  }
2590
2695
  }
2591
- const text = plainText(el.textContent)
2696
+ const text = matchText(el.textContent)
2592
2697
  if (text === '') continue
2593
2698
  const exact = text === original
2594
2699
  const prefix = text.startsWith(original.slice(0, 60)) || original.startsWith(text.slice(0, 60))
@@ -2602,11 +2707,13 @@ window.__ModuleLoader__.load({
2602
2707
  bestScore = score
2603
2708
  }
2604
2709
  }
2710
+ if (best === null) best = findPatchTextFallback(owner, wanted)
2605
2711
  if (best === null) continue
2606
2712
  patchOriginals.set(best, { html: best.innerHTML, text: best.textContent })
2607
- best.textContent = wanted.text
2713
+ best.textContent = displayText(wanted.text)
2608
2714
  best.setAttribute(PATCH_ATTR, key)
2609
2715
  best.style.whiteSpace = 'pre-wrap'
2716
+ patchedElements.set(key, best)
2610
2717
  }
2611
2718
 
2612
2719
  // Reasoning notes: put one under the reply row, found by the reply's own
@@ -2614,15 +2721,15 @@ window.__ModuleLoader__.load({
2614
2721
  for (const [key, wanted] of wantedNotes) {
2615
2722
  let note = [...root.querySelectorAll(`[${NOTE_ATTR}]`)].find((el) => el.getAttribute(NOTE_ATTR) === key)
2616
2723
  let row = rowBySeq.get(wanted.reply.seq) ?? null
2617
- const replyText = plainText(wanted.reply.text)
2724
+ const replyText = matchText(wanted.reply.text)
2618
2725
  for (const node of row === null ? nodes : []) {
2619
- const text = plainText(node.textContent)
2726
+ const text = matchText(node.textContent)
2620
2727
  if (text === '') continue
2621
2728
  if (replyText !== '' && text.includes(replyText)) {
2622
2729
  row = node
2623
2730
  break
2624
2731
  }
2625
- if (plainText(wanted.block.original) !== '' && text.includes(plainText(wanted.block.original))) {
2732
+ if (matchText(wanted.block.original) !== '' && text.includes(matchText(wanted.block.original))) {
2626
2733
  row = node
2627
2734
  break
2628
2735
  }
@@ -2967,6 +3074,14 @@ window.__ModuleLoader__.load({
2967
3074
  // --- plugin ---------------------------------------------------------------
2968
3075
 
2969
3076
  function apply(ctx) {
3077
+ // Diagnostic only: the host restart does not reload the page's JS, so a
3078
+ // stale client bundle and a live host can coexist. This marker says which
3079
+ // browser half actually ran (the old bundle has none).
3080
+ try {
3081
+ document.documentElement.dataset.dshetVersion = PLUGIN_VERSION
3082
+ } catch {
3083
+ /* a non-DOM host is not worth failing over */
3084
+ }
2970
3085
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-edit-turn: dictionaries')
2971
3086
 
2972
3087
  const controllers = new Map()
package/lib/index.js CHANGED
@@ -80,7 +80,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
80
80
  export const name = PLUGIN_ID
81
81
 
82
82
  /** Keep in sync with package.json and lib/client.js. */
83
- export const PLUGIN_VERSION = '0.3.0'
83
+ export const PLUGIN_VERSION = '0.3.1'
84
84
 
85
85
  // A ring of the last few requests, answering "did the browser half even ask, and
86
86
  // what did it get?" without needing its console. Read-only diagnostics: the
@@ -1239,7 +1239,20 @@ export function editableTurns(events, surfaceNodes, options = {}) {
1239
1239
  if (!event || !isHumanPrompt(event)) continue
1240
1240
  if (seq === head) continue
1241
1241
  const { text, attachments } = readMessageText(event)
1242
- const turn = turnOf.get(seq)
1242
+ // A replacement carrier is appended after its original turn's bracket has
1243
+ // closed, so turnIndex cannot name its turn. It still stands at that
1244
+ // prompt's position in the conversation: inherit the turn from the event
1245
+ // it replaced, or the editor's turn list loses the row (turn: null).
1246
+ let turn = turnOf.get(seq)
1247
+ if (typeof turn !== 'number' && Array.isArray(event.sourceEventSeqs)) {
1248
+ for (const source of event.sourceEventSeqs) {
1249
+ const inherited = turnOf.get(source)
1250
+ if (typeof inherited === 'number') {
1251
+ turn = inherited
1252
+ break
1253
+ }
1254
+ }
1255
+ }
1243
1256
  out.push({
1244
1257
  seq,
1245
1258
  turn: typeof turn === 'number' ? turn : null,
@@ -1645,6 +1658,13 @@ async function stateOf(ctx, sessionId, config) {
1645
1658
  const folded = foldSurface(events)
1646
1659
  const ledger = rollbackLedger(events)
1647
1660
  const patches = patchesOf(events)
1661
+ // A patch whose target has been retired (a later rerun shadowed it) is inert:
1662
+ // the projection skips it and no row can show it. Reporting it in /state made
1663
+ // the browser draw a patch that could never land, and made "which patches are
1664
+ // live" ambiguous. Keep the internal map for the replies; publish the active
1665
+ // subset only.
1666
+ const surfaceSet = new Set(folded.nodes)
1667
+ const activePatches = new Map([...patches].filter(([seq]) => surfaceSet.has(seq)))
1648
1668
  const live = findLiveSession(ctx, sessionId)
1649
1669
  // Probed per request: the editor offers what THIS deployment can do right
1650
1670
  // now, so a store that is unmounted (or not yet mounted) cannot leave the
@@ -1676,7 +1696,7 @@ async function stateOf(ctx, sessionId, config) {
1676
1696
  // The landed in-place patches, latest per block. The browser half uses them
1677
1697
  // to draw the projected text in place of the recorded one; the model already
1678
1698
  // sees them through the host projection.
1679
- patches: patchList(patches),
1699
+ patches: patchList(activePatches),
1680
1700
  mode: config.patch === true ? 'patch' : 'replay',
1681
1701
  live: Boolean(live),
1682
1702
  busy: isBusy(events),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Edit any message in DeepSeek Harness in place: a prompt is replaced where it stands; a model reply block (answer text or reasoning) is patched through a plugin record and a message projection, so the turn stays where it is, later turns are untouched and the log grows by one small record. Falls back to the replay splice on hosts without the projection API. The append-only session log is never rewritten.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",