dsh-edit-turn 0.2.0 → 0.2.4

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 +432 -64
  2. package/lib/client.js +709 -98
  3. package/lib/index.js +247 -60
  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.4;
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,24 @@ 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.0'
63
+ export const PLUGIN_VERSION = '0.2.4'
64
+
65
+ // A ring of the last few requests, answering "did the browser half even ask, and
66
+ // what did it get?" without needing its console. Read-only diagnostics: the
67
+ // browser half's own state is otherwise invisible from the host side.
68
+ const REQUEST_LOG_LIMIT = 40
69
+ const requestLog = []
70
+
71
+ /** Record one request for the diagnostic route. */
72
+ export function noteRequest(entry) {
73
+ requestLog.push({ at: Date.now(), ...entry })
74
+ if (requestLog.length > REQUEST_LOG_LIMIT) requestLog.shift()
75
+ }
76
+
77
+ /** The recorded requests, oldest first. */
78
+ export function recentRequests() {
79
+ return requestLog.slice()
80
+ }
52
81
 
53
82
  const ROUTE_PREFIX = '/dsh-edit-turn'
54
83
  const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -65,16 +94,32 @@ const FLUSH_DEADLINE_MS = 5_000
65
94
  */
66
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.'
67
96
 
68
- /** Deployment policy for the rollback. */
69
- export const Config = z.object({
70
- markerText: z.string().default(DEFAULT_MARKER),
71
- /**
72
- * Whether the client asks for a second confirmation before applying. Off by
73
- * default: the editor is already an explicit action the user opened, and the
74
- * panel states what saving does. Set it to `true` to require the extra step.
75
- */
76
- confirm: z.boolean().default(false),
77
- })
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
+ })
78
123
 
79
124
  // ---------------------------------------------------------------------------
80
125
  // pure session-log logic (no DSH SDK imports; unit-testable under node --test)
@@ -216,7 +261,13 @@ export function messageIdOf(event) {
216
261
  export function isHumanPrompt(event) {
217
262
  if (!event || event.type !== 'user/message') return false
218
263
  const data = event.data
219
- 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
220
271
  }
221
272
 
222
273
  /**
@@ -296,6 +347,16 @@ export function rollbackLedger(events) {
296
347
  return { hidden, edits }
297
348
  }
298
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
+
299
360
  /** Highest turn number recorded in the log, or undefined for an empty log. */
300
361
  export function lastTurnOf(events) {
301
362
  let last
@@ -373,14 +434,21 @@ export function planRollback(events, surfaceNodes, request) {
373
434
  if (target.seq === surfaceNodes[0]) {
374
435
  throw new EditPlanError('not-editable', 'the system prompt head cannot be edited')
375
436
  }
376
- // Two editable kinds, two outcomes: rewriting a prompt re-runs the turn;
377
- // 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.
378
439
  const mode = isHumanPrompt(target) ? 'prompt' : isAssistantReply(target) ? 'reply' : null
379
440
  if (mode === null) {
380
441
  throw new EditPlanError('not-editable', 'only your own messages and the model replies can be edited')
381
442
  }
382
443
  const startIdx = nodeIndex.get(target.seq)
383
- 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)
384
452
  if (shadowed.length === 0 || shadowed[0] !== target.seq) {
385
453
  throw new EditPlanError('not-editable', 'the target does not open a rollback window')
386
454
  }
@@ -419,18 +487,22 @@ export function planRollback(events, surfaceNodes, request) {
419
487
  * @returns one entry per editable prompt, in conversation order.
420
488
  */
421
489
  export function editableTurns(events, surfaceNodes) {
422
- const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
490
+ const eventBySeq = new Map(events.map((event) => [event.seq, event]))
423
491
  const turnOf = turnIndex(events)
424
492
  const head = surfaceNodes[0]
425
493
  const out = []
426
- for (const event of events) {
427
- if (!isHumanPrompt(event)) continue
428
- if (!nodeIndex.has(event.seq)) continue
429
- 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
430
502
  const { text, attachments } = readMessageText(event)
431
- const turn = turnOf.get(event.seq)
503
+ const turn = turnOf.get(seq)
432
504
  out.push({
433
- seq: event.seq,
505
+ seq,
434
506
  turn: typeof turn === 'number' ? turn : null,
435
507
  messageId: messageIdOf(event) ?? null,
436
508
  text,
@@ -595,20 +667,40 @@ async function flushSession(ctx, session) {
595
667
 
596
668
  async function stateOf(ctx, sessionId, config) {
597
669
  const events = await readEvents(ctx, sessionId)
598
- if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
670
+ if (!events) {
671
+ noteRequest({ kind: 'state', sessionId, ok: false, code: 'session-not-found' })
672
+ throw new HttpError(404, 'session-not-found', 'no session log for this id')
673
+ }
599
674
  const folded = foldSurface(events)
600
675
  const ledger = rollbackLedger(events)
601
676
  const live = findLiveSession(ctx, sessionId)
677
+ const turns = editableTurns(events, folded.nodes)
678
+ const replies = editableReplies(events, folded.nodes)
679
+ noteRequest({
680
+ kind: 'state',
681
+ sessionId,
682
+ ok: true,
683
+ events: events.length,
684
+ turns: turns.length,
685
+ replies: replies.length,
686
+ hidden: ledger.hidden.length,
687
+ live: Boolean(live),
688
+ })
602
689
  return {
603
690
  hidden: ledger.hidden,
604
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,
605
697
  surface: folded.nodes,
606
- turns: editableTurns(events, folded.nodes),
607
- replies: editableReplies(events, folded.nodes),
698
+ turns,
699
+ replies,
608
700
  live: Boolean(live),
609
701
  busy: isBusy(events),
610
702
  lastSeq: events.length > 0 ? events[events.length - 1].seq : -1,
611
- config: { confirm: config.confirm },
703
+ config: { confirm: config.confirm, rerun: config.rerun === true },
612
704
  version: PLUGIN_VERSION,
613
705
  }
614
706
  }
@@ -624,17 +716,24 @@ async function stateOf(ctx, sessionId, config) {
624
716
  export function buildCarrier(plan, lastTurn, config) {
625
717
  const silent = config.carrier !== 'user/message'
626
718
  if (silent) {
627
- // 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.
628
727
  return {
629
- type: 'system/message',
728
+ type: 'developer/message',
630
729
  data: {
631
730
  turn: typeof lastTurn === 'number' ? lastTurn : 0,
632
731
  step: 1,
633
732
  message: {
634
733
  id: randomUUID(),
635
- role: 'system',
734
+ role: 'developer',
636
735
  content: [],
637
- source: { kind: `plugin:${PLUGIN_ID}` },
736
+ source: { kind: `plugin:${PLUGIN_ID}`, editedBy: PLUGIN_ID },
638
737
  },
639
738
  },
640
739
  }
@@ -645,7 +744,7 @@ export function buildCarrier(plan, lastTurn, config) {
645
744
  id: randomUUID(),
646
745
  role: 'user',
647
746
  content: [{ type: 'text', text: config.markerText }],
648
- source: { kind: `plugin:${PLUGIN_ID}` },
747
+ source: { kind: `plugin:${PLUGIN_ID}`, editedBy: PLUGIN_ID },
649
748
  },
650
749
  }
651
750
  }
@@ -662,19 +761,21 @@ export function buildCarrier(plan, lastTurn, config) {
662
761
  * ordinary reply, while `editedBy` records honestly that the text was written by
663
762
  * the plugin and not produced by the model.
664
763
  *
665
- * @param plan - the planned window, for the original's turn/step/provider.
764
+ * @param plan - the planned window, for the original's provider/model.
666
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.
667
768
  * @returns `{ type, data }` for `session.append`, to be marked `surfaceOp: 'append'`.
668
769
  */
669
- export function buildCorrection(plan, text) {
770
+ export function buildCorrection(plan, text, turn, step) {
670
771
  const source = { kind: 'model', editedBy: PLUGIN_ID }
671
772
  if (typeof plan.provider === 'string' && plan.provider !== '') source.provider = plan.provider
672
773
  if (typeof plan.model === 'string' && plan.model !== '') source.model = plan.model
673
774
  return {
674
775
  type: 'assistant/message',
675
776
  data: {
676
- turn: typeof plan.turn === 'number' ? plan.turn : 0,
677
- step: typeof plan.step === 'number' ? plan.step : 1,
777
+ turn: typeof turn === 'number' ? turn : 0,
778
+ step: typeof step === 'number' ? step : 1,
678
779
  message: {
679
780
  id: randomUUID(),
680
781
  role: 'assistant',
@@ -721,14 +822,40 @@ async function applyEdit(ctx, sessionId, body, config) {
721
822
  if (!surfaceNodes.includes(seq)) throw new HttpError(409, 'stale', 'the session changed, retry')
722
823
  }
723
824
 
724
- 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
+ }
725
851
  let replacement
726
852
  try {
727
853
  // A replacement carrier has to be a surface event with non-empty
728
854
  // `sourceEventSeqs` coverage of the whole shadowed window. An assistant
729
855
  // message forbids `sourceEventSeqs` outright, so the carrier is either an
730
- // empty `system/message` (default, dormant, invisible) or a short
731
- // `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.
732
859
  replacement = session.append(carrier.type, carrier.data, {
733
860
  surfaceOp: { op: 'replace', startSeq: plan.startSeq, endSeq: plan.endSeq },
734
861
  sourceEventSeqs: plan.shadowed,
@@ -748,10 +875,15 @@ async function applyEdit(ctx, sessionId, body, config) {
748
875
  // continues from there. Nothing is re-run: the answer was fixed, the model was
749
876
  // not asked again.
750
877
  if (plan.mode === 'reply') {
751
- const correction = buildCorrection(plan, text)
878
+ const correction = buildCorrection(plan, text, editTurn, 1)
752
879
  let appended
753
880
  try {
754
881
  appended = session.append(correction.type, correction.data, { surfaceOp: 'append' })
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' } })
755
887
  } catch (error) {
756
888
  return {
757
889
  kind: 'reply',
@@ -773,15 +905,31 @@ async function applyEdit(ctx, sessionId, body, config) {
773
905
  }
774
906
  }
775
907
 
776
- // A rewritten prompt is re-run through the only official prompt path, which
777
- // 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
+
778
925
  const controller = ctx.get('sessionController')
779
926
  if (!controller || typeof controller.prompt !== 'function') {
780
927
  return {
781
928
  kind: 'prompt',
782
929
  replacementSeq: replacement.seq,
783
- promptAccepted: false,
784
- promptError: 'sessionController.prompt unavailable',
930
+ applied: true,
931
+ reran: false,
932
+ rerunError: 'sessionController.prompt unavailable',
785
933
  shadowed: plan.shadowed,
786
934
  ...flush,
787
935
  }
@@ -800,8 +948,9 @@ async function applyEdit(ctx, sessionId, body, config) {
800
948
  return {
801
949
  kind: 'prompt',
802
950
  replacementSeq: replacement.seq,
803
- promptAccepted: false,
804
- promptError: String((error && error.message) || error),
951
+ applied: true,
952
+ reran: false,
953
+ rerunError: String((error && error.message) || error),
805
954
  shadowed: plan.shadowed,
806
955
  ...flush,
807
956
  }
@@ -810,7 +959,8 @@ async function applyEdit(ctx, sessionId, body, config) {
810
959
  return {
811
960
  kind: 'prompt',
812
961
  replacementSeq: replacement.seq,
813
- promptAccepted: true,
962
+ applied: true,
963
+ reran: true,
814
964
  shadowed: plan.shadowed,
815
965
  ...flush,
816
966
  }
@@ -990,6 +1140,7 @@ export function apply(ctx, config) {
990
1140
  : DEFAULT_MARKER,
991
1141
  carrier: config && config.carrier === 'user/message' ? 'user/message' : 'system/message',
992
1142
  confirm: config && typeof config.confirm === 'boolean' ? config.confirm : false,
1143
+ rerun: config && config.rerun === true,
993
1144
  }
994
1145
 
995
1146
  const registerRoutes = (webServer, fiber) => {
@@ -1013,6 +1164,24 @@ export function apply(ctx, config) {
1013
1164
  }),
1014
1165
  )
1015
1166
 
1167
+ // Read-only diagnostics. The browser half's own state cannot be observed
1168
+ // from here, so the next best thing is an honest record of what its requests
1169
+ // asked for and what they got back.
1170
+ fiber.effect(() =>
1171
+ webServer.register({
1172
+ kind: 'exact',
1173
+ path: `${ROUTE_PREFIX}/debug`,
1174
+ handler: (req, res) => {
1175
+ if (!guard(req, res)) return
1176
+ if (req.method !== 'GET') {
1177
+ sendJson(res, 405, { ok: false, code: 'method', error: 'GET only' })
1178
+ return
1179
+ }
1180
+ sendJson(res, 200, { ok: true, version: PLUGIN_VERSION, requests: recentRequests() })
1181
+ },
1182
+ }),
1183
+ )
1184
+
1016
1185
  fiber.effect(() =>
1017
1186
  webServer.register({
1018
1187
  kind: 'exact',
@@ -1033,8 +1202,26 @@ export function apply(ctx, config) {
1033
1202
  }
1034
1203
  try {
1035
1204
  const sessionId = requireSessionId(typeof body.sessionId === 'string' ? body.sessionId.trim() : '')
1036
- 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 })
1037
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
+ })
1038
1225
  failure(res, error)
1039
1226
  }
1040
1227
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.2.0",
3
+ "version": "0.2.4",
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"