dsh-edit-turn 0.2.14 → 0.2.16
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 +14 -4
- package/lib/client.js +1 -1
- package/lib/index.js +111 -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
|
|
|
@@ -213,6 +215,12 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
|
|
|
213
215
|
|
|
214
216
|
### 更新日志
|
|
215
217
|
|
|
218
|
+
**0.2.15** —— 修一个自己埋的隐患:回复编辑开的合成轮次会让下一次提问撞号。
|
|
219
|
+
|
|
220
|
+
- **发现**:回复编辑必须在日志里开一个轮次(读路径只在"打开的轮次+步骤"里认 `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` 推到该轮号**。
|
|
221
|
+
- **形状与降级**:`syncLoopTurn(agent, maxTurn)` 只看 `phase.kind === 'idle'` 且 `phase.lastTurn` 为数字时才动,否则 `'unavailable'` 什么也不碰(冷会话、循环不在场都走这条),并把结果以 `loopTurn` 写进 apply 响应、把 `unavailable` 记进 `/dsh-edit-turn/debug` 的请求环(字段刻意不叫 `code:`——静态检查会把它当宿主错误码去要文案)。
|
|
222
|
+
- 新增/改写 host 测试 2 个:命中的循环被推过消耗掉的轮号(连续两次编辑,计数器只向前),够不着的循环原样留着并留诊断;加单测 1 个(守卫:running / 缺字段 / null 都返回 unavailable)。129 单测 / 80 契约 / 静态 exit0 / verify:live / verify:ui 全绿。
|
|
223
|
+
|
|
216
224
|
**0.2.14** —— 修「重新 apply 时上一次注入的节点不清理 → 同一条消息行上堆出多个宿主」(互操作契约 I3 / §5)。只修 Bug,交互语义不变。
|
|
217
225
|
|
|
218
226
|
- **修复:重新 apply(HMR / 现场重载 / 插件开关 / bundle 组重载)会在同一行里再种一个编辑宿主,而不是复用上一次那个**。旧宿主的身份记在模块实例的 WeakMap 里,实例一换就认不出;于是每 apply 一次,那条操作条里就多一个「编辑这条消息」的笔。真机 CDP 探针实测同一 strip 里 `dshet-action-host` 多达 **7 个**、children 一度到 17(页面重载后回到 1、stripKids = 5),用户看到的是「一行上出现 3 个重跑按钮」。现在**每个注入节点都带命名空间属性**(宿主 `data-dshet-action-host="1"`、浮层根 `data-dshet-layer="1"`、编辑框 `data-dshet-editor="1"`、替代气泡 `data-dshet-revision="1"`),注入前先按属性在行内查一次、查到就**复用**——并且把它重新指到**当前**控制器(激活函数挂在按钮元素上、按下时才读,不再是上次那份捕获了旧闭包的监听;否则复用的笔点开的是已退休的控制器,表现就是「点了没反应」)。每趟 DOM pass 结束还会清掉「本次实例没有认领的」同命名空间宿主,正是那 7 个的直接来源;扫描只认自己的命名空间,兄弟插件的按钮永远不在候选里(I3)。
|
|
@@ -473,11 +481,13 @@ The transcript only builds rows for **append surface events** (ui-chat's user an
|
|
|
473
481
|
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;
|
|
474
482
|
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);
|
|
475
483
|
3. **`sourceEventSeqs` always lists the whole window**: the official validator demands complete coverage; one missing node and the append is refused;
|
|
476
|
-
4. **Every replacement carries the semantic marker**: `source.
|
|
484
|
+
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;
|
|
477
485
|
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;
|
|
478
486
|
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.
|
|
479
487
|
|
|
480
|
-
**The `source.kind` behaviour
|
|
488
|
+
**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.)
|
|
489
|
+
|
|
490
|
+
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' }`.
|
|
481
491
|
|
|
482
492
|
### Verification status
|
|
483
493
|
|
package/lib/client.js
CHANGED
|
@@ -34,7 +34,7 @@ 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.16'
|
|
38
38
|
|
|
39
39
|
// --- copy -----------------------------------------------------------------
|
|
40
40
|
|
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.16'
|
|
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 })
|
|
@@ -574,6 +593,23 @@ function findLiveSession(ctx, sessionId) {
|
|
|
574
593
|
// Resolve the live Session that owns the append. An already-open session is
|
|
575
594
|
// used directly; a cold one is resumed through the official controller, which
|
|
576
595
|
// is exactly what the web UI does when the user opens it.
|
|
596
|
+
/**
|
|
597
|
+
* The live agent whose loop owns this session's turn counter, when one exists.
|
|
598
|
+
* @param ctx - plugin context.
|
|
599
|
+
* @param sessionId - session to resolve.
|
|
600
|
+
* @returns the agent, or undefined (cold session, or no controller mounted).
|
|
601
|
+
*/
|
|
602
|
+
async function resolveLoopAgent(ctx, sessionId) {
|
|
603
|
+
const controller = ctx.get('sessionController')
|
|
604
|
+
if (!controller || typeof controller.resolveAgent !== 'function') return undefined
|
|
605
|
+
try {
|
|
606
|
+
const result = await controller.resolveAgent(sessionId)
|
|
607
|
+
return result && result.agent ? result.agent : undefined
|
|
608
|
+
} catch {
|
|
609
|
+
return undefined
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
577
613
|
async function resolveSession(ctx, sessionId) {
|
|
578
614
|
const live = findLiveSession(ctx, sessionId)
|
|
579
615
|
if (live) return live
|
|
@@ -589,6 +625,44 @@ async function resolveSession(ctx, sessionId) {
|
|
|
589
625
|
return undefined
|
|
590
626
|
}
|
|
591
627
|
|
|
628
|
+
/**
|
|
629
|
+
* Make the live agent loop agree with a turn this plugin opened in the log.
|
|
630
|
+
*
|
|
631
|
+
* The loop keeps its own counter in an idle-phase field (`phase.lastTurn`): it
|
|
632
|
+
* is seeded when the loop is constructed from the projection and advanced only
|
|
633
|
+
* by turns the loop itself opens. A reply edit has to open a turn - the read
|
|
634
|
+
* path admits an `assistant/message` only inside an open turn and step - so
|
|
635
|
+
* afterwards that field still names the turn this plugin consumed, and the
|
|
636
|
+
* loop's next run would open the same number. That collides: both turn/start
|
|
637
|
+
* events claim one number and the log fails its next load
|
|
638
|
+
* (`turn/start does not open the expected turn`), which is the corruption this
|
|
639
|
+
* plugin already shipped once and repaired.
|
|
640
|
+
*
|
|
641
|
+
* The shape guard is deliberate and the failure is soft: if the loop is not
|
|
642
|
+
* idle, or its shape is not what this expects, nothing is touched and the
|
|
643
|
+
* caller records why. The sibling plugin dsh-rerun-turn solves the same
|
|
644
|
+
* problem the same way (`syncLoopTurn`); there is no official seam for it.
|
|
645
|
+
*
|
|
646
|
+
* @param agent - the live loop agent, when one exists.
|
|
647
|
+
* @param maxTurn - the highest turn now in the log.
|
|
648
|
+
* @returns `'synced' | 'already-current' | 'unavailable'`.
|
|
649
|
+
*/
|
|
650
|
+
export function syncLoopTurn(agent, maxTurn) {
|
|
651
|
+
try {
|
|
652
|
+
const phase = agent && agent.phase
|
|
653
|
+
if (!phase || typeof phase !== 'object' || phase.kind !== 'idle' || typeof phase.lastTurn !== 'number') {
|
|
654
|
+
return 'unavailable'
|
|
655
|
+
}
|
|
656
|
+
if (maxTurn > phase.lastTurn) {
|
|
657
|
+
phase.lastTurn = maxTurn
|
|
658
|
+
return 'synced'
|
|
659
|
+
}
|
|
660
|
+
return 'already-current'
|
|
661
|
+
} catch {
|
|
662
|
+
return 'unavailable'
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
592
666
|
function eventsFromLive(session) {
|
|
593
667
|
if (session && typeof session.snapshotEvents === 'function') {
|
|
594
668
|
try {
|
|
@@ -734,7 +808,19 @@ export function buildCarrier(plan, lastTurn, config) {
|
|
|
734
808
|
id: randomUUID(),
|
|
735
809
|
role: 'user',
|
|
736
810
|
content: [{ type: 'text', text: config.markerText }],
|
|
737
|
-
|
|
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 },
|
|
738
824
|
},
|
|
739
825
|
}
|
|
740
826
|
}
|
|
@@ -885,11 +971,25 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
885
971
|
}
|
|
886
972
|
}
|
|
887
973
|
const corrected = await flushSession(ctx, session)
|
|
974
|
+
// The synthetic turn this edit opened is invisible to the live loop's own
|
|
975
|
+
// counter; without the sync its next run reuses that number and the log
|
|
976
|
+
// fails its next load. Only a reply edit opens a turn - a prompt edit does
|
|
977
|
+
// not, and needs nothing.
|
|
978
|
+
const loopAgent = await resolveLoopAgent(ctx, sessionId)
|
|
979
|
+
const loopTurn = syncLoopTurn(loopAgent, editTurn)
|
|
980
|
+
// `loopTurn` is the diagnostic the request ring records; the field is DELIBERATELY
|
|
981
|
+
// not called `code` - the static checks read `code:` as host error codes and
|
|
982
|
+
// must not start demanding a user-visible message for an internal sync state.
|
|
983
|
+
const syncNote = loopTurn
|
|
984
|
+
if (syncNote === 'unavailable') {
|
|
985
|
+
noteRequest({ kind: 'reply', sessionId, ok: true, loopTurn: syncNote, turn: editTurn })
|
|
986
|
+
}
|
|
888
987
|
return {
|
|
889
988
|
kind: 'reply',
|
|
890
989
|
replacementSeq: replacement.seq,
|
|
891
990
|
appendedSeq: appended.seq,
|
|
892
991
|
applied: true,
|
|
992
|
+
loopTurn,
|
|
893
993
|
shadowed: plan.shadowed,
|
|
894
994
|
...corrected,
|
|
895
995
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-edit-turn",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.16",
|
|
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",
|