dsh-edit-turn 0.2.15 → 0.2.17
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 +24 -6
- package/lib/client.js +268 -1
- package/lib/index.js +42 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -114,11 +114,13 @@ session.append('developer/message', { turn, step, message: { role: 'developer',
|
|
|
114
114
|
1. **prompt 编辑保持单节点窗口**:`planRollback` 的 `mode === 'prompt'` 只遮蔽目标那一格(`shadowed = [target.seq]`),不会顺手回退到末尾;
|
|
115
115
|
2. **替换事件与目标同事件类型**:改 `user/message` 落下的就是 `user/message`(回复编辑不换类型就换不了位,见第 5 条);
|
|
116
116
|
3. **`sourceEventSeqs` 永远列全窗口**:官方校验要求完整覆盖,缺一个节点事件会被拒;
|
|
117
|
-
4. **每个替换都带语义标记**:`source.
|
|
117
|
+
4. **每个替换都带语义标记**:`source.editedBy === 'dsh-edit-turn'`——包括那个不含文本的空 `developer/message` 载体,兄弟插件按 `editedBy` 识别即可,不必靠推断窗口形状;
|
|
118
118
|
5. **回复编辑 = 多节点回退 + 追加新回复**,不是就地替换:旧回复行没有入口是**设计如此**(新行自带入口)。不要为了保住旧行的入口往 `assistant/message` 上加 `sourceEventSeqs`——官方校验会直接拒;
|
|
119
119
|
6. **映射已公开**:`GET /dsh-edit-turn/state` 的 `revisions[]` 给出 `{ replacementSeq, startSeq, endSeq, shadowed }`(字段名与 `POST /apply` 响应一致),`hidden[]` 每题一格列出 `{ seq, turn, replacement }`。跨插件不必再从事件流自行推导。
|
|
120
120
|
|
|
121
|
-
**`source.kind`
|
|
121
|
+
**`source.kind` 的行为(这里修过一个坑)**:prompt 编辑落地的那条 `user/message` **保持 `source.kind === 'user'`**,来历记在 `source.editedBy` 上。原因:平台里有近二十处按 `source.kind === 'user'` 认定「这是人类提问」——`dsh-session-turn-outline`(轮次导轨与轨迹视图)、`dsh-client-ui-trajectory`、`dsh-client-ui-chat`、inbox 的 steering 过滤、会话列表的 `lastPromptAt`。载体站在用户的位置上,改掉这个 kind 会让这些消费者**看不见这一轮的提问**:导轨空掉、`turnOutline` 的 `prompt` 变成空串、轨迹视图把提问归到「上下文」而不是「用户」。(0.2.15 及更早就是这样。)
|
|
122
|
+
|
|
123
|
+
保持 `kind === 'user'` **不会**让它变成「被回答的新输入」:入队根本不看 kind——inbox 投影的 reducer 只处理 `agent/inbox/spliced`(`dsh-agent-loop`),而该事件只由显式的 agent splice 写入;跳过新输入的那些扫描判的是 `surfaceOp === 'append'`,而载体是 `surfaceOp: { op: 'replace' }`。
|
|
122
124
|
|
|
123
125
|
### 验证状态
|
|
124
126
|
|
|
@@ -129,7 +131,7 @@ session.append('developer/message', { turn, step, message: { role: 'developer',
|
|
|
129
131
|
| 运行中的实例是否真的挂载了本插件(只读路由守卫探针,无需 token) | `npm run probe:loaded [端口]` | 通过:`/state` 返 400、`/apply` 返 405 —— 这两个状态码只有本插件会返回 |
|
|
130
132
|
| 浏览器半部到底问了什么、拿回了什么(只读诊断,含失败记录) | `GET /dsh-edit-turn/debug` | 通过:真实浏览器里确认客户端确实拿到 replies;并复现了旧 v3 会话的 session-not-found |
|
|
131
133
|
| 官方 append **与读取**契约(真实校验器,进程内) | `npm run verify:contract` | 80 项通过(每段写入后都用加载器重读整份日志):替换事件被接受、派生历史真的收缩、日志 append-only、工具结果与调用同进同退、空 developer 载体不产生模型消息、**连续两次回退都被接受**、**编辑模型回答的完整机制被接受**、**跨插件依赖的不变量(单节点窗口 / 同事件类型 / 全窗口 sourceEventSeqs / 每个替换都带 `editedBy` 标记 / 改写后的 `source.kind` 不是 `user` / 修正回复不带 `sourceEventSeqs`)** |
|
|
132
|
-
| 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` |
|
|
134
|
+
| 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` | 139 项通过 |
|
|
133
135
|
| 客户端半部静态检查(注册、i18n 完整性、样式、皮肤可读性、版本三处同步、线协议) | `npm run verify:client` | 全部通过 |
|
|
134
136
|
| 实机前端产物校验(运行中的 DSH 是否在下发当前代码) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | 全部通过(含**宿主节点形状锚点**) |
|
|
135
137
|
| **真实浏览器渲染冒烟**(CDP 驱动已开着的标签页,只读) | `npm run verify:ui` | 全部通过:下发的字节就是刚改的字节、无槽位崩溃留下的空占位、回复铅笔在平台动作条里、被回滚的行保住动作条、**被回滚的用户行一定显示替代文本(消息不会凭空消失)**、**替代气泡一定排在时间/复制之上(顺序与正常消息一致)**、**改写后那颗铅笔一定在还活着的动作条末尾(已在原始会话、切走的会话、切回后三处现场确认)**、**回复铅笔一定排在动作条最左(复制之前,已现场确认)**、气泡上没有 `.dshet-floating` 压着文字、切走再切回入口仍在 |
|
|
@@ -172,6 +174,7 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
|
|
|
172
174
|
- **回退是持久的,隐藏不是**。回退写进日志后,模型上下文永久改变;转录里那些行的隐藏是本插件客户端半部做的。卸载插件后,旧行会重新显示出来(而模型上下文里的回退仍然生效)——因为替换事件是官方事件,不会随插件消失。
|
|
173
175
|
- 回退后**系统提示词保持不变**(窗口永不包含 surface 节点 0,该节点也永不可编辑)。
|
|
174
176
|
- **旧会话(v3 格式日志)在 DSH 0.1.7 下读不到**:DSH 的会话读取层对这类日志返回 `session-not-found`,本插件因此在其中完全不工作(表现为没有编辑入口)。新写的会话是 v4,正常。
|
|
177
|
+
- **改写过的行,只有转录里的那颗复制按钮被接管**。平台自己画的复制按钮在别处(**轨迹视图** `dsh-client-ui-trajectory`)读的是同一份投影数据,本版没有接管它:在那里复制一条被改写过的消息,仍会拿到改写前的文本。
|
|
175
178
|
|
|
176
179
|
### 排查:编辑入口整个不见了
|
|
177
180
|
|
|
@@ -213,6 +216,18 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
|
|
|
213
216
|
|
|
214
217
|
### 更新日志
|
|
215
218
|
|
|
219
|
+
**0.2.17** —— 修「改写过的提示词,复制出来还是旧文本」。只修 Bug,交互语义不变。
|
|
220
|
+
|
|
221
|
+
- **根因在平台侧,但它就在下发的 bundle 里,读得到**:平台的复制按钮把它**画那一行时用的那条消息**交给剪贴板——文本取自投影节点(`dsh-client-ui-chat/lib/client.js:1117` 的 `MessageIconActions` 收一个 `text` prop,`onCopy` 里调 `writeClipboard(text)`,`:1129-1141`;这个 `text` 由 `UserStyleBubble` 从节点数据 `contentParts(data.content)` 解出,`:1398-1404` 把它交出去)。而**改写是替换事件,平台不给它建行**:`messageDefinition.match` 只认 `isAppendSurfaceEvent`(`surfaceOp === 'append'`,`:9267`)。于是那一行永远是**原来那条 append 消息**,行上复制按钮手里是「回复1」,界面上显示「回复2」的却是本插件的替代气泡(`bubble.textContent`)。用户按复制 → 粘出旧文本。**换编辑器无效**:编辑器的文本来自宿主账本(`targetFor` 读 `view.editable`),跟那颗按钮不是一回事。(行号按本机下发版本:DSH `0.2.0-rc.2`。)
|
|
222
|
+
- **修法:接住那颗按钮的按下,平台节点一根手指都不碰**。改写过的行由本插件在**平台自己的**复制按钮上装一个**捕获阶段**的 click 监听(捕获先于宿主挂在 root 上的冒泡处理器),命中即 `stopPropagation`(不让宿主再用旧文本写一次剪贴板),改用**宿主自己的** `writeClipboard`(`@deepseek-ai/dsh-client-ui-primitives`)写入**这一行现在显示的文本**(存在 `data-dshet-copy-text` 上),并自己画出宿主那次「复制成功」:1 秒的勾(样式表 `[data-dshet-copy-flash="1"]`,和宿主一样把图标换成勾)加上宿主自己的文案(`aria-label` 换成「复制成功」,按钮原文案先记在 `data-dshet-copy-label` 里,1 秒后还回去)。
|
|
223
|
+
- **按钮本身不移除、不隐藏、不禁用**(节点归宿主,宿主用自己的 reconciler 卸载它)。**插件走了之后那颗按钮退化成宿主本来的行为(复制原文),而不是变成一颗死按钮**;宿主版本的 `ui-primitives` 若没有 `writeClipboard`,本插件干脆不接管(同样保持宿主行为)。
|
|
224
|
+
- **归属与幂等(I3 / I4)**:接住按钮要用的一切(文本、文案、写入函数)都在**按下那一刻从按钮上读**,所以重载后的新实例能接管旧实例装的那颗监听;`data-dshet-copy-own` 保证同一颗按钮上永远只有一颗监听;行不再被改写、或本插件卸载时,只交还**本插件自己写下的**那些属性(卸载按 `[data-dshet-copy-text]` 全局收回)。兄弟插件的按钮(带自己命名空间的那些)不会被误认成宿主的复制按钮。
|
|
225
|
+
- **不做的**:不改平台代码、不改会话日志;**回复**编辑不受影响(它追加的是**新的一条**回答行,那行自带正确文本);轨迹视图(`dsh-client-ui-trajectory:5273`)有同一个读法,本版未涉及(见「已知限制」)。
|
|
226
|
+
- **English**: the platform's copy action hands over the text of the message the row was DRAWN for, and a rewritten prompt gets no row of its own (rows are built for append-origin surface events only), so copying from that row pasted the wording the user had replaced. This plugin now answers that press from a capture-phase listener on the host's own copy button - stopping the host's handler from writing the stale text - writes the text the row shows through the host's own `writeClipboard`, draws the host's own one-second "copied" check and label itself, and touches nothing else on the button: never removed, never hidden, never disabled, so a bundle that goes away leaves a working button that copies its own message again.
|
|
227
|
+
- 本版:`npm test` **141/141**、`npm run verify:contract` 80/80、`npm run verify:client` **163/163**(`npm run check` exit 0);新增 8 条 DOM 用例、6 条静态检查、2 个 `verify:live` 标记。DOM 桩顺带补上 `data-*` 属性与 `dataset` 的**双向反射**(此前只有 dataset→属性 一个方向:`setAttribute('data-...')` 造出来的夹具对 `dataset` 查不到,而浏览器里两者是同一件事)。**负向验证**(`/tmp` 副本,仓库文件未动;逐点还原后只跑 `test/client.dom.test.js`):整份 `lib/client.js` 还原到 HEAD → **7 条新用例转红**(第 8 条「没被改写的行交给宿主」是不越权的守卫,本就该绿);逐点:去掉接管调用 → 7 红、恢复行时不再释放 → 1 红、卸载不再收回 → 1 红、去掉 `stopPropagation` → 1 红、不画勾 → 1 红、不还文案 → 1 红、不再删文本标记 → 2 红、不再跳过兄弟按钮 → 1 红、去掉捕获标志 → 1 红、去掉 `data-dshet-copy-own` 优先查找 → 1 红。两套识别(按宿主的文案、按「条里没人认领的第一个按钮」)互为兜底:单独去掉任一条用例仍全绿,**同时去掉正好 2 红**。
|
|
228
|
+
- **验证表里 `npm test` 的旧数字(105)是过期值**,一并改成实测的 139(英文表同处一并改)。
|
|
229
|
+
- **真机提示**:运行中的实例下发的是 profile 里**安装的副本**(`~/.dsh/profiles/web/node_modules/dsh-edit-turn`,仍是 0.2.16),要看到本修复需要**重新安装本目录 + 重启 DSH**;本次没有重启,也没跑 `verify:live` / `verify:ui`(前者需要启动时打印的 token,后者没有可驱动的调试标签页)。
|
|
230
|
+
|
|
216
231
|
**0.2.15** —— 修一个自己埋的隐患:回复编辑开的合成轮次会让下一次提问撞号。
|
|
217
232
|
|
|
218
233
|
- **发现**:回复编辑必须在日志里开一个轮次(读路径只在"打开的轮次+步骤"里认 `assistant/message`,修正消息就是 assistant),但 **agent 循环的轮号计数器在 `phase.lastTurn`,是循环构造时播种、只由它自己开的轮推进的**——它看不见我们追加的 `turn/start`。于是下一次提问在同一个会话里会开出**同一个轮号**,两条 `turn/start` 撞号,整份日志在下次加载时报 `turn/start does not open the expected turn`,这正是我们 0.2.4 修掉的那类事故的另一个入口。兄弟插件 dsh-rerun-turn 早踩过并解决了同一问题(其 0.1.1:改用不占轮次的载体 + 事后 `syncLoopTurn`);我们照做:回复编辑闭合自己的轮次后,**把活循环的 `lastTurn` 推到该轮号**。
|
|
@@ -479,11 +494,13 @@ The transcript only builds rows for **append surface events** (ui-chat's user an
|
|
|
479
494
|
1. **A prompt edit keeps a single-node window**: `planRollback` for `mode === 'prompt'` shadows exactly the message itself (`shadowed = [target.seq]`) and never rolls back to the tail;
|
|
480
495
|
2. **The replacement event has the type of the target**: a `user/message` edit lands a `user/message` (a reply cannot be swapped in place — see 5);
|
|
481
496
|
3. **`sourceEventSeqs` always lists the whole window**: the official validator demands complete coverage; one missing node and the append is refused;
|
|
482
|
-
4. **Every replacement carries the semantic marker**: `source.
|
|
497
|
+
4. **Every replacement carries the semantic marker**: `source.editedBy === 'dsh-edit-turn'` — including the empty text-free `developer/message` carrier, so siblings can recognise rewrites by `editedBy` alone instead of inferring from the window shape;
|
|
483
498
|
5. **A reply edit is a multi-node rollback plus an appended correction**, not an in-place swap: the old reply row keeping no entry is **by design** (the new row carries it). Do not add `sourceEventSeqs` to an `assistant/message` to keep the old row's entry — the official validator refuses it outright;
|
|
484
499
|
6. **The mapping is published**: `revisions[]` on `GET /dsh-edit-turn/state` gives `{ replacementSeq, startSeq, endSeq, shadowed }` (the field names the `POST /apply` response already used), and `hidden[]` lists `{ seq, turn, replacement }` per shadowed row. No sibling has to re-derive the ledger from the event stream.
|
|
485
500
|
|
|
486
|
-
**The `source.kind` behaviour
|
|
501
|
+
**The `source.kind` behaviour — this one was a bug:** after a prompt edit the landed `user/message` **keeps `source.kind === 'user'`**, with the provenance on `source.editedBy`. About twenty places in DSH detect human prompts with `source.kind === 'user'` — `dsh-session-turn-outline` (the turn rail and the Trajectory view), `dsh-client-ui-trajectory`, `dsh-client-ui-chat`, the inbox steering filter, `lastPromptAt` on the session list. The carrier stands in the user's place, so changing that kind made every one of those consumers stop seeing the turn's prompt: the rail lost it, `turnOutline` reported an empty prompt, and the Trajectory view filed the wording under context rather than under the user. (0.2.15 and earlier did exactly that.)
|
|
502
|
+
|
|
503
|
+
Keeping `kind === 'user'` does **not** turn the carrier into fresh input that gets answered: queueing never reads `kind` — the inbox projection's reducer handles only `agent/inbox/spliced` (`dsh-agent-loop`), and that event is written solely by an explicit agent splice; the scans that skip past fresh input test `surfaceOp === 'append'`, and this carrier is `surfaceOp: { op: 'replace' }`.
|
|
487
504
|
|
|
488
505
|
### Verification status
|
|
489
506
|
|
|
@@ -494,7 +511,7 @@ Verified against DSH `0.1.6-alpha.2` and `0.1.7-rc.1`, entirely **without model
|
|
|
494
511
|
| Is the plugin actually mounted in a running instance? (read-only route-guard probe, no token needed) | `npm run probe:loaded [port]` | pass: `/state` answers 400 and `/apply` answers 405 - status codes only this plugin produces |
|
|
495
512
|
| What the browser half asked for and got back (read-only diagnostics, failures included) | `GET /dsh-edit-turn/debug` | pass: a real browser was confirmed fetching replies, and an old v3 session was reproduced as session-not-found |
|
|
496
513
|
| Official append AND READ contract against the real validator, in process | `npm run verify:contract` | 80 checks pass (every write sequence is reloaded through the reader afterwards): the replacement is accepted, the derived history really shrinks, the log stays append-only, a tool result leaves with its call, the empty developer carrier adds no model message, **two consecutive rollbacks are both accepted**, **the whole reply-editing mechanism is accepted**, **the invariants siblings depend on (single-node window, same event type, full `sourceEventSeqs` coverage, an `editedBy` marker on every replacement, a rewritten prompt whose `source.kind` is not `user`, a correction that carries no `sourceEventSeqs`)** |
|
|
497
|
-
| Pure logic, host integration and browser-half DOM behaviour (real HTTP, real validator, stubbed services, DOM stub) | `npm test` |
|
|
514
|
+
| Pure logic, host integration and browser-half DOM behaviour (real HTTP, real validator, stubbed services, DOM stub) | `npm test` | 139 tests pass |
|
|
498
515
|
| Browser-half static checks (registration, i18n completeness, styles, skin legibility, three-way version sync, wire contract) | `npm run verify:client` | all pass |
|
|
499
516
|
| Live client artifact (is the running DSH serving the current code?) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | all pass, **including anchors on the host's node shapes** |
|
|
500
517
|
| **Real browser rendering smoke** (CDP drives an already-open tab, read-only) | `npm run verify:ui` | all pass: the served bytes are the edited bytes, no empty placeholder left by a retired entry, the reply pencil sits in the platform action bar, a rolled-back row keeps its action bar, **every collapsed user message still shows the text that replaced it**, **that bubble sits above the time and the copy, in the order the platform draws a message**, **the rewriting pencil sits at the end of the surviving bar (confirmed live in the original session, the other session, and back again)**, **the reply pencil leads its bar, ahead of the copy (confirmed live)**, no `.dshet-floating` covers the revision text, and the entries survive a session round trip |
|
|
@@ -725,6 +742,7 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
|
|
|
725
742
|
- **The rollback is durable; the hiding is not.** Once written, the rollback permanently changes the model context. Hiding those rows in the transcript is this plugin's browser half. Uninstall the plugin and the old rows reappear - while the rollback in the model context still stands, because the replacement is an official event that outlives the plugin.
|
|
726
743
|
- **The system prompt is never touched**: the window can never include surface node 0, and that node is never editable.
|
|
727
744
|
- **Old sessions (v3-format logs) cannot be read under DSH 0.1.7**: the session reader answers `session-not-found` for them, so the plugin does not work in one at all (the visible symptom is no edit entry). Newly written sessions are v4 and work normally.
|
|
745
|
+
- **Only the transcript's own copy button is taken over on a rewritten row.** The platform draws the same stale text in its other copy affordance - the **Trajectory view** (`dsh-client-ui-trajectory`) - which this release does not answer: copying a rewritten message there still hands over the pre-rewrite wording.
|
|
728
746
|
|
|
729
747
|
### Troubleshooting: the edit entry disappeared entirely
|
|
730
748
|
|
package/lib/client.js
CHANGED
|
@@ -34,13 +34,21 @@ window.__ModuleLoader__.load({
|
|
|
34
34
|
const RERUN_PREFIX = '/dsh-rerun-turn'
|
|
35
35
|
|
|
36
36
|
/** Keep in sync with package.json and lib/index.js. */
|
|
37
|
-
const PLUGIN_VERSION = '0.2.
|
|
37
|
+
const PLUGIN_VERSION = '0.2.17'
|
|
38
38
|
|
|
39
39
|
// --- copy -----------------------------------------------------------------
|
|
40
40
|
|
|
41
41
|
const zh = {
|
|
42
42
|
'action.edit': '编辑这条消息',
|
|
43
43
|
'action.edit-reply': '编辑这条回答',
|
|
44
|
+
// The platform's own words for its copy action, spelled here because the
|
|
45
|
+
// row's copy button is recognised BY them (see `nativeCopyButton`): the host
|
|
46
|
+
// labels that button through the shared `common` dictionary, and this is the
|
|
47
|
+
// same wording. Namespace keys shadow the shared ones, so these two keep the
|
|
48
|
+
// bundle's own key surface complete; the structural rule does not need them.
|
|
49
|
+
'copy': '复制',
|
|
50
|
+
'copied': '复制成功',
|
|
51
|
+
'action.copied': '复制成功',
|
|
44
52
|
'editor.title': '编辑这条消息',
|
|
45
53
|
'editor.title-reply': '编辑这条回答',
|
|
46
54
|
'editor.placeholder': '改成你希望自己当时说的内容。保存后这条消息之后的轮次会从模型上下文中移除,改后的内容成为新的起点。',
|
|
@@ -78,6 +86,9 @@ window.__ModuleLoader__.load({
|
|
|
78
86
|
const en = {
|
|
79
87
|
'action.edit': 'Edit this message',
|
|
80
88
|
'action.edit-reply': 'Edit this reply',
|
|
89
|
+
'copy': 'Copy',
|
|
90
|
+
'copied': 'Copied',
|
|
91
|
+
'action.copied': 'Copied',
|
|
81
92
|
'editor.title': 'Edit this message',
|
|
82
93
|
'editor.title-reply': 'Edit this reply',
|
|
83
94
|
'editor.placeholder': 'Saving rolls the conversation back to just before this message and this turn runs again.',
|
|
@@ -190,6 +201,15 @@ window.__ModuleLoader__.load({
|
|
|
190
201
|
// the platform's time / copy / delete buttons (and every other plugin's)
|
|
191
202
|
// stay reachable, so only rows with nothing worth keeping go fully away.
|
|
192
203
|
'[data-dshet-hidden="1"]:not([data-dshet-keep-actions]){display:none!important}',
|
|
204
|
+
// The one platform action on such a row that cannot answer for itself:
|
|
205
|
+
// copy hands over the text of the message the row was DRAWN for - the
|
|
206
|
+
// wording the user replaced, because the host draws no row for a
|
|
207
|
+
// replacement event. This half answers that press and draws the host's
|
|
208
|
+
// own "copied" moment on the same button (`takeOverRowCopy`): the icon
|
|
209
|
+
// is swapped for a check the way the host swaps it, for the same second.
|
|
210
|
+
'[data-dshet-copy-flash="1"]{position:relative}',
|
|
211
|
+
'[data-dshet-copy-flash="1"]>svg{opacity:0}',
|
|
212
|
+
'[data-dshet-copy-flash="1"]::after{content:"";position:absolute;left:50%;top:50%;width:7px;height:12px;margin:-7px 0 0 -4px;border:solid currentColor;border-width:0 1.5px 1.5px 0;transform:rotate(45deg)}',
|
|
193
213
|
// One rounded container, the way the platform's own composer box is
|
|
194
214
|
// built: the input is transparent and borderless inside it, the actions
|
|
195
215
|
// sit at its bottom-right, and focus is shown on the container rather
|
|
@@ -893,6 +913,9 @@ window.__ModuleLoader__.load({
|
|
|
893
913
|
const existing = ownRevisionBubble(row)
|
|
894
914
|
if (revisionSeq === null || !hidden) {
|
|
895
915
|
if (existing !== null) existing.remove()
|
|
916
|
+
// Nothing of ours stands in for this row any more: the row's own copy
|
|
917
|
+
// button answers for itself again.
|
|
918
|
+
releaseRowCopy(row)
|
|
896
919
|
return true
|
|
897
920
|
}
|
|
898
921
|
// Only one bubble per row, ever: drop any other bubble that claims it
|
|
@@ -912,6 +935,7 @@ window.__ModuleLoader__.load({
|
|
|
912
935
|
const text = entry && typeof entry.text === 'string' ? entry.text : ''
|
|
913
936
|
if (text === '') {
|
|
914
937
|
if (existing !== null) existing.remove()
|
|
938
|
+
releaseRowCopy(row)
|
|
915
939
|
return node.kind !== 'user'
|
|
916
940
|
}
|
|
917
941
|
const bubble = existing === null ? cloneUserBubble(row) : existing
|
|
@@ -936,14 +960,21 @@ window.__ModuleLoader__.load({
|
|
|
936
960
|
injectRowAction(row, editTarget, controller, t)
|
|
937
961
|
removeRowAction(bubble)
|
|
938
962
|
bubble.classList.remove('dshet-revision-action')
|
|
963
|
+
// The bar that survived still holds the host's own copy button, and it
|
|
964
|
+
// reads the message this row was drawn for - the wording the user just
|
|
965
|
+
// replaced. This row's text is the bubble above it, so the press is
|
|
966
|
+
// answered here (`takeOverRowCopy`).
|
|
967
|
+
takeOverRowCopy(row, text, t)
|
|
939
968
|
} else {
|
|
940
969
|
injectRowAction(bubble, editTarget, controller, t, { gutter: true })
|
|
941
970
|
removeRowAction(row)
|
|
942
971
|
bubble.classList.add('dshet-revision-action')
|
|
972
|
+
releaseRowCopy(row)
|
|
943
973
|
}
|
|
944
974
|
} else {
|
|
945
975
|
removeRowAction(bubble)
|
|
946
976
|
bubble.classList.remove('dshet-revision-action')
|
|
977
|
+
releaseRowCopy(row)
|
|
947
978
|
}
|
|
948
979
|
return true
|
|
949
980
|
}
|
|
@@ -995,6 +1026,237 @@ window.__ModuleLoader__.load({
|
|
|
995
1026
|
return null
|
|
996
1027
|
}
|
|
997
1028
|
|
|
1029
|
+
// --- the rewritten row answers its own copy -------------------------------
|
|
1030
|
+
//
|
|
1031
|
+
// The platform's copy action hands over the text of the message it DREW the
|
|
1032
|
+
// row for. A prompt this plugin rewrote in place never gets a row of its own:
|
|
1033
|
+
// the host draws transcript rows for append-origin surface events only
|
|
1034
|
+
// (`isAppendSurfaceEvent` in dsh-client-ui-chat), and a rewrite is a
|
|
1035
|
+
// replacement, so the row that stands in for the message is the ORIGINAL one
|
|
1036
|
+
// and still carries the wording the user replaced. Pressing copy on a
|
|
1037
|
+
// rewritten prompt therefore pastes what the user was trying to get rid of:
|
|
1038
|
+
// rewrite 回复1 into 回复2, press copy, paste 回复1.
|
|
1039
|
+
//
|
|
1040
|
+
// The button cannot be repaired from here - its text is read off the host's
|
|
1041
|
+
// projection node, while the rewritten wording is the textContent of a bubble
|
|
1042
|
+
// this half owns - and it must not be taken out either: the host owns that
|
|
1043
|
+
// node and unmounts it through its own reconciler, and a bundle that goes
|
|
1044
|
+
// away has to leave a working button behind, not a dead one. What this half
|
|
1045
|
+
// CAN do is answer the press first: the listener is planted in the CAPTURE
|
|
1046
|
+
// phase, the host listens for clicks at its root in the bubble phase, so this
|
|
1047
|
+
// runs ahead of the host's handler and stops it, and the text it writes is
|
|
1048
|
+
// the text this row actually shows.
|
|
1049
|
+
//
|
|
1050
|
+
// Everything the listener needs is read off the BUTTON when the press
|
|
1051
|
+
// happens - the text, the label, the writer - never captured in the closure.
|
|
1052
|
+
// A reloaded bundle finds the button by its marker, refreshes that state, and
|
|
1053
|
+
// a listener the instance before it planted goes on working (the rule
|
|
1054
|
+
// `wireAction` follows for the pencil).
|
|
1055
|
+
const COPY_TEXT = 'dshetCopyText'
|
|
1056
|
+
const COPY_FLASH = 'dshetCopyFlash'
|
|
1057
|
+
const COPY_LABEL = 'dshetCopyLabel'
|
|
1058
|
+
const COPY_OWN = 'dshetCopyOwn'
|
|
1059
|
+
/** The live state of one press; a name no sibling plugin uses. */
|
|
1060
|
+
const COPY_PRESS = 'dshetCopyPress'
|
|
1061
|
+
const COPY_TIMER = 'dshetCopyTimer'
|
|
1062
|
+
/** The host draws its own check for a second; this is the same second. */
|
|
1063
|
+
const COPY_FEEDBACK_MS = 1000
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* A button another plugin put in this bar.
|
|
1067
|
+
*
|
|
1068
|
+
* Every injected action in this family carries its own namespace marker (the
|
|
1069
|
+
* three sibling plugins do, by contract), and the host's own actions carry no
|
|
1070
|
+
* `data-*` of their own at all. This plugin's four markers are excluded:
|
|
1071
|
+
* they are written on the HOST's button, not on one of ours.
|
|
1072
|
+
*/
|
|
1073
|
+
function pluginOwned(button) {
|
|
1074
|
+
const data = button.dataset
|
|
1075
|
+
if (data === undefined || data === null) return false
|
|
1076
|
+
for (const key of Object.keys(data)) {
|
|
1077
|
+
if (key === COPY_TEXT || key === COPY_FLASH || key === COPY_LABEL || key === COPY_OWN) continue
|
|
1078
|
+
if (key !== '') return true
|
|
1079
|
+
}
|
|
1080
|
+
return false
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* The platform's own copy button in a row's action bar, or null when the bar
|
|
1085
|
+
* holds none of its own to answer for.
|
|
1086
|
+
*
|
|
1087
|
+
* The host draws the clock, then the copy button, and gives a user row neither
|
|
1088
|
+
* a branch action nor an extra strip - so the bar's FIRST button is the copy
|
|
1089
|
+
* button. The CSS-module class the host's icons carry is deliberately not
|
|
1090
|
+
* required: it is a build hash that can be renamed, and a rule that reads it
|
|
1091
|
+
* would go quietly dead on the day it is (the pencil placement still matches
|
|
1092
|
+
* that suffix, which is a different, visible contract). A sibling plugin's
|
|
1093
|
+
* button is skipped: it carries its own namespace marker, and a sibling's is
|
|
1094
|
+
* never this half's to answer for. A button this half already answers is
|
|
1095
|
+
* found by ITS marker alone (I3), however the bar has been rebuilt around it.
|
|
1096
|
+
*/
|
|
1097
|
+
function nativeCopyButton(row, t) {
|
|
1098
|
+
if (row === null || row === undefined || typeof row.querySelector !== 'function') return null
|
|
1099
|
+
const bar = row.querySelector('[class*="_actions"]')
|
|
1100
|
+
if (bar === null || bar === undefined) return null
|
|
1101
|
+
// The host's own wording for the two states of its copy button. It resolves
|
|
1102
|
+
// through the same namespace -> shared `common` fallback the host's own
|
|
1103
|
+
// lookup uses, so this names the button rather than guessing at it; a host
|
|
1104
|
+
// that cannot resolve them simply falls through to the rule below.
|
|
1105
|
+
const copy = typeof t === 'function' ? t('copy') : null
|
|
1106
|
+
const copied = typeof t === 'function' ? t('copied') : null
|
|
1107
|
+
let unclaimed = null
|
|
1108
|
+
for (const child of bar.children) {
|
|
1109
|
+
if (child.tagName !== 'BUTTON') continue
|
|
1110
|
+
if (child.dataset !== undefined && child.dataset[COPY_OWN] === '1') return child
|
|
1111
|
+
if (pluginOwned(child)) continue
|
|
1112
|
+
const label = typeof child.getAttribute === 'function' ? child.getAttribute('aria-label') : null
|
|
1113
|
+
if (typeof label === 'string' && label !== '' && (label === copy || label === copied)) return child
|
|
1114
|
+
if (unclaimed === null) unclaimed = child
|
|
1115
|
+
}
|
|
1116
|
+
return unclaimed
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* Hand a rewritten row's copy press to this half.
|
|
1121
|
+
*
|
|
1122
|
+
* The button is left exactly as the host rendered it - not removed, not
|
|
1123
|
+
* hidden, not disabled; only this half's own markers go on it. Without the
|
|
1124
|
+
* host's own clipboard writer the button keeps the host's behaviour too: a
|
|
1125
|
+
* takeover that cannot copy is worse than none.
|
|
1126
|
+
*/
|
|
1127
|
+
function takeOverRowCopy(row, text, t) {
|
|
1128
|
+
const button = nativeCopyButton(row, t)
|
|
1129
|
+
if (button === null) return
|
|
1130
|
+
const write = primitives !== null && primitives !== undefined && typeof primitives.writeClipboard === 'function'
|
|
1131
|
+
? primitives.writeClipboard
|
|
1132
|
+
: null
|
|
1133
|
+
if (write === null) return
|
|
1134
|
+
if (button.dataset !== undefined && button.dataset[COPY_OWN] !== '1') {
|
|
1135
|
+
button.dataset[COPY_OWN] = '1'
|
|
1136
|
+
if (typeof button.addEventListener === 'function') {
|
|
1137
|
+
// Capture: this runs ahead of the host's own click handling, which
|
|
1138
|
+
// listens at the root and would write the message the row was drawn for.
|
|
1139
|
+
// Stopping the event there is also what keeps that handler from running
|
|
1140
|
+
// at all, so the host's own follow-up (its tooltip withdraw, its check)
|
|
1141
|
+
// does not happen for this press - which is why the check is drawn here.
|
|
1142
|
+
button.addEventListener('click', (event) => pressRowCopy(button, event), true)
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
if (button.dataset !== undefined) {
|
|
1146
|
+
// The host's own wording, kept to give back: what the button said before
|
|
1147
|
+
// this half drew its check over it.
|
|
1148
|
+
if (button.dataset[COPY_LABEL] === undefined) {
|
|
1149
|
+
const label = typeof button.getAttribute === 'function' ? button.getAttribute('aria-label') : null
|
|
1150
|
+
if (typeof label === 'string' && label !== '') button.dataset[COPY_LABEL] = label
|
|
1151
|
+
}
|
|
1152
|
+
if (button.dataset[COPY_TEXT] !== text) button.dataset[COPY_TEXT] = text
|
|
1153
|
+
}
|
|
1154
|
+
button[COPY_PRESS] = { done: t('action.copied'), write }
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* The press itself.
|
|
1159
|
+
*
|
|
1160
|
+
* No text marker means the row is not (or no longer) a rewritten prompt, and
|
|
1161
|
+
* the press is left completely alone: the host's own handler answers it, and a
|
|
1162
|
+
* released row behaves like every other row.
|
|
1163
|
+
*/
|
|
1164
|
+
function pressRowCopy(button, event) {
|
|
1165
|
+
const data = button.dataset
|
|
1166
|
+
if (data === undefined || typeof data[COPY_TEXT] !== 'string') return
|
|
1167
|
+
if (event !== null && event !== undefined) {
|
|
1168
|
+
if (typeof event.preventDefault === 'function') event.preventDefault()
|
|
1169
|
+
if (typeof event.stopPropagation === 'function') event.stopPropagation()
|
|
1170
|
+
}
|
|
1171
|
+
// One copy per check, the way the host paces its own button.
|
|
1172
|
+
if (data[COPY_FLASH] === '1') return
|
|
1173
|
+
const press = button[COPY_PRESS]
|
|
1174
|
+
if (press === null || press === undefined || typeof press.write !== 'function') return
|
|
1175
|
+
let pending = null
|
|
1176
|
+
try {
|
|
1177
|
+
pending = press.write(data[COPY_TEXT])
|
|
1178
|
+
} catch {
|
|
1179
|
+
return
|
|
1180
|
+
}
|
|
1181
|
+
if (pending === null || pending === undefined || typeof pending.then !== 'function') return
|
|
1182
|
+
pending.then(
|
|
1183
|
+
(ok) => {
|
|
1184
|
+
if (ok === true) flashRowCopy(button, press.done)
|
|
1185
|
+
},
|
|
1186
|
+
() => {},
|
|
1187
|
+
)
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* The host's own "copied" moment, drawn on the button it belongs to.
|
|
1192
|
+
*
|
|
1193
|
+
* The host shows it by swapping its icon and its label. On this row its own
|
|
1194
|
+
* React state never hears about the press, so this half draws the same thing
|
|
1195
|
+
* from its stylesheet (`[data-dshet-copy-flash="1"]`) and gives the borrowed
|
|
1196
|
+
* label back when the moment is over. Both are this half's own writes, and
|
|
1197
|
+
* both are taken back on release (I4).
|
|
1198
|
+
*/
|
|
1199
|
+
function flashRowCopy(button, done) {
|
|
1200
|
+
const data = button.dataset
|
|
1201
|
+
if (data === undefined) return
|
|
1202
|
+
data[COPY_FLASH] = '1'
|
|
1203
|
+
if (typeof done === 'string' && done !== '' && typeof button.setAttribute === 'function') {
|
|
1204
|
+
button.setAttribute('aria-label', done)
|
|
1205
|
+
}
|
|
1206
|
+
if (button[COPY_TIMER] !== undefined && button[COPY_TIMER] !== null) {
|
|
1207
|
+
if (typeof window.clearTimeout === 'function') window.clearTimeout(button[COPY_TIMER])
|
|
1208
|
+
}
|
|
1209
|
+
if (typeof window.setTimeout !== 'function') return
|
|
1210
|
+
button[COPY_TIMER] = window.setTimeout(() => {
|
|
1211
|
+
button[COPY_TIMER] = null
|
|
1212
|
+
clearRowCopyFlash(button)
|
|
1213
|
+
}, COPY_FEEDBACK_MS)
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Take the check back.
|
|
1218
|
+
*
|
|
1219
|
+
* `data-dshet-copy-label` is what the host's own button said before this half
|
|
1220
|
+
* drew its check on it; the host never wrote it, so giving it back is this
|
|
1221
|
+
* half returning its own change (I4).
|
|
1222
|
+
*/
|
|
1223
|
+
function clearRowCopyFlash(button) {
|
|
1224
|
+
if (button[COPY_TIMER] !== undefined && button[COPY_TIMER] !== null) {
|
|
1225
|
+
if (typeof window.clearTimeout === 'function') window.clearTimeout(button[COPY_TIMER])
|
|
1226
|
+
button[COPY_TIMER] = null
|
|
1227
|
+
}
|
|
1228
|
+
const data = button.dataset
|
|
1229
|
+
if (data === undefined) return
|
|
1230
|
+
delete data[COPY_FLASH]
|
|
1231
|
+
const label = data[COPY_LABEL]
|
|
1232
|
+
if (typeof label === 'string' && label !== '' && typeof button.setAttribute === 'function') {
|
|
1233
|
+
button.setAttribute('aria-label', label)
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** Hand one platform copy button back: nothing of ours is left WRITTEN on it. */
|
|
1238
|
+
function releaseCopyButton(button) {
|
|
1239
|
+
if (button === null || button === undefined || typeof button !== 'object') return
|
|
1240
|
+
clearRowCopyFlash(button)
|
|
1241
|
+
const data = button.dataset
|
|
1242
|
+
if (data !== undefined) {
|
|
1243
|
+
delete data[COPY_TEXT]
|
|
1244
|
+
delete data[COPY_LABEL]
|
|
1245
|
+
}
|
|
1246
|
+
button[COPY_PRESS] = undefined
|
|
1247
|
+
// `data-dshet-copy-own` stays, and so does the listener: neither claims
|
|
1248
|
+
// anything about the message any more, and that marker is what stops a
|
|
1249
|
+
// reloaded bundle from planting a second listener on the same button. A
|
|
1250
|
+
// press with no text marker returns above and reaches the host's own
|
|
1251
|
+
// handler - exactly the behaviour the row had before this plugin saw it.
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/** The copy handover of a row, wherever the last pass left it. */
|
|
1255
|
+
function releaseRowCopy(row) {
|
|
1256
|
+
const button = nativeCopyButton(row)
|
|
1257
|
+
if (button !== null) releaseCopyButton(button)
|
|
1258
|
+
}
|
|
1259
|
+
|
|
998
1260
|
// The message a chain of rewrites ends at.
|
|
999
1261
|
//
|
|
1000
1262
|
// Rewriting the same message twice lands two replacements: 8 -> 19 for the
|
|
@@ -1894,6 +2156,11 @@ window.__ModuleLoader__.load({
|
|
|
1894
2156
|
// markers are this plugin's own writes, so this is its own state - and
|
|
1895
2157
|
// only its own: the restore path asks the attribution guard first (I4).
|
|
1896
2158
|
each('[data-dshet-hidden="1"]', (row) => setRowHidden(row, false))
|
|
2159
|
+
// A row's copy button was answering for this half; that handover is this
|
|
2160
|
+
// half's own write on a HOST node and goes back the same way (I4). The
|
|
2161
|
+
// button itself stays where the host owns it - it simply copies its own
|
|
2162
|
+
// message again, through the host's handler.
|
|
2163
|
+
each('[data-dshet-copy-text]', (button) => releaseCopyButton(button))
|
|
1897
2164
|
each('[data-dshet-collapsed="1"]', (child) => {
|
|
1898
2165
|
child.style.display = ''
|
|
1899
2166
|
delete child.dataset.dshetCollapsed
|
package/lib/index.js
CHANGED
|
@@ -57,7 +57,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
|
|
|
57
57
|
export const name = PLUGIN_ID
|
|
58
58
|
|
|
59
59
|
/** Keep in sync with package.json and lib/client.js. */
|
|
60
|
-
export const PLUGIN_VERSION = '0.2.
|
|
60
|
+
export const PLUGIN_VERSION = '0.2.17'
|
|
61
61
|
|
|
62
62
|
// A ring of the last few requests, answering "did the browser half even ask, and
|
|
63
63
|
// what did it get?" without needing its console. Read-only diagnostics: the
|
|
@@ -248,16 +248,34 @@ export function messageIdOf(event) {
|
|
|
248
248
|
* @param event - raw session event.
|
|
249
249
|
* @returns true for a human prompt.
|
|
250
250
|
*/
|
|
251
|
+
export function isEditCarrier(source) {
|
|
252
|
+
if (!source || typeof source !== 'object') return false
|
|
253
|
+
// Current shape. `editedBy` is what identifies a carrier now; the prompt carrier
|
|
254
|
+
// deliberately keeps `kind: 'user'` (see buildCarrier) so the platform still reads
|
|
255
|
+
// it as the human prompt, and the silent reply carrier keeps a producer-owned kind.
|
|
256
|
+
if (source.editedBy === PLUGIN_ID) return true
|
|
257
|
+
// Historical shapes, kept so ledgers and editability stay correct over logs
|
|
258
|
+
// written by earlier releases: the producer-owned kind without `editedBy`, and
|
|
259
|
+
// the session-format v3 `{ kind: 'plugin', plugin }`.
|
|
260
|
+
if (source.kind === `plugin:${PLUGIN_ID}`) return true
|
|
261
|
+
return source.kind === 'plugin' && source.plugin === PLUGIN_ID
|
|
262
|
+
}
|
|
263
|
+
|
|
251
264
|
export function isHumanPrompt(event) {
|
|
252
265
|
if (!event || event.type !== 'user/message') return false
|
|
253
266
|
const data = event.data
|
|
254
267
|
if (!data || !data.source) return false
|
|
268
|
+
// The platform's own test, and the one every consumer applies (`turn-outline`,
|
|
269
|
+
// the Trajectory view, `ui-chat`, the inbox steering filter, `lastPromptAt`): a
|
|
270
|
+
// human prompt is a `user/message` whose source kind is `'user'`. A message this
|
|
271
|
+
// plugin rewrote stands in a human's place and keeps that kind, so it stays
|
|
272
|
+
// editable (edit it again). Matching a plugin-owned kind instead is what made
|
|
273
|
+
// the rewrite invisible to the turn outline - the round lost its prompt.
|
|
255
274
|
if (data.source.kind === 'user') return true
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
return data.source.kind === `plugin:${PLUGIN_ID}` && data.source.editedBy === PLUGIN_ID
|
|
275
|
+
// Carriers written before that fix carry a producer-owned kind. Same message -
|
|
276
|
+
// a `user/message` this plugin rewrote - so it must stay editable, or upgrading
|
|
277
|
+
// would silently make every earlier edit un-editable.
|
|
278
|
+
return isEditCarrier(data.source)
|
|
261
279
|
}
|
|
262
280
|
|
|
263
281
|
/**
|
|
@@ -304,9 +322,10 @@ export function readMessageText(event) {
|
|
|
304
322
|
/**
|
|
305
323
|
* Rebuild this plugin's rollback ledger from the log alone.
|
|
306
324
|
*
|
|
307
|
-
* Every rollback is one replacement event whose message source
|
|
308
|
-
* `{
|
|
309
|
-
* other producer - compaction, for instance - is
|
|
325
|
+
* Every rollback is one replacement event whose message source carries this
|
|
326
|
+
* plugin's marker (`{ editedBy: 'dsh-edit-turn' }`; see {@link isEditCarrier}).
|
|
327
|
+
* A replacement landed by any other producer - compaction, for instance - is
|
|
328
|
+
* ignored.
|
|
310
329
|
*
|
|
311
330
|
* @param events - complete contiguous raw event log.
|
|
312
331
|
* @returns `{ hidden, edits }`: one hidden entry per shadowed seq, and one
|
|
@@ -322,7 +341,7 @@ export function rollbackLedger(events) {
|
|
|
322
341
|
const event = bySeq.get(replacement.seq)
|
|
323
342
|
const data = event && event.data
|
|
324
343
|
const source = data && (data.source || (data.message && data.message.source))
|
|
325
|
-
if (!(source
|
|
344
|
+
if (!isEditCarrier(source)) continue
|
|
326
345
|
for (const seq of replacement.shadowed) {
|
|
327
346
|
const turn = turnOf.get(seq)
|
|
328
347
|
hidden.push({ seq, turn: typeof turn === 'number' ? turn : null, replacement: replacement.seq })
|
|
@@ -789,7 +808,19 @@ export function buildCarrier(plan, lastTurn, config) {
|
|
|
789
808
|
id: randomUUID(),
|
|
790
809
|
role: 'user',
|
|
791
810
|
content: [{ type: 'text', text: config.markerText }],
|
|
792
|
-
|
|
811
|
+
// `kind: 'user'`, NOT a plugin-owned kind. This carrier stands in a human's
|
|
812
|
+
// place - it IS the wording the user is asking with now - and the platform
|
|
813
|
+
// owns that judgement: a human prompt is exactly a `user/message` whose
|
|
814
|
+
// `source.kind` is `'user'`. Writing anything else here made the turn's
|
|
815
|
+
// prompt unrecognisable to every consumer that tests it -
|
|
816
|
+
// `dsh-session-turn-outline` (the turn rail and the Trajectory view),
|
|
817
|
+
// `dsh-client-ui-trajectory`, `dsh-client-ui-chat`, the inbox steering
|
|
818
|
+
// filter, and `lastPromptAt` on the session list - so an edited turn
|
|
819
|
+
// showed no prompt at all and `turnOutline` reported `prompt: ""`. The
|
|
820
|
+
// format's producer-owned kind (`plugin:<id>`) is for messages a plugin
|
|
821
|
+
// authors in its own voice; a prompt it merely rewrote is not one.
|
|
822
|
+
// `editedBy` below carries the provenance that `kind` used to.
|
|
823
|
+
source: { kind: 'user', editedBy: PLUGIN_ID },
|
|
793
824
|
},
|
|
794
825
|
}
|
|
795
826
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-edit-turn",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.17",
|
|
4
4
|
"description": "Edit any user turn in DeepSeek Harness and re-run it: an inline editor on the message row rolls the conversation back to just before that turn through the official surface-replace contract, then re-prompts the agent with the revised text. The append-only session log is never rewritten.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|