dsh-edit-turn 0.3.3 → 0.5.2

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/lib/index.js CHANGED
@@ -80,7 +80,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
80
80
  export const name = PLUGIN_ID
81
81
 
82
82
  /** Keep in sync with package.json and lib/client.js. */
83
- export const PLUGIN_VERSION = '0.3.3'
83
+ export const PLUGIN_VERSION = '0.5.2'
84
84
 
85
85
  // A ring of the last few requests, answering "did the browser half even ask, and
86
86
  // what did it get?" without needing its console. Read-only diagnostics: the
@@ -162,8 +162,20 @@ export const Config =
162
162
  * replay splice otherwise; `patch` refuses to run without the
163
163
  * projection; `replay` always uses the old path. Prompt edits always use
164
164
  * the official surface replacement.
165
+ *
166
+ * There is deliberately no `replace` value: appending a fresh
167
+ * `assistant/message` with a replace op is refused by the session's own
168
+ * surface validation (see the note in the README).
165
169
  */
166
170
  replyMode: z.string().default('auto'),
171
+ /**
172
+ * Which editor presentation the browser half draws. `frame` outlines the
173
+ * whole message row (reference-UI style); `bubble` is exactly the
174
+ * message bubble; `center` is a large centred panel. The setting exists
175
+ * because the best shape is a matter of taste and was hard to pick
176
+ * without trying them side by side.
177
+ */
178
+ editorStyle: z.union([z.const('frame'), z.const('bubble'), z.const('center')]).default('frame'),
167
179
  })
168
180
 
169
181
  // ---------------------------------------------------------------------------
@@ -1300,6 +1312,25 @@ export function editableReplies(events, surfaceNodes, options = {}) {
1300
1312
  if (!patch && text.trim() === '') continue
1301
1313
  if (patch && blocks.every((block) => block.text.trim() === '')) continue
1302
1314
  const turn = turnOf.get(event.seq)
1315
+ // One turn runs many steps, and every step's assistant message carries its
1316
+ // own reasoning. The answer's row is the one with the action strip, so its
1317
+ // block list is widened to the whole turn: every reasoning and every answer
1318
+ // text of that turn is offered, each annotated with the seq it lives in.
1319
+ let listed = blocks.map((block) => ({ ...block, seq: event.seq }))
1320
+ const carriesAnswer = blocks.some((block) => block.kind === 'text' && block.text.trim() !== '')
1321
+ if (patch && carriesAnswer && typeof turn === 'number') {
1322
+ const acrossTurn = []
1323
+ for (const other of events) {
1324
+ if (!isAssistantReply(other) || turnOf.get(other.seq) !== turn) continue
1325
+ if (!nodeIndex.has(other.seq)) continue
1326
+ const otherData = other.data && typeof other.data === 'object' ? other.data : {}
1327
+ const otherMessage = otherData.message && typeof otherData.message === 'object' ? otherData.message : otherData
1328
+ for (const block of projectedBlocks(otherMessage, other.seq, patches)) {
1329
+ acrossTurn.push({ ...block, seq: other.seq })
1330
+ }
1331
+ }
1332
+ if (acrossTurn.length > listed.length) listed = acrossTurn
1333
+ }
1303
1334
  out.push({
1304
1335
  seq: event.seq,
1305
1336
  turn: typeof turn === 'number' ? turn : null,
@@ -1311,7 +1342,7 @@ export function editableReplies(events, surfaceNodes, options = {}) {
1311
1342
  patched: blocks.some((block) => block.patched),
1312
1343
  // Present only where the editor can target one: the replay path can only
1313
1344
  // replace the whole reply, so it has no block picker.
1314
- ...(patch ? { blocks } : {}),
1345
+ ...(patch ? { blocks: listed } : {}),
1315
1346
  })
1316
1347
  }
1317
1348
  return out
@@ -1706,7 +1737,12 @@ async function stateOf(ctx, sessionId, config) {
1706
1737
  // path (a chip that may draw its own thumbnail); both are answered from a
1707
1738
  // live service probe, so the editor never promises what the host cannot do.
1708
1739
  capabilities: { attachments: store !== null, preview: canPreviewAttachments(store) },
1709
- config: { confirm: config.confirm, replyMode: config.replyMode, patch: config.patch === true },
1740
+ config: {
1741
+ confirm: config.confirm,
1742
+ replyMode: config.replyMode,
1743
+ patch: config.patch === true,
1744
+ editorStyle: config.editorStyle,
1745
+ },
1710
1746
  version: PLUGIN_VERSION,
1711
1747
  }
1712
1748
  }
@@ -1809,7 +1845,88 @@ export function buildCorrection(plan, text, turn, step) {
1809
1845
  }
1810
1846
  }
1811
1847
 
1848
+ /**
1849
+ * The all-blocks save: one plugin record carrying a target per edited block,
1850
+ * possibly across several messages of the same turn. Each target is planned
1851
+ * exactly like a single-block save, and unchanged blocks are simply left out.
1852
+ *
1853
+ * @param ctx - cordis context.
1854
+ * @param sessionId - the addressed session.
1855
+ * @param body - `{ targets: [{ seq, blockIndex, kind, text }] }`.
1856
+ * @param config - plugin config.
1857
+ * @returns the same shape `/apply` returns for a reply patch.
1858
+ */
1859
+ async function applyTargetsEdit(ctx, sessionId, body, config) {
1860
+ const targets = Array.isArray(body.targets) ? body.targets : []
1861
+ if (targets.length === 0 || targets.length > MAX_PARTS) {
1862
+ throw new HttpError(400, 'invalid', 'targets must be a nonempty, bounded array')
1863
+ }
1864
+ for (const target of targets) {
1865
+ if (target === null || typeof target !== 'object') throw new HttpError(400, 'invalid', 'each target must be an object')
1866
+ if (typeof target.text !== 'string' || target.text.trim() === '') throw new HttpError(400, 'invalid', 'an edited block cannot be empty')
1867
+ if (target.text.length > MAX_TEXT) throw new HttpError(400, 'invalid', 'the revised block is too long')
1868
+ }
1869
+ const session = await resolveSession(ctx, sessionId)
1870
+ if (!session || typeof session.append !== 'function') {
1871
+ throw new HttpError(409, 'session-not-active', 'the session is not open in DSH')
1872
+ }
1873
+ const events = await readEvents(ctx, sessionId)
1874
+ if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
1875
+ // No busy gate here: this path only appends the in-place patch record, which
1876
+ // leaves every surface node where it is. A running turn keeps writing its own
1877
+ // events; the record lands between them and the fold ignores it.
1878
+ const surfaceNodes = surfaceOf(ctx, sessionId, events)
1879
+ const patches = patchesOf(events)
1880
+ const plans = []
1881
+ for (const target of targets) {
1882
+ let plan
1883
+ try {
1884
+ plan = planRollback(events, surfaceNodes, { seq: target.seq, blockIndex: target.blockIndex, kind: target.kind }, { patches, patch: config.patch === true })
1885
+ } catch (error) {
1886
+ if (error instanceof EditPlanError) {
1887
+ const status = error.code === 'not-editable' ? 400 : 409
1888
+ throw new HttpError(status, error.code, error.message)
1889
+ }
1890
+ throw error
1891
+ }
1892
+ if (plan.patch !== true) throw new HttpError(400, 'not-editable', 'all-blocks editing needs the in-place reply path')
1893
+ if (target.text !== plan.original) plans.push({ plan, text: target.text })
1894
+ }
1895
+ if (plans.length === 0) {
1896
+ return { kind: 'reply', applied: false, unchanged: true, patch: true, shadowed: [], targets: [] }
1897
+ }
1898
+ if (typeof sessionApi.appendPluginRecord !== 'function') {
1899
+ throw new HttpError(409, 'unsupported', 'this harness does not expose plugin records; reply edits need the replay path')
1900
+ }
1901
+ let recordSeq
1902
+ try {
1903
+ recordSeq = sessionApi.appendPluginRecord(
1904
+ session,
1905
+ PATCH_TYPE,
1906
+ buildPatchData(plans.map(({ plan, text }) => ({ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }))),
1907
+ )
1908
+ } catch (error) {
1909
+ const message = String((error && error.message) || error)
1910
+ if (isProjectionUnavailable(message)) {
1911
+ throw new HttpError(409, 'projection-missing', 'the edit-turn projection is not registered for this session; restart DSH so the owning plugin registers it, then retry')
1912
+ }
1913
+ throw new HttpError(409, 'stale', `the session refused the patch record: ${message}`)
1914
+ }
1915
+ const flush = await flushSession(ctx, session)
1916
+ return {
1917
+ kind: 'reply',
1918
+ applied: true,
1919
+ mode: 'patch',
1920
+ patch: true,
1921
+ recordSeq,
1922
+ targets: plans.map(({ plan, text }) => ({ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text, original: plan.original })),
1923
+ shadowed: [],
1924
+ ...flush,
1925
+ }
1926
+ }
1927
+
1812
1928
  async function applyEdit(ctx, sessionId, body, config) {
1929
+ if (Array.isArray(body.targets) && body.targets.length > 0) return applyTargetsEdit(ctx, sessionId, body, config)
1813
1930
  const text = typeof body.text === 'string' ? body.text : ''
1814
1931
  if (text.trim() === '') throw new HttpError(400, 'invalid', 'the revised message cannot be empty')
1815
1932
  if (text.length > MAX_TEXT) throw new HttpError(400, 'invalid', 'the revised message is too long')
@@ -1820,20 +1937,23 @@ async function applyEdit(ctx, sessionId, body, config) {
1820
1937
  }
1821
1938
  const events = await readEvents(ctx, sessionId)
1822
1939
  if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
1823
- if (isBusy(events)) throw new HttpError(409, 'busy', 'the session is still working')
1940
+ const busy = isBusy(events)
1824
1941
 
1825
1942
  const surfaceNodes = surfaceOf(ctx, sessionId, events)
1826
1943
  const patches = patchesOf(events)
1827
1944
  const patchMode = config.patch === true
1945
+ const request = {
1946
+ // A block from another step of the same turn names its own seq; that is
1947
+ // the message the projection has to patch.
1948
+ seq: typeof body.targetSeq === 'number' ? body.targetSeq : typeof body.seq === 'number' ? body.seq : undefined,
1949
+ messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
1950
+ turn: typeof body.turn === 'number' ? body.turn : undefined,
1951
+ blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
1952
+ kind: typeof body.kind === 'string' ? body.kind : undefined,
1953
+ }
1828
1954
  let plan
1829
1955
  try {
1830
- plan = planRollback(events, surfaceNodes, {
1831
- seq: typeof body.seq === 'number' ? body.seq : undefined,
1832
- messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
1833
- turn: typeof body.turn === 'number' ? body.turn : undefined,
1834
- blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
1835
- kind: typeof body.kind === 'string' ? body.kind : undefined,
1836
- }, { patches, patch: patchMode })
1956
+ plan = planRollback(events, surfaceNodes, request, { patches, patch: patchMode })
1837
1957
  } catch (error) {
1838
1958
  if (error instanceof EditPlanError) {
1839
1959
  const status = error.code === 'not-editable' ? 400 : 409
@@ -1842,6 +1962,13 @@ async function applyEdit(ctx, sessionId, body, config) {
1842
1962
  throw error
1843
1963
  }
1844
1964
 
1965
+ // A running turn is a hard stop for every path that moves the surface: the
1966
+ // prompt rewrite shadows a message the turn may still be reading, and the
1967
+ // replay splice shadows a window and regenerates the tail. The in-place record
1968
+ // below moves nothing, so it is allowed to land while the agent is still
1969
+ // writing - the model reads it on its next fold.
1970
+ if (busy && plan.patch !== true) throw new HttpError(409, 'busy', 'the session is still working')
1971
+
1845
1972
  // The in-place reply path: one small plugin record, interpreted by this
1846
1973
  // plugin's projection. Nothing leaves the surface, so there is no window to
1847
1974
  // check, no turn to open and no tail to replay. The record is append-only and
@@ -1871,8 +1998,19 @@ async function applyEdit(ctx, sessionId, body, config) {
1871
1998
  buildPatchData([{ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }]),
1872
1999
  )
1873
2000
  } catch (error) {
1874
- throw new HttpError(409, 'stale', `the session refused the patch record: ${String((error && error.message) || error)}`)
2001
+ const message = String((error && error.message) || error)
2002
+ // The projection is not registered for this session (a live reload that
2003
+ // dropped it). The same edit can land through the replay path, which
2004
+ // needs no projection - so fall through instead of failing the save.
2005
+ if (isProjectionUnavailable(message)) {
2006
+ // The fallback replays the tail, which a running turn forbids.
2007
+ if (busy) throw new HttpError(409, 'busy', 'the session is still working')
2008
+ plan = planRollback(events, surfaceNodes, request, { patches, patch: false })
2009
+ } else {
2010
+ throw new HttpError(409, 'stale', `the session refused the patch record: ${message}`)
2011
+ }
1875
2012
  }
2013
+ if (plan.patch === true) {
1876
2014
  const flush = await flushSession(ctx, session)
1877
2015
  return {
1878
2016
  kind: 'reply',
@@ -1890,6 +2028,7 @@ async function applyEdit(ctx, sessionId, body, config) {
1890
2028
  shadowed: [],
1891
2029
  ...flush,
1892
2030
  }
2031
+ }
1893
2032
  }
1894
2033
 
1895
2034
  // The live surface is the append authority; a node that vanished between the
@@ -2324,9 +2463,25 @@ function requireSessionId(value) {
2324
2463
  return value
2325
2464
  }
2326
2465
 
2466
+ /**
2467
+ * Whether the host refused a plugin record because its projection is no longer
2468
+ * registered for this session (a live reload that dropped it, or a session
2469
+ * loaded before the owning plugin). Such a save can still go through the replay
2470
+ * path, so it is not fatal.
2471
+ *
2472
+ * @param message - the host's error text.
2473
+ * @returns true when the record's projection is gone.
2474
+ */
2475
+ export function isProjectionUnavailable(message) {
2476
+ return typeof message === 'string' && /projection .*was removed or replaced/i.test(message)
2477
+ }
2478
+
2327
2479
  function failure(res, error) {
2328
2480
  const status = error instanceof HttpError ? error.status : 500
2329
2481
  const code = error instanceof HttpError ? error.code : 'internal'
2482
+ // An unexpected throw is the only case the client cannot explain; leave the
2483
+ // stack in the host log so a report can name the cause.
2484
+ if (!(error instanceof HttpError)) console.error('[dsh-edit-turn] internal error', error)
2330
2485
  sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
2331
2486
  }
2332
2487
 
@@ -2341,6 +2496,9 @@ export function apply(ctx, config) {
2341
2496
  carrier: config && config.carrier === 'user/message' ? 'user/message' : 'system/message',
2342
2497
  confirm: config && typeof config.confirm === 'boolean' ? config.confirm : false,
2343
2498
  replyMode: config && (config.replyMode === 'replay' || config.replyMode === 'patch') ? config.replyMode : 'auto',
2499
+ editorStyle: config && (config.editorStyle === 'bubble' || config.editorStyle === 'center')
2500
+ ? config.editorStyle
2501
+ : 'frame',
2344
2502
  }
2345
2503
 
2346
2504
  // The in-place reply path needs two host seams: a plugin record with the
package/lib/patch.js CHANGED
@@ -35,9 +35,27 @@ const KIND_TO_TYPES = {
35
35
  * @returns 'text' | 'reasoning' | null.
36
36
  */
37
37
  export function kindOfBlock(block) {
38
+ return blockField(block) === null ? null : blockField(block).kind
39
+ }
40
+
41
+ /**
42
+ * The editable text of one block, plus which field carries it.
43
+ *
44
+ * A platform is free to name the reasoning payload `text`, `reasoning` or
45
+ * `thinking`; the editor reads all three and writes back to the field it read,
46
+ * so a shape this build has not seen before is still editable.
47
+ *
48
+ * @param block - one content block.
49
+ * @returns `{ kind, field, text }`, or null when the block carries no text.
50
+ */
51
+ export function blockField(block) {
38
52
  if (!block || typeof block !== 'object') return null
39
- if (block.type === 'text' && typeof block.text === 'string') return 'text'
40
- if ((block.type === 'reasoning' || block.type === 'thinking') && typeof block.text === 'string') return 'reasoning'
53
+ if (block.type === 'text' && typeof block.text === 'string') return { kind: 'text', field: 'text', text: block.text }
54
+ if (block.type === 'reasoning' || block.type === 'thinking') {
55
+ for (const field of ['text', 'reasoning', 'thinking']) {
56
+ if (typeof block[field] === 'string') return { kind: 'reasoning', field, text: block[field] }
57
+ }
58
+ }
41
59
  return null
42
60
  }
43
61
 
@@ -51,9 +69,9 @@ export function editableBlocks(message) {
51
69
  const out = []
52
70
  for (let index = 0; index < content.length; index += 1) {
53
71
  const block = content[index]
54
- const kind = kindOfBlock(block)
55
- if (kind === null) continue
56
- out.push({ index, kind, text: block.text })
72
+ const found = blockField(block)
73
+ if (found === null) continue
74
+ out.push({ index, kind: found.kind, text: found.text })
57
75
  }
58
76
  return out
59
77
  }
@@ -125,10 +143,10 @@ export function applyTargetsToMessage(message, targets) {
125
143
  const content = Array.isArray(message.content) ? message.content.slice() : []
126
144
  for (const target of targets) {
127
145
  const block = content[target.blockIndex]
128
- const kind = kindOfBlock(block)
129
- if (kind === null || kind !== target.kind) continue
130
- const patched = { ...block, text: target.text }
131
- if (kind === 'reasoning') {
146
+ const found = blockField(block)
147
+ if (found === null || found.kind !== target.kind) continue
148
+ const patched = { ...block, [found.field]: target.text }
149
+ if (found.kind === 'reasoning') {
132
150
  // The signature signs the original text; keep it and some providers
133
151
  // reject the whole request. The recorded block keeps its signature; the
134
152
  // projected copy drops it, exactly like the disk path does.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.3.3",
3
+ "version": "0.5.2",
4
4
  "description": "Edit any message in DeepSeek Harness in place: a prompt is replaced where it stands; a model reply block (answer text or reasoning) is patched through a plugin record and a message projection, so the turn stays where it is, later turns are untouched and the log grows by one small record. Falls back to the replay splice on hosts without the projection API. The append-only session log is never rewritten.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",