dsh-edit-turn 0.2.1 → 0.2.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.
Files changed (4) hide show
  1. package/README.md +418 -62
  2. package/lib/client.js +641 -129
  3. package/lib/index.js +194 -64
  4. package/package.json +6 -2
package/lib/index.js CHANGED
@@ -22,25 +22,37 @@
22
22
  // admission path. It resumes the Session when cold and starts exactly one
23
23
  // new turn from the revised text.
24
24
  //
25
- // The replacement carrier defaults to an EMPTY `system/message`. The official
26
- // format documents an empty later system node as dormant - "empty later nodes
27
- // are dormant and project to no message" - so the rollback adds nothing the
28
- // model can see and the derived context after an edit is exactly what it would
29
- // be had the conversation really stopped at the edit point. `carrier:
30
- // 'user/message'` switches to a short marker text instead; that shape is what
31
- // an equivalent plugin already runs in production, so it is the fallback.
32
- // Either way `sourceEventSeqs` must list the complete shadowed node set, which
25
+ // The silent replacement carrier is an EMPTY `developer/message` (0.2.5;
26
+ // before that an empty `system/message`, which no reader accepts - see the
27
+ // README). Empty content projects to no model message, so the rollback adds
28
+ // nothing the model can see and the derived context after an edit is exactly
29
+ // what it would be had the conversation really stopped at the edit point.
30
+ // `sourceEventSeqs` must list the complete shadowed node set, which
33
31
  // is why an `assistant/message` carrier is impossible (it forbids
34
32
  // `sourceEventSeqs` outright).
35
33
  //
36
- // The module imports only `@deepseek-ai/schemastery` and
37
- // `@deepseek-ai/dsh-tools` (both declared). Every DSH service is resolved
38
- // through the cordis context at call time, so the plugin still loads on a
39
- // profile that lacks one and degrades to a clear HTTP failure instead.
34
+ // The module resolves `@deepseek-ai/schemastery` defensively. `dsh-tools` is a
35
+ // host-provided peer (the loader resolves it for every plugin), but the schema
36
+ // package is a regular dependency: when it cannot be resolved - a pruned
37
+ // package cache, an installation that skipped dependencies - a static import
38
+ // takes the whole plugin down and the feature simply disappears from the UI
39
+ // with nothing but a line on the host's stdout to explain it. A guarded require
40
+ // degrades to "no config form, defaults apply" instead, which costs a settings
41
+ // surface rather than the feature. Every field is optional in `apply` anyway.
40
42
  import { randomUUID } from 'node:crypto'
41
- import z from '@deepseek-ai/schemastery'
43
+ import { createRequire } from 'node:module'
42
44
  import { defineTool } from '@deepseek-ai/dsh-tools'
43
45
 
46
+ const nodeRequire = createRequire(import.meta.url)
47
+ const schemaPackage = (() => {
48
+ try {
49
+ return nodeRequire('@deepseek-ai/schemastery')
50
+ } catch {
51
+ return null
52
+ }
53
+ })()
54
+ const z = schemaPackage === null ? null : (schemaPackage.default ?? schemaPackage)
55
+
44
56
  /** Plugin id shared by the host and browser halves. */
45
57
  export const PLUGIN_ID = 'dsh-edit-turn'
46
58
 
@@ -48,7 +60,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
48
60
  export const name = PLUGIN_ID
49
61
 
50
62
  /** Keep in sync with package.json and lib/client.js. */
51
- export const PLUGIN_VERSION = '0.2.1'
63
+ export const PLUGIN_VERSION = '0.2.5'
52
64
 
53
65
  // A ring of the last few requests, answering "did the browser half even ask, and
54
66
  // what did it get?" without needing its console. Read-only diagnostics: the
@@ -82,16 +94,32 @@ const FLUSH_DEADLINE_MS = 5_000
82
94
  */
83
95
  const DEFAULT_MARKER = '[dsh-edit-turn] The user revised an earlier message. Every turn after that message was rolled back; the revised message follows. Send your reply to the revised message.'
84
96
 
85
- /** Deployment policy for the rollback. */
86
- export const Config = z.object({
87
- markerText: z.string().default(DEFAULT_MARKER),
88
- /**
89
- * Whether the client asks for a second confirmation before applying. Off by
90
- * default: the editor is already an explicit action the user opened, and the
91
- * panel states what saving does. Set it to `true` to require the extra step.
92
- */
93
- confirm: z.boolean().default(false),
94
- })
97
+ /**
98
+ * Deployment policy for the rollback.
99
+ *
100
+ * `undefined` when the schema package could not be resolved - DSH then hands
101
+ * `apply` no config at all, and every field below is read with the same default
102
+ * there, so the plugin behaves identically minus the settings form.
103
+ */
104
+ export const Config =
105
+ z === null
106
+ ? undefined
107
+ : z.object({
108
+ markerText: z.string().default(DEFAULT_MARKER),
109
+ /**
110
+ * Whether the client asks for a second confirmation before applying. Off
111
+ * by default: the editor is already an explicit action the user opened, and
112
+ * the panel states what saving does. Set it to `true` to require the extra
113
+ * step.
114
+ */
115
+ confirm: z.boolean().default(false),
116
+ /**
117
+ * Whether saving a revised prompt also runs the turn again. Off by default:
118
+ * the revision becomes the standing wording and waits for the next message,
119
+ * so saving costs no model call. Set it to `true` to re-run on save.
120
+ */
121
+ rerun: z.boolean().default(false),
122
+ })
95
123
 
96
124
  // ---------------------------------------------------------------------------
97
125
  // pure session-log logic (no DSH SDK imports; unit-testable under node --test)
@@ -233,7 +261,13 @@ export function messageIdOf(event) {
233
261
  export function isHumanPrompt(event) {
234
262
  if (!event || event.type !== 'user/message') return false
235
263
  const data = event.data
236
- return Boolean(data && data.source && data.source.kind === 'user')
264
+ if (!data || !data.source) return false
265
+ if (data.source.kind === 'user') return true
266
+ // A message this plugin rewrote stands in a human's place: it is the wording
267
+ // the user is asking with now, so it stays editable (edit it again) even though
268
+ // its source records that the plugin wrote it. The distinct kind also keeps the
269
+ // platform from treating it as fresh input to answer.
270
+ return data.source.kind === `plugin:${PLUGIN_ID}` && data.source.editedBy === PLUGIN_ID
237
271
  }
238
272
 
239
273
  /**
@@ -313,6 +347,16 @@ export function rollbackLedger(events) {
313
347
  return { hidden, edits }
314
348
  }
315
349
 
350
+ /** The turn number the format expects next: the last one opened, plus one. */
351
+ export function nextTurnOf(events) {
352
+ let last = 0
353
+ for (const event of events) {
354
+ const turn = event.data && event.data.turn
355
+ if (typeof turn === 'number' && turn > last) last = turn
356
+ }
357
+ return last + 1
358
+ }
359
+
316
360
  /** Highest turn number recorded in the log, or undefined for an empty log. */
317
361
  export function lastTurnOf(events) {
318
362
  let last
@@ -390,14 +434,21 @@ export function planRollback(events, surfaceNodes, request) {
390
434
  if (target.seq === surfaceNodes[0]) {
391
435
  throw new EditPlanError('not-editable', 'the system prompt head cannot be edited')
392
436
  }
393
- // Two editable kinds, two outcomes: rewriting a prompt re-runs the turn;
394
- // rewriting a reply replaces the answer and lets the conversation continue.
437
+ // Two editable kinds. A prompt is rewritten in place and the conversation
438
+ // continues; a reply is replaced and everything built on it goes with it.
395
439
  const mode = isHumanPrompt(target) ? 'prompt' : isAssistantReply(target) ? 'reply' : null
396
440
  if (mode === null) {
397
441
  throw new EditPlanError('not-editable', 'only your own messages and the model replies can be edited')
398
442
  }
399
443
  const startIdx = nodeIndex.get(target.seq)
400
- const shadowed = surfaceNodes.slice(startIdx)
444
+ // Two different windows, because the two edits mean different things.
445
+ //
446
+ // A revised PROMPT is replaced in place: only the message itself leaves the
447
+ // surface, so the reply under it and everything after stay exactly as they
448
+ // were - edit the wording, keep the conversation. A revised REPLY cannot do
449
+ // that (an answer invalidates whatever was built on it), so its window runs to
450
+ // the tail and the turn is closed again behind the correction.
451
+ const shadowed = mode === 'prompt' ? [target.seq] : surfaceNodes.slice(startIdx)
401
452
  if (shadowed.length === 0 || shadowed[0] !== target.seq) {
402
453
  throw new EditPlanError('not-editable', 'the target does not open a rollback window')
403
454
  }
@@ -436,18 +487,22 @@ export function planRollback(events, surfaceNodes, request) {
436
487
  * @returns one entry per editable prompt, in conversation order.
437
488
  */
438
489
  export function editableTurns(events, surfaceNodes) {
439
- const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
490
+ const eventBySeq = new Map(events.map((event) => [event.seq, event]))
440
491
  const turnOf = turnIndex(events)
441
492
  const head = surfaceNodes[0]
442
493
  const out = []
443
- for (const event of events) {
444
- if (!isHumanPrompt(event)) continue
445
- if (!nodeIndex.has(event.seq)) continue
446
- if (event.seq === head) continue
494
+ // Surface order, not log order: a prompt rewritten in place travels as a
495
+ // replace-carrier, which is APPENDED at the end of the log while standing at
496
+ // the surface position of the wording it replaced. Walking `events` would
497
+ // report the revision as the newest message in the conversation.
498
+ for (const seq of surfaceNodes) {
499
+ const event = eventBySeq.get(seq)
500
+ if (!event || !isHumanPrompt(event)) continue
501
+ if (seq === head) continue
447
502
  const { text, attachments } = readMessageText(event)
448
- const turn = turnOf.get(event.seq)
503
+ const turn = turnOf.get(seq)
449
504
  out.push({
450
- seq: event.seq,
505
+ seq,
451
506
  turn: typeof turn === 'number' ? turn : null,
452
507
  messageId: messageIdOf(event) ?? null,
453
508
  text,
@@ -634,13 +689,18 @@ async function stateOf(ctx, sessionId, config) {
634
689
  return {
635
690
  hidden: ledger.hidden,
636
691
  edits: ledger.edits.length,
692
+ // The window record behind every hidden entry, with the same field names the
693
+ // apply response uses (`replacementSeq`, `shadowed`, plus the window bounds).
694
+ // Published so a sibling plugin can follow a rewritten message to its live
695
+ // node without re-deriving the ledger from the log itself.
696
+ revisions: ledger.edits,
637
697
  surface: folded.nodes,
638
698
  turns,
639
699
  replies,
640
700
  live: Boolean(live),
641
701
  busy: isBusy(events),
642
702
  lastSeq: events.length > 0 ? events[events.length - 1].seq : -1,
643
- config: { confirm: config.confirm },
703
+ config: { confirm: config.confirm, rerun: config.rerun === true },
644
704
  version: PLUGIN_VERSION,
645
705
  }
646
706
  }
@@ -656,17 +716,24 @@ async function stateOf(ctx, sessionId, config) {
656
716
  export function buildCarrier(plan, lastTurn, config) {
657
717
  const silent = config.carrier !== 'user/message'
658
718
  if (silent) {
659
- // An empty later system node is dormant and projects to no model message.
719
+ // The silent carrier is an empty message that projects to no model message.
720
+ // It has to be a `developer/message`: the format admits only system-prompt
721
+ // sources on `system/message`, so an empty plugin-owned system node (what
722
+ // this used to append) made every session that contained one unreadable -
723
+ // `SessionFormatError` on load, after a perfectly accepted append. An empty
724
+ // developer message is legal, dormant, and a replacement never gets a row
725
+ // of its own (rows are built for append events only), so it is invisible in
726
+ // the transcript too.
660
727
  return {
661
- type: 'system/message',
728
+ type: 'developer/message',
662
729
  data: {
663
730
  turn: typeof lastTurn === 'number' ? lastTurn : 0,
664
731
  step: 1,
665
732
  message: {
666
733
  id: randomUUID(),
667
- role: 'system',
734
+ role: 'developer',
668
735
  content: [],
669
- source: { kind: `plugin:${PLUGIN_ID}` },
736
+ source: { kind: `plugin:${PLUGIN_ID}`, editedBy: PLUGIN_ID },
670
737
  },
671
738
  },
672
739
  }
@@ -677,7 +744,7 @@ export function buildCarrier(plan, lastTurn, config) {
677
744
  id: randomUUID(),
678
745
  role: 'user',
679
746
  content: [{ type: 'text', text: config.markerText }],
680
- source: { kind: `plugin:${PLUGIN_ID}` },
747
+ source: { kind: `plugin:${PLUGIN_ID}`, editedBy: PLUGIN_ID },
681
748
  },
682
749
  }
683
750
  }
@@ -694,19 +761,21 @@ export function buildCarrier(plan, lastTurn, config) {
694
761
  * ordinary reply, while `editedBy` records honestly that the text was written by
695
762
  * the plugin and not produced by the model.
696
763
  *
697
- * @param plan - the planned window, for the original's turn/step/provider.
764
+ * @param plan - the planned window, for the original's provider/model.
698
765
  * @param text - the replacement text.
766
+ * @param turn - the turn the correction lands in (the freshly opened one).
767
+ * @param step - the step the correction lands in.
699
768
  * @returns `{ type, data }` for `session.append`, to be marked `surfaceOp: 'append'`.
700
769
  */
701
- export function buildCorrection(plan, text) {
770
+ export function buildCorrection(plan, text, turn, step) {
702
771
  const source = { kind: 'model', editedBy: PLUGIN_ID }
703
772
  if (typeof plan.provider === 'string' && plan.provider !== '') source.provider = plan.provider
704
773
  if (typeof plan.model === 'string' && plan.model !== '') source.model = plan.model
705
774
  return {
706
775
  type: 'assistant/message',
707
776
  data: {
708
- turn: typeof plan.turn === 'number' ? plan.turn : 0,
709
- step: typeof plan.step === 'number' ? plan.step : 1,
777
+ turn: typeof turn === 'number' ? turn : 0,
778
+ step: typeof step === 'number' ? step : 1,
710
779
  message: {
711
780
  id: randomUUID(),
712
781
  role: 'assistant',
@@ -753,14 +822,40 @@ async function applyEdit(ctx, sessionId, body, config) {
753
822
  if (!surfaceNodes.includes(seq)) throw new HttpError(409, 'stale', 'the session changed, retry')
754
823
  }
755
824
 
756
- const carrier = buildCarrier(plan, lastTurnOf(events), config)
825
+ // A revised prompt has to (a) replace the old wording in the model context and
826
+ // (b) NOT be answered. The platform answers any user message it finds on the
827
+ // surface, so the revision cannot be *appended* - appending one made the loop
828
+ // answer it, and the next save added another copy (three "你好" bubbles). It
829
+ // travels as the rollback's replacement instead: a replace-type carrier, which
830
+ // removes the old prompt and stands in its place, in one event.
831
+ // A reply edit needs somewhere to land: `assistant/message` (and any message
832
+ // with a step coordinate) is only readable inside an OPEN turn and step, and
833
+ // opening one in the log is what advances the runtime's own turn counter too
834
+ // (it projects `lastTurn` from every turn/start it observes). A prompt edit
835
+ // does not: `user/message` carries the revision and needs no turn.
836
+ const editTurn = plan.mode === 'reply' ? nextTurnOf(events) : lastTurnOf(events)
837
+ const carrier = buildCarrier(plan, editTurn, plan.mode === 'prompt' ? { ...config, carrier: 'user/message', markerText: text } : config)
838
+ if (plan.mode === 'reply') {
839
+ try {
840
+ session.append('turn/start', { turn: editTurn })
841
+ session.append('step/start', { turn: editTurn, step: 1 })
842
+ } catch (error) {
843
+ return {
844
+ kind: 'reply',
845
+ applied: false,
846
+ applyError: `could not open a turn for the correction: ${String((error && error.message) || error)}`,
847
+ shadowed: plan.shadowed,
848
+ }
849
+ }
850
+ }
757
851
  let replacement
758
852
  try {
759
853
  // A replacement carrier has to be a surface event with non-empty
760
854
  // `sourceEventSeqs` coverage of the whole shadowed window. An assistant
761
855
  // message forbids `sourceEventSeqs` outright, so the carrier is either an
762
- // empty `system/message` (default, dormant, invisible) or a short
763
- // `user/message` marker.
856
+ // empty `developer/message` (default: dormant, invisible to the model, and
857
+ // never given a row of its own because rows are built for appends) or a
858
+ // short `user/message` marker.
764
859
  replacement = session.append(carrier.type, carrier.data, {
765
860
  surfaceOp: { op: 'replace', startSeq: plan.startSeq, endSeq: plan.endSeq },
766
861
  sourceEventSeqs: plan.shadowed,
@@ -780,17 +875,15 @@ async function applyEdit(ctx, sessionId, body, config) {
780
875
  // continues from there. Nothing is re-run: the answer was fixed, the model was
781
876
  // not asked again.
782
877
  if (plan.mode === 'reply') {
783
- const correction = buildCorrection(plan, text)
878
+ const correction = buildCorrection(plan, text, editTurn, 1)
784
879
  let appended
785
880
  try {
786
881
  appended = session.append(correction.type, correction.data, { surfaceOp: 'append' })
787
- // Close the turn again, now that the corrected reply is the last thing in
788
- // it. The host anchors a turn's tail (its duration and action strip) at the
789
- // turn's LAST `turn/end`, so a correction appended after the old one lands
790
- // OUTSIDE the turn and the tail renders above the corrected text. These two
791
- // events put the tail back behind it, exactly as a normal turn ends.
792
- session.append('step/end', { turn: appended.data.turn, step: appended.data.step })
793
- session.append('turn/end', { turn: appended.data.turn, reason: { kind: 'completed' } })
882
+ // Close the step and the turn behind the corrected reply: the host anchors
883
+ // a turn's tail (duration, action strip) at its last `turn/end`, and the
884
+ // format only reads a step message inside an open turn and step.
885
+ session.append('step/end', { turn: editTurn, step: 1 })
886
+ session.append('turn/end', { turn: editTurn, reason: { kind: 'completed' } })
794
887
  } catch (error) {
795
888
  return {
796
889
  kind: 'reply',
@@ -812,15 +905,31 @@ async function applyEdit(ctx, sessionId, body, config) {
812
905
  }
813
906
  }
814
907
 
815
- // A rewritten prompt is re-run through the only official prompt path, which
816
- // also resumes a cold Session and starts exactly one new turn.
908
+ // The rollback above already did the whole job: the carrier that replaced the
909
+ // window *is* the revised prompt, so the model reads it as the user's wording
910
+ // from here on and nothing has to be prompted. Saving therefore costs no model
911
+ // call - which is the point. `rerun: true` restores the older behaviour of
912
+ // asking the model right away (through the one official prompt path, which also
913
+ // resumes a cold Session and starts exactly one new turn).
914
+ if (config.rerun !== true) {
915
+ return {
916
+ kind: 'prompt',
917
+ replacementSeq: replacement.seq,
918
+ applied: true,
919
+ reran: false,
920
+ shadowed: plan.shadowed,
921
+ ...flush,
922
+ }
923
+ }
924
+
817
925
  const controller = ctx.get('sessionController')
818
926
  if (!controller || typeof controller.prompt !== 'function') {
819
927
  return {
820
928
  kind: 'prompt',
821
929
  replacementSeq: replacement.seq,
822
- promptAccepted: false,
823
- promptError: 'sessionController.prompt unavailable',
930
+ applied: true,
931
+ reran: false,
932
+ rerunError: 'sessionController.prompt unavailable',
824
933
  shadowed: plan.shadowed,
825
934
  ...flush,
826
935
  }
@@ -839,8 +948,9 @@ async function applyEdit(ctx, sessionId, body, config) {
839
948
  return {
840
949
  kind: 'prompt',
841
950
  replacementSeq: replacement.seq,
842
- promptAccepted: false,
843
- promptError: String((error && error.message) || error),
951
+ applied: true,
952
+ reran: false,
953
+ rerunError: String((error && error.message) || error),
844
954
  shadowed: plan.shadowed,
845
955
  ...flush,
846
956
  }
@@ -849,7 +959,8 @@ async function applyEdit(ctx, sessionId, body, config) {
849
959
  return {
850
960
  kind: 'prompt',
851
961
  replacementSeq: replacement.seq,
852
- promptAccepted: true,
962
+ applied: true,
963
+ reran: true,
853
964
  shadowed: plan.shadowed,
854
965
  ...flush,
855
966
  }
@@ -1029,6 +1140,7 @@ export function apply(ctx, config) {
1029
1140
  : DEFAULT_MARKER,
1030
1141
  carrier: config && config.carrier === 'user/message' ? 'user/message' : 'system/message',
1031
1142
  confirm: config && typeof config.confirm === 'boolean' ? config.confirm : false,
1143
+ rerun: config && config.rerun === true,
1032
1144
  }
1033
1145
 
1034
1146
  const registerRoutes = (webServer, fiber) => {
@@ -1090,8 +1202,26 @@ export function apply(ctx, config) {
1090
1202
  }
1091
1203
  try {
1092
1204
  const sessionId = requireSessionId(typeof body.sessionId === 'string' ? body.sessionId.trim() : '')
1093
- sendJson(res, 200, { ok: true, ...(await applyEdit(ctx, sessionId, body, resolved)) })
1205
+ const result = await applyEdit(ctx, sessionId, body, resolved)
1206
+ // Recorded so the next "the button does nothing" can be answered
1207
+ // from here: an apply that never appears was never sent - the click
1208
+ // was lost in the browser half, not refused by this one.
1209
+ noteRequest({
1210
+ kind: 'apply',
1211
+ sessionId,
1212
+ ok: true,
1213
+ edit: result.kind,
1214
+ seq: typeof body.seq === 'number' ? body.seq : null,
1215
+ replacement: typeof result.replacementSeq === 'number' ? result.replacementSeq : null,
1216
+ })
1217
+ sendJson(res, 200, { ok: true, ...result })
1094
1218
  } catch (error) {
1219
+ noteRequest({
1220
+ kind: 'apply',
1221
+ sessionId: typeof body.sessionId === 'string' ? body.sessionId.trim() : null,
1222
+ ok: false,
1223
+ code: error && error.code ? String(error.code) : 'internal',
1224
+ })
1095
1225
  failure(res, error)
1096
1226
  }
1097
1227
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.2.1",
3
+ "version": "0.2.5",
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",
@@ -40,11 +40,15 @@
40
40
  "verify:live": "node tools/verify-live-client.mjs",
41
41
  "verify:profile": "bash tools/verify-dsh-plugin.sh",
42
42
  "probe:loaded": "node tools/probe-loaded.mjs",
43
- "check": "npm run test && npm run verify:contract && npm run verify:client"
43
+ "check": "npm run test && npm run verify:contract && npm run verify:client",
44
+ "verify:ui": "node tools/verify-live-ui.mjs"
44
45
  },
45
46
  "dependencies": {
46
47
  "@deepseek-ai/schemastery": "^3.18.1"
47
48
  },
49
+ "devDependencies": {
50
+ "@deepseek-ai/dsh-session": "^0.1.7-rc.2"
51
+ },
48
52
  "peerDependencies": {
49
53
  "@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",
50
54
  "@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"