dsh-edit-turn 0.5.4 → 0.5.5

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
@@ -142,7 +142,6 @@ ctx.sessions.registerMessageProjection({
142
142
  | 路由 | `GET /dsh-edit-turn/state?sessionId=` | 可编辑轮次、已遮蔽行账本(`hidden` + `revisions`)、surface、忙碌状态;`mode` 是 `patch` 或 `replay`;`patches[]` 是已落地的就地补丁;回复项带 `blocks[]`(`index` / `kind` / 当前投影 `text` / `original` / `patched`) |
143
143
  | 路由 | `POST /dsh-edit-turn/apply` | 提示词:`{ sessionId, seq \| messageId \| turn, text, parts? }`;就地回复:再加 `blockIndex` + `kind`(`text` / `reasoning`)。写入改后内容;`parts` 是要保留/新增的块列表(`[{ keep } \| { add: { data, mediaType?, name? } }]`,缺省 = 老客户端的纯文字载体);响应含 `applied` / `shadowed` / `replacementSeq` / `dropped` / `blocks`;改后文本与当前活文本一字不差**且块列表与原消息相同**时什么都不写,响应改为 `applied:false` + `unchanged:true` + `shadowed:[]`(`original` 是它比较的那份文本)。这是一个**标志,不是拒绝**:调用方自己决定下一步(编辑器的「重跑」正是靠它继续发起重跑) |
144
144
  | 路由 | `GET /dsh-edit-turn/attachment?sessionId=&seq=&index=` | 只读:该会话日志确实引用过的那个块的原始字节(芯片缩略图用);回环 + 同源 + 只认本会话引用过的引用,取不到就 404(芯片退回显示名字)。它**只用于显示**,改写路径完全不经过它 |
145
- | 工具 | `edit_turn_targets` | 只读:列出该会话当前可编辑的轮次、回复与每条回复的可编辑块(供 agent 自查) |
146
145
  | 前端 | `conversation.input.overlay` | 每会话控制器:编辑入口、就地编辑器、被遮蔽行的隐藏账本 |
147
146
 
148
147
  ### 跨插件契约(给兄弟插件)
@@ -195,7 +194,7 @@ DSH_BIN=/path/to/dsh/lib/bin.js PORT=4123 DSH_HOME=/tmp/dsh-edit-turn-home bash
195
194
 
196
195
  **「两支铅笔该落在哪儿」同样已被覆盖**,这是按你给的规范改的:用户行那颗笔排在**动作条的最右边**(时间、复制、以及兄弟插件后插进来的按钮之后),回复行那颗笔排在**动作条的最左边**(复制按钮之前)。两处都不是"换个插入点"能办到的,各自卡在一个宿主限制上:折叠行原先把笔塞进替代气泡的字外留白里(要悬停改写文本才看得见,也够不着"复制");回复笔受宿主注册的 slot 约束,DOM 上必然排在平台自己画的复制按钮之后。现在的做法是:①`renderRevision` 把改写后的笔**交给那条活下来的动作条**(`injectRowAction(row, editTarget, …)`),同时把它从气泡里摘掉,只有行彻底没有条可留时才退回气泡字外留白;`applyDom` 在行可见时**只在没有替代气泡站位时**才拆掉行上的笔——否则会把上一行刚放进去的笔拆走。②回复笔靠新增的 `.dshet-reply-action` 加 flex `order:-1` 冲到最左,因为那一格宿主是 `display:contents`,DOM 序永远只能第二,只有顺序属性改得动;用户笔同理——兄弟插件(`dsh-delete-turn`)会在任意时刻把垃圾桶**追加**进同一条动作条,DOM 插入序保不住右端,所以 `.dshet-action-host` 带 `order:9`,无论谁后插都在它左边。颜色也一并对齐平台:`.dshet-action` 改用平台的 `--dsw-alias-label-tertiary`(悬停 `--dsw-alias-label-primary`,留一个等于默认主题取值的兜底),不再用自己的蓝灰墨色——此前它比旁边的复制/垃圾桶更蓝,看起来像另一个控件;实测两者计算色都是 `rgb(129, 133, 140)`。测试上:原"铅笔在字外留白"的用例改写为「铅笔并进那条活下来的条的末尾、气泡里不再留笔」,无条用例补上"留白里仍有笔",回复 entry 用例补 class 断言;静态检查加 4 条钉住这一批写法(含「气泡按内容收窄」),另加 1 条钉住 `order:9`,`verify:live` 加 2 个下发标记;`verify:ui` 的「行末尾的笔」断言改成**按布局量右边缘**(DOM 序不算数)并已在真机重载后跑通——现场那个用户条是 `时间 → 复制 → 垃圾桶 → 笔`,笔在最右(原始会话/切走的会话/切回后三处确认)。三处新写法各做过反向复现:把「笔交给动作条」「`.dshet-reply-action` class」「`applyDom` 的站位守卫」逐一还原,各有一个用例失败(守卫那处失败在「笔并进条末尾」用例上——它恰好也钉住了"刚放好又被拆走"这条回归)。当前 `npm run check` **105 项** + 契约 + 静态全绿。
197
196
 
198
- **开发前置**:`npm test` 与两个 `verify:*` 需要 `@deepseek-ai/dsh-session` 与 `@deepseek-ai/dsh-tools` 可解析。本插件自身不依赖它们(宿主半部只 import `schemastery` 与 `dsh-tools`),测试需要一个装了 DSH 的 `node_modules`:
197
+ **开发前置**:`npm test` 与两个 `verify:*` 需要 `@deepseek-ai/dsh-session` 与 `@deepseek-ai/dsh-settings` 可解析。本插件运行时不依赖它们(宿主半部只 import `schemastery` 与 `dsh-session`,后者由宿主提供),测试需要一个装了 DSH 的 `node_modules`:
199
198
 
200
199
  ```sh
201
200
  ln -s /path/to/dsh-install/node_modules ./node_modules
@@ -233,7 +232,7 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
233
232
  cd <插件目录> && rm -f node_modules && npm install --omit=dev --legacy-peer-deps
234
233
  ```
235
234
 
236
- 注意区分两类依赖:宿主提供的 **peer**(`dsh-tools`、`dsh-settings`)由 DSH 加载器解析,可以不装;**普通 dependencies**(如 `schemastery`)必须能解析到,加载器不会替你找。
235
+ 注意区分两类依赖:宿主提供的 **peer**(`dsh-settings`、`dsh-session`)由 DSH 加载器解析,可以不装;**普通 dependencies**(如 `schemastery`)必须能解析到,加载器不会替你找。
237
236
  3. **加载了但没入口时**,用只读诊断路由看浏览器半部到底问了什么:
238
237
 
239
238
  ```sh
@@ -278,6 +277,18 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
278
277
 
279
278
  ### 更新日志
280
279
 
280
+ **0.5.5** —— 不再向模型暴露任何工具;新增会话日志裁剪工具。
281
+
282
+ - **模型工具移除**:本插件原先注册了一个模型可见的工具(`edit_turn_targets`,给模型列可编辑的轮次/步骤)。它其实用不上——编辑是靠 UI 面板做的,模型不需要知道这件事——而且它把 `dsh-tools` 平台拖成了硬依赖。现在整个工具拿掉,插件不再注册任何模型可见的工具,也不再依赖 `dsh-tools`。
283
+
284
+ - **新增 `tools/compact-session.mjs`(会话日志裁剪)**:长会话的日志会一路涨到十几 MB,而宿主的压缩是**累积**的——每个检查点覆盖前一个,所以更早的窗口对模型早已不可见,却还压在日志里、还显示在对话区。这个工具按窗口裁剪:只保留最后 N 个窗口(`--keep-windows N`),把更早的窗口整段切掉,同时把受影响的引用全部重编。
285
+
286
+ 它按"先算后写"走:默认只写临时文件并打印试算结果,`--apply` 才落盘,落盘前检查 `session.lock`(宿主正打开着的会话一律拒绝),落盘时把原文件改名为 `<日志>.precompact-<时间戳>` 再原子写入。
287
+
288
+ 裁剪不是简单截断——宿主对日志有三层校验(格式层 `dsh-session-format-v3-to-v4`、会话层 `dsh-session`、投影层 inbox 拼接),任何一处不合法整份日志都加载不了。所以工具会重排与重编:切点退到 `turn/start`(检查点不是轮次边界)、轮次与步骤从 1 重编号、系统提示词放进第一对 `turn/start`+`step/start` 之后(它必须落在已打开的轮次里,同时仍是第一个表面节点)、所有 `*Seq`/`*Seqs`/`*Range` 交叉引用统一重编(`throughSeq` 除外——它指向**别的会话**的日志)、compaction 的记账字段按模拟出的表面现算、`compaction/summary` 没了就把整组丢掉、inbox 拼接记录先重放拿到最终状态再决定去留。
289
+
290
+ 实测:13.2 MB / 29,496 事件的会话裁到 4.8 MB / 9,302 事件,而**模型看到的内容完全不变**(可见节点 1000 → 1000)。
291
+
281
292
  **0.5.4** —— 设置卡导出给组合包(dsh-as-aistudio);会话 id 校验放宽,空会话按空状态处理。
282
293
 
283
294
  - **会话 id 不再只认 UUID**:命名会话(例如 `bili-whale`,`~/.dsh/storages/<profile>/sessions/bili-whale.json`)是完全合法的会话,但这里的校验只接受 UUID 形式,于是对它的每个请求都被 400 拒掉 —— 面板上就是「状态加载失败,稍后重试。」。现在按 DSH 自己的语法(宿主侧 schema 就是 `z.string().min(1)`)放宽为 `/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/`,仍然挡住 `/`、`\`、`..`、空白与控制字符。
@@ -389,7 +400,7 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
389
400
  1. `lib/patch.js`:`PATCH_TYPE = 'plugin:dsh-edit-turn/patch'`,记录载荷 `{ version, targets: [{ seq, blockIndex, kind, text }] }`;`patchProjection()` 按 seq 分组、以「已投影消息」为 base 叠加多条记录,改思维链时删除 `signature` / `thinkingSignature`;
390
401
  2. `apply` 现场探测 `appendPluginRecord` 与 `ctx.sessions.registerMessageProjection`,成功才启用 patch 模式(`/debug` 的 `mode` 可查);任一缺失退回重放,`replyMode: 'patch'` 则直接报错;
391
402
  3. `planRollback` 的回复分支在 patch 模式下只解析目标块(`blockIndex` / `kind`),不计算窗口、不碰 turn;`applyEdit` 只调一次 `appendPluginRecord` 再 flush;
392
- 4. `/state` 返回 `mode`、`patches[]`、每条回复的 `blocks[]`(`index` / `kind` / 当前投影 `text` / `original` / `patched`);`edit_turn_targets` 工具也列出块。
403
+ 4. `/state` 返回 `mode`、`patches[]`、每条回复的 `blocks[]`(`index` / `kind` / 当前投影 `text` / `original` / `patched`)。
393
404
  - **客户端**:回复编辑器加块选择器;保存只发 `blockIndex` / `kind` / `text`;正文补丁在宿主 markdown 容器里就地替换,思维链补丁在回复下方显示注记。原始行、动作条、工具卡片都不动。
394
405
  - **兼容**:没有投影能力的宿主继续走 0.2.21 重放;`replyMode: replay` 可强制旧路径;卸载插件并重启宿主后投影消失,模型与 UI 都回到记录里的原文——这是 overlay 机制与 surface 事件的取舍,README 的「工作原理」已写明。
395
406
  - **测试**:`npm test` **205/205**、`npm run verify:contract` **96** 项、`npm run verify:client` **184** 项;新增 9 条纯 patch 用例、3 条宿主 patch 用例(一条记录、无 turn、投影叠加、思维链签名去掉、unchanged 不写、热重载复用投影)、4 条客户端 DOM 用例(块选择器、保存载荷、正文就地显示、思维链注记),沙箱 `verify:profile` 新增「真实 profile 中 `mode=patch` 且 `appendPluginRecord` 可用」的探针。
@@ -572,7 +583,7 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
572
583
 
573
584
  - `dsh.engines.dsh`: `>=0.1.6-alpha.2`(本插件依赖该版本的消息行定义与 surface 语义;`0.1.6-alpha.2` 与 `0.1.7-rc.1` 均已实测)。
574
585
  - 客户端依赖:`dsh-client-locale`、`dsh-client-ui-chat`、`dsh-client-ui-conversation`、`dsh-client-ui-primitives`。
575
- - 宿主依赖:`dsh-settings`、`dsh-tools`(peer);`@deepseek-ai/schemastery`(直接依赖)。
586
+ - 宿主依赖:`dsh-settings`、`dsh-session`(peer);`@deepseek-ai/schemastery`(直接依赖)。
576
587
 
577
588
  ### License
578
589
 
@@ -772,7 +783,6 @@ first `session.append`, so a refused upload (too large, malformed bytes) answers
772
783
  | Route | `GET /dsh-edit-turn/state?sessionId=` | Editable turns, the hidden-row ledger (`hidden` + `revisions`), the surface, busy state |
773
784
  | Route | `POST /dsh-edit-turn/apply` | `{ sessionId, seq \| messageId \| turn, text, parts? }` → write the revised text; `parts` is the block list to keep and add (`[{ keep } \| { add: { data, mediaType?, name? } }]`; omitted means the text-only carrier an older client sends); the response carries `applied` / `shadowed` / `replacementSeq` / `dropped` / `blocks` |
774
785
  | Route | `GET /dsh-edit-turn/attachment?sessionId=&seq=&index=` | Read-only: the raw bytes of one block the session's own log cites (a chip's thumbnail). Loopback and same-origin only, and it serves nothing no session references; a miss answers 404 and the chip falls back to its label. Display only - the edit path never goes through it |
775
- | Tool | `edit_turn_targets` | Read-only: list the session's editable turns and their text |
776
786
  | Client | `conversation.input.overlay` | Per-session controller: edit entry, in-place editor, hidden-row ledger |
777
787
 
778
788
  ### Cross-plugin contract (for sibling plugins)
@@ -1011,9 +1021,10 @@ checks pin the container shape and the IME guard; `verify:live` carries the
1011
1021
  marker.
1012
1022
 
1013
1023
  **Development prerequisite:** `npm test` and both `verify:*` commands need
1014
- `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-tools` to resolve. The plugin
1024
+ `@deepseek-ai/dsh-settings` and `@deepseek-ai/dsh-session` to resolve. The plugin
1015
1025
  itself does not depend on them (the host half imports only `schemastery` and
1016
- `dsh-tools`), so tests need a `node_modules` from a DSH installation:
1026
+ `dsh-session`, the latter provided by the host), so tests need a `node_modules`
1027
+ from a DSH installation:
1017
1028
 
1018
1029
  ```sh
1019
1030
  ln -s /path/to/dsh-install/node_modules ./node_modules
@@ -1051,7 +1062,7 @@ Separate "the plugin never loaded" from "it loaded but nothing rendered" - the t
1051
1062
  cd <plugin dir> && rm -f node_modules && npm install --omit=dev --legacy-peer-deps
1052
1063
  ```
1053
1064
 
1054
- Mind the two kinds of dependency: **peers** provided by the host (`dsh-tools`, `dsh-settings`) are resolved by the DSH loader and need no install; **regular dependencies** (like `schemastery`) must resolve on their own - the loader will not find them for you.
1065
+ Mind the two kinds of dependency: **peers** provided by the host (`dsh-settings`, `dsh-session`) are resolved by the DSH loader and need no install; **regular dependencies** (like `schemastery`) must resolve on their own - the loader will not find them for you.
1055
1066
  3. **"One session shows no edit entries at all while `/dsh-edit-turn/state` answers 404 in its console"**: that session is being **held by another DSH instance** (the desktop app has it open, or vice versa). A session can only belong to one instance at a time - host behaviour, not a plugin problem. Use it from that instance, or release it in the other one first. `verify:ui` notes and skips this case instead of failing.
1056
1067
  4. **"The history fails to load / one session is unreadable"** (`session-not-found`,
1057
1068
  with `SessionFormatError` in the console): this can be a log broken by the reply
@@ -1087,7 +1098,7 @@ byte-for-byte backup - truncates at the first event the reader refuses:
1087
1098
 
1088
1099
  - `dsh.engines.dsh`: `>=0.1.6-alpha.2` (this plugin relies on that release's message-row definitions and surface semantics, and was verified on it).
1089
1100
  - Client dependencies: `dsh-client-locale`, `dsh-client-ui-chat`, `dsh-client-ui-conversation`, `dsh-client-ui-primitives`.
1090
- - Host dependencies: `dsh-settings`, `dsh-tools` (peers); `@deepseek-ai/schemastery` (direct).
1101
+ - Host dependencies: `dsh-settings`, `dsh-session` (peers); `@deepseek-ai/schemastery` (direct).
1091
1102
 
1092
1103
  ### License
1093
1104
 
package/lib/client.js CHANGED
@@ -45,7 +45,7 @@ window.__ModuleLoader__.load({
45
45
  const RERUN_PREFIX = '/dsh-rerun-turn'
46
46
 
47
47
  /** Keep in sync with package.json and lib/index.js. */
48
- const PLUGIN_VERSION = '0.5.4'
48
+ const PLUGIN_VERSION = '0.5.5'
49
49
 
50
50
  // --- copy -----------------------------------------------------------------
51
51
 
@@ -60,6 +60,8 @@ window.__ModuleLoader__.load({
60
60
  'copy': '复制',
61
61
  'copied': '复制成功',
62
62
  'action.copied': '复制成功',
63
+ 'action.copy': '复制这条回答',
64
+ 'action.tail-actions': '这一轮的动作',
63
65
  'editor.title': '编辑这条消息',
64
66
  'editor.title-reply': '编辑这条回答',
65
67
  'editor.placeholder': '改成你希望自己当时说的内容。保存后就地替换这条消息,后面的对话保持不变。',
@@ -147,6 +149,8 @@ window.__ModuleLoader__.load({
147
149
  'copy': 'Copy',
148
150
  'copied': 'Copied',
149
151
  'action.copied': 'Copied',
152
+ 'action.copy': 'Copy this reply',
153
+ 'action.tail-actions': 'Actions for this turn',
150
154
  'editor.title': 'Edit this message',
151
155
  'editor.title-reply': 'Edit this reply',
152
156
  'editor.placeholder': 'Write what you want to have said. Saving replaces this message in place; the conversation after it stays as it is.',
@@ -424,6 +428,18 @@ window.__ModuleLoader__.load({
424
428
  // variable resolves on the action bar (and everywhere above it); the
425
429
  // fallback is the value it resolves to in the default theme.
426
430
  '.dshet-action{width:28px;height:28px;padding:6px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:28px;background:transparent;color:var(--dsw-alias-label-tertiary,#81858c);cursor:pointer;transition:background-color .12s,color .12s}',
431
+ // The fallback strip for a turn with no closing body.
432
+ //
433
+ // The gate is the host's OWN marker: TurnTailNodeView renders
434
+ // `data-actions-reveal` on the tail container exactly on the branch that
435
+ // also draws the official action row, and never on the branch that has
436
+ // none. So one rule hides this strip wherever the official one exists -
437
+ // the two can never be drawn together, and no observer is needed.
438
+ '.dshet-tail{display:inline-flex;align-items:center;gap:2px;margin-left:4px}',
439
+ '[data-turn-tail][data-actions-reveal] .dshet-tail{display:none}',
440
+ '.dshet-tail[data-reveal=always]{opacity:1}',
441
+ '@media (hover:hover){.dshet-tail[data-reveal=hover]{opacity:0;transition:opacity 80ms}[data-turn-tail]:hover .dshet-tail[data-reveal=hover],[data-turn-tail]:focus-within .dshet-tail[data-reveal=hover]{opacity:1}}',
442
+ '.dshet-tail-action[data-copied=1]{color:var(--dshet-accent,#4d6bfe)}',
427
443
  '.dshet-action:hover:not(:disabled){background:var(--dshet-chip);box-shadow:inset 0 0 0 1px var(--dshet-line);color:var(--dsw-alias-label-primary,var(--dshet-ink))}',
428
444
  '.dshet-action:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,var(--dshet-accent));outline-offset:2px}',
429
445
  '.dshet-action:disabled{cursor:default;opacity:.4}',
@@ -579,12 +595,18 @@ window.__ModuleLoader__.load({
579
595
  ].join('')
580
596
 
581
597
  const TAG_ID = 'dsh-edit-turn/edit-turn.css'
582
- if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(TAG_ID) + ']') === null) {
583
- const tag = document.createElement('style')
584
- tag.dataset.plugin = NS
585
- tag.dataset.pluginCss = TAG_ID
586
- tag.textContent = CSS
587
- document.head.appendChild(tag)
598
+ if (typeof document !== 'undefined') {
599
+ // Reused, not skipped: an upgrade over a live page finds the tag the
600
+ // PREVIOUS build injected, and leaving it alone kept the new rules out of
601
+ // the page entirely.
602
+ let tag = document.querySelector('style[data-plugin-css=' + JSON.stringify(TAG_ID) + ']')
603
+ if (tag === null) {
604
+ tag = document.createElement('style')
605
+ tag.dataset.plugin = NS
606
+ tag.dataset.pluginCss = TAG_ID
607
+ document.head.appendChild(tag)
608
+ }
609
+ if (tag.textContent !== CSS) tag.textContent = CSS
588
610
  }
589
611
 
590
612
  // --- icon -----------------------------------------------------------------
@@ -3697,9 +3719,12 @@ window.__ModuleLoader__.load({
3697
3719
  error.className = 'dshet-error'
3698
3720
  error.setAttribute('role', 'status')
3699
3721
  const detail = typeof view.failureDetail === 'string' && view.failureDetail !== '' ? view.failureDetail : null
3700
- error.textContent = (failureCode === 'internal' || failureCode === 'generic') && detail !== null
3701
- ? `${errorText(t, failureCode)}(${detail.slice(0, 300)})`
3702
- : errorText(t, failureCode)
3722
+ // The host's own sentence is always shown: a code-only message leaves the
3723
+ // reader with nothing to act on, and it is what made a real 400 impossible
3724
+ // to diagnose.
3725
+ error.textContent = detail === null
3726
+ ? errorText(t, failureCode)
3727
+ : `${errorText(t, failureCode)}(${detail.slice(0, 300)})`
3703
3728
  box.appendChild(error)
3704
3729
  }
3705
3730
 
@@ -4533,6 +4558,211 @@ window.__ModuleLoader__.load({
4533
4558
  })
4534
4559
  }
4535
4560
 
4561
+ // --- the fallback strip ---------------------------------------------------
4562
+ //
4563
+ // TurnTailNodeView, when a turn's model reply has no closing body
4564
+ // (`closing === null`), returns the turnTail slot alone and never renders
4565
+ // `conversation.chat.assistant-actions`. Every action on that row goes with
4566
+ // it - this plugin's pencil, and the platform's own copy, branch, feedback
4567
+ // and usage buttons too. Nothing a plugin registers inside a strip that is
4568
+ // not rendered can bring those back, so the fallback lives in the one slot
4569
+ // that branch DOES render.
4570
+ //
4571
+ // It is not a second bar for every turn: the host puts `data-actions-reveal`
4572
+ // on the tail container only on the branch that draws its own strip, and one
4573
+ // CSS rule hides this one wherever that attribute is present.
4574
+
4575
+ /**
4576
+ * The reply this turn's fallback strip acts on.
4577
+ *
4578
+ * A turn runs several steps and the strip belongs at the end of the turn,
4579
+ * so the LAST rewriteable reply of that turn wins - the same row the host
4580
+ * would have hung its own strip on.
4581
+ *
4582
+ * @param view - the controller snapshot (see readReplyView).
4583
+ * @param turn - the TurnLocation the slot hands over.
4584
+ * @returns the reply entry, or undefined when this turn has none.
4585
+ */
4586
+ function replyForTurn(view, turn) {
4587
+ if (view === null || view === undefined || typeof turn !== 'number') return undefined
4588
+ const replies = view.replies
4589
+ if (replies === null || replies === undefined || typeof replies.values !== 'function') return undefined
4590
+ if (view.hidden === null || view.hidden === undefined || typeof view.hidden.has !== 'function') return undefined
4591
+ let best
4592
+ for (const reply of replies.values()) {
4593
+ if (reply === null || typeof reply !== 'object') continue
4594
+ if (typeof reply.seq !== 'number' || reply.turn !== turn) continue
4595
+ if (view.hidden.has(reply.seq)) continue
4596
+ if (best === undefined || reply.seq > best.seq) best = reply
4597
+ }
4598
+ return best
4599
+ }
4600
+
4601
+ /**
4602
+ * What a copy press on the fallback strip writes.
4603
+ *
4604
+ * The model's own answer when the turn has one, and its reasoning when the
4605
+ * whole reply went into thinking - the case this strip exists for. Blocks
4606
+ * are joined the way they read, blank ones dropped so a trailing empty
4607
+ * block does not become a trailing newline.
4608
+ *
4609
+ * @param entry - one reply entry.
4610
+ * @returns the text to copy; empty when there is nothing readable.
4611
+ */
4612
+ function replyCopyText(entry) {
4613
+ const blocks = entry !== null && entry !== undefined && Array.isArray(entry.blocks) ? entry.blocks : []
4614
+ const gather = (kind) =>
4615
+ blocks
4616
+ .filter((block) => block !== null && typeof block === 'object' && block.kind === kind)
4617
+ .map((block) => (typeof block.text === 'string' ? block.text : ''))
4618
+ .filter((text) => text.trim() !== '')
4619
+ .join('\n\n')
4620
+ const answer = gather('text')
4621
+ return answer !== '' ? answer : gather('reasoning')
4622
+ }
4623
+
4624
+ function CopyIcon() {
4625
+ return jsx('svg', {
4626
+ width: 16,
4627
+ height: 16,
4628
+ viewBox: '0 0 16 16',
4629
+ fill: 'none',
4630
+ 'aria-hidden': true,
4631
+ children: [
4632
+ jsx('rect', { x: 5.2, y: 5.2, width: 8, height: 8, rx: 1.6, stroke: 'currentColor', strokeWidth: 1.2 }, 0),
4633
+ jsx('path', { d: 'M10.8 5.2V3.6A1.6 1.6 0 0 0 9.2 2H3.6A1.6 1.6 0 0 0 2 3.6v5.6a1.6 1.6 0 0 0 1.6 1.6h1.6', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round' }, 1),
4634
+ ],
4635
+ })
4636
+ }
4637
+
4638
+ function CheckIcon() {
4639
+ return jsx('svg', {
4640
+ width: 16,
4641
+ height: 16,
4642
+ viewBox: '0 0 16 16',
4643
+ fill: 'none',
4644
+ 'aria-hidden': true,
4645
+ children: jsx('path', { d: 'M3.2 8.4l3.2 3.2 6.4-6.4', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round' }, 0),
4646
+ })
4647
+ }
4648
+
4649
+ /**
4650
+ * The fallback strip for a turn the host draws no action row for.
4651
+ *
4652
+ * @param props - the slot's owner props ({ turn, seq, openFile }) plus the
4653
+ * injected controller, the standard `useChat` and this plugin's `t`.
4654
+ */
4655
+ function TailActionEntry(input) {
4656
+ const props = input !== null && typeof input === 'object' ? input : {}
4657
+ const turn = props.turn
4658
+ const controller = props.controller
4659
+ const t = typeof props.t === 'function' ? props.t : null
4660
+ const useChat = props.useChat
4661
+ const [view, setView] = react.useState(() => {
4662
+ try {
4663
+ return readReplyView(controller)
4664
+ } catch {
4665
+ return null
4666
+ }
4667
+ })
4668
+ const [copied, setCopied] = react.useState(false)
4669
+ react.useEffect(() => {
4670
+ let unsubscribe = null
4671
+ try {
4672
+ setView(readReplyView(controller))
4673
+ if (controller !== null && controller !== undefined && typeof controller.subscribe === 'function') {
4674
+ unsubscribe = controller.subscribe(() => setView(readReplyView(controller)))
4675
+ }
4676
+ } catch {
4677
+ setView(null)
4678
+ }
4679
+ return () => {
4680
+ if (typeof unsubscribe === 'function') unsubscribe()
4681
+ }
4682
+ }, [controller])
4683
+ // The host reveals the latest turn's row always and older ones on hover.
4684
+ // The same rule, from the same source it uses (the last turn in the
4685
+ // timeline), so the strip does not behave differently from the row it
4686
+ // stands in for.
4687
+ const lastTurn = typeof useChat === 'function'
4688
+ ? useChat((state) => {
4689
+ if (state === null || typeof state !== 'object') return undefined
4690
+ const timeline = state.timeline
4691
+ const order = timeline === null || timeline === undefined ? null : timeline.turnOrder
4692
+ if (!Array.isArray(order) || order.length === 0) return undefined
4693
+ return order[order.length - 1]
4694
+ })
4695
+ : undefined
4696
+ const entry = replyForTurn(view, turn)
4697
+ // Nothing this plugin can act on: the strip stays out of the way rather
4698
+ // than drawing an empty row.
4699
+ if (entry === undefined) return null
4700
+ const label = t === null ? '' : t('action.edit-reply')
4701
+ const copyLabel = t === null ? '' : t(copied ? 'action.copied' : 'action.copy')
4702
+ const open = (event) => {
4703
+ event.preventDefault()
4704
+ event.stopPropagation()
4705
+ controller.open({
4706
+ anchor: event.currentTarget,
4707
+ seq: entry.seq,
4708
+ mode: 'reply',
4709
+ turn: entry.turn,
4710
+ messageId: entry.messageId,
4711
+ text: typeof entry.text === 'string' ? entry.text : '',
4712
+ attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
4713
+ ...(Array.isArray(entry.blocks) ? { blocks: entry.blocks, patch: true } : { blocks: [] }),
4714
+ })
4715
+ }
4716
+ const copy = (event) => {
4717
+ event.preventDefault()
4718
+ event.stopPropagation()
4719
+ const text = replyCopyText(entry)
4720
+ if (text === '') return
4721
+ const write = primitives !== null && primitives !== undefined && typeof primitives.writeClipboard === 'function'
4722
+ ? primitives.writeClipboard
4723
+ : typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function'
4724
+ ? (value) => navigator.clipboard.writeText(value)
4725
+ : null
4726
+ if (write === null) return
4727
+ Promise.resolve()
4728
+ .then(() => write(text))
4729
+ .then(
4730
+ () => {
4731
+ setCopied(true)
4732
+ window.setTimeout(() => setCopied(false), 1200)
4733
+ },
4734
+ () => {},
4735
+ )
4736
+ }
4737
+ return jsxs('div', {
4738
+ className: 'dshet-tail',
4739
+ 'data-reveal': lastTurn === turn ? 'always' : 'hover',
4740
+ role: 'toolbar',
4741
+ 'aria-label': t === null ? undefined : t('action.tail-actions'),
4742
+ children: [
4743
+ jsx('button', {
4744
+ type: 'button',
4745
+ className: 'dshet-action dshet-tail-action',
4746
+ 'aria-label': label,
4747
+ title: label,
4748
+ onClick: open,
4749
+ key: 'edit',
4750
+ children: jsx(PencilIcon, {}),
4751
+ }),
4752
+ jsx('button', {
4753
+ type: 'button',
4754
+ className: 'dshet-action dshet-tail-action',
4755
+ 'data-copied': copied ? '1' : undefined,
4756
+ 'aria-label': copyLabel,
4757
+ title: copyLabel,
4758
+ onClick: copy,
4759
+ key: 'copy',
4760
+ children: copied ? jsx(CheckIcon, {}) : jsx(CopyIcon, {}),
4761
+ }),
4762
+ ],
4763
+ })
4764
+ }
4765
+
4536
4766
  // --- react entries --------------------------------------------------------
4537
4767
 
4538
4768
  function OverlayEntry({ useChat, useEditTurn, controller, t }) {
@@ -5014,6 +5244,23 @@ window.__ModuleLoader__.load({
5014
5244
  // The row's configure arrow in the plugin manager. Without this the
5015
5245
  // component page is a read-only overview (the card cannot be opened); the
5016
5246
  // key is the manager's `<bundle package>#<row id>` contract.
5247
+ // The fallback strip, for turns the host draws no action row for (see
5248
+ // TailActionEntry). Order 40 keeps it after the shipped contributors
5249
+ // (schedule sits at 20). It is hidden by CSS wherever the host's own strip
5250
+ // exists, so ordering only matters on the turns it appears at all.
5251
+ ctx.slots.inject('conversation.chat.turnTail', () =>
5252
+ ctx.slots.register(
5253
+ {
5254
+ name: 'conversation.chat.turnTail',
5255
+ id: 'edit-turn-tail',
5256
+ order: 40,
5257
+ locale: NS,
5258
+ inject: (sessionId) => ({ controller: controllerFor(sessionId) }),
5259
+ },
5260
+ TailActionEntry,
5261
+ ),
5262
+ )
5263
+
5017
5264
  ctx.slots.inject('plugins.row.config', () =>
5018
5265
  ctx.slots.register(
5019
5266
  {
package/lib/index.js CHANGED
@@ -39,9 +39,8 @@
39
39
  // is why an `assistant/message` carrier is impossible (it forbids
40
40
  // `sourceEventSeqs` outright).
41
41
  //
42
- // The module resolves `@deepseek-ai/schemastery` defensively. `dsh-tools` is a
43
- // host-provided peer (the loader resolves it for every plugin), but the schema
44
- // package is a regular dependency: when it cannot be resolved - a pruned
42
+ // The module resolves `@deepseek-ai/schemastery` defensively. The schema package
43
+ // is a regular dependency: when it cannot be resolved - a pruned
45
44
  // package cache, an installation that skipped dependencies - a static import
46
45
  // takes the whole plugin down and the feature simply disappears from the UI
47
46
  // with nothing but a line on the host's stdout to explain it. A guarded require
@@ -50,7 +49,6 @@
50
49
  import { randomUUID } from 'node:crypto'
51
50
  import { readFile } from 'node:fs/promises'
52
51
  import { createRequire } from 'node:module'
53
- import { defineTool } from '@deepseek-ai/dsh-tools'
54
52
  import * as sessionApi from '@deepseek-ai/dsh-session'
55
53
  import {
56
54
  PATCH_TYPE,
@@ -80,7 +78,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
80
78
  export const name = PLUGIN_ID
81
79
 
82
80
  /** Keep in sync with package.json and lib/client.js. */
83
- export const PLUGIN_VERSION = '0.5.4'
81
+ export const PLUGIN_VERSION = '0.5.5'
84
82
 
85
83
  // A ring of the last few requests, answering "did the browser half even ask, and
86
84
  // what did it get?" without needing its console. Read-only diagnostics: the
@@ -2335,62 +2333,6 @@ function sessionIdFromExec(exec) {
2335
2333
  return undefined
2336
2334
  }
2337
2335
 
2338
- function registerTool(ctx, config) {
2339
- const tools = ctx.get('tools')
2340
- if (!tools || typeof tools.register !== 'function') return
2341
- tools.register(
2342
- defineTool({
2343
- name: 'edit_turn_targets',
2344
- description:
2345
- 'List the user turns and model replies of a DeepSeek Harness session that the edit-turn plugin can rewrite, with each message\'s current text and, for a reply, every editable block (output text and reasoning). A prompt is replaced where it stands; a reply block is patched in place when the host supports projections, otherwise the reply correction is appended and the later turns are replayed behind it. Read-only: it changes nothing.',
2346
- parameters: {
2347
- sessionId: {
2348
- type: 'string',
2349
- description: 'Session id to inspect. Defaults to the session this agent is running in.',
2350
- },
2351
- limit: { type: 'number', description: 'Maximum number of turns to return (default 20).' },
2352
- },
2353
- output: {
2354
- schema: { type: 'string' },
2355
- render: (_args, value) => [{ type: 'text', text: value }],
2356
- },
2357
- async execute(args, exec) {
2358
- const sessionId = typeof args.sessionId === 'string' && args.sessionId !== '' ? args.sessionId : sessionIdFromExec(exec)
2359
- if (sessionId === undefined) {
2360
- throw new Error('no sessionId was supplied and the calling agent has no session')
2361
- }
2362
- const events = await readEvents(ctx, sessionId)
2363
- if (!events) throw new Error(`no session log for ${sessionId}`)
2364
- const nodes = surfaceOf(ctx, sessionId, events)
2365
- const patches = patchesOf(events)
2366
- const turns = editableTurns(events, nodes)
2367
- const replies = editableReplies(events, nodes, { patches, patch: config && config.patch === true })
2368
- const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.floor(args.limit) : 20
2369
- const shown = turns.slice(-limit)
2370
- const shownReplies = replies.slice(-limit)
2371
- const lines = [
2372
- `session ${sessionId}: ${turns.length} editable turn(s), ${replies.length} editable reply/replies, ${isBusy(events) ? 'BUSY' : 'idle'}`,
2373
- ]
2374
- for (const turn of shown) {
2375
- const preview = turn.text.length > 160 ? `${turn.text.slice(0, 160)}...` : turn.text
2376
- lines.push(`- turn ${turn.turn} (seq ${turn.seq}, ${turn.attachments} attachment(s)): ${preview}`)
2377
- }
2378
- if (turns.length > shown.length) lines.push(`(${turns.length - shown.length} earlier turn(s) omitted)`)
2379
- for (const reply of shownReplies) {
2380
- const preview = reply.text.length > 160 ? `${reply.text.slice(0, 160)}...` : reply.text
2381
- const blocks = Array.isArray(reply.blocks)
2382
- ? ` [${reply.blocks.map((block) => `${block.index}:${block.kind}${block.patched ? '*' : ''}`).join(', ')}]`
2383
- : ''
2384
- lines.push(`- reply (seq ${reply.seq}, ${reply.attachments} non-text part(s))${blocks}: ${preview}`)
2385
- }
2386
- if (replies.length > shownReplies.length) lines.push(`(${replies.length - shownReplies.length} earlier reply/replies omitted)`)
2387
- lines.push('editing a turn replaces its wording in place (no model call); editing a reply block patches it in place when the host supports plugin-record projections, otherwise it replaces the reply and replays the tail. Re-running a turn is dsh-rerun-turn\'s button.')
2388
- return lines.join('\n')
2389
- },
2390
- }),
2391
- )
2392
- }
2393
-
2394
2336
  // --- http --------------------------------------------------------------------
2395
2337
 
2396
2338
  function isLoopbackAddress(address) {
@@ -2792,12 +2734,6 @@ export function apply(ctx, config) {
2792
2734
  ctx.inject(['webServer'], (sub) => registerRoutes(sub.webServer, sub))
2793
2735
  }
2794
2736
 
2795
- const tools = ctx.get('tools')
2796
- if (tools) {
2797
- registerTool(ctx, resolved)
2798
- } else {
2799
- ctx.inject(['tools'], (sub) => registerTool(sub, resolved))
2800
- }
2801
2737
  }
2802
2738
 
2803
2739
  export default apply
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
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",
@@ -52,7 +52,6 @@
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.7 || ^0.1.1-rc.2 || ^0.1.2-alpha.2 || ^0.1.3-alpha.2 || ^0.1.5-alpha.1 || ^0.2.0-rc.1",
55
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.7 || ^0.1.1-rc.2 || ^0.1.2-alpha.2 || ^0.1.3-alpha.2 || ^0.1.5-alpha.1 || ^0.2.0-rc.1",
56
55
  "@deepseek-ai/dsh-session": "^0.2.0-rc.1 || ^0.2.1-rc.2"
57
56
  },
58
57
  "dsh": {