dsh-edit-turn 0.3.3 → 0.5.0

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.0'
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
@@ -164,6 +164,14 @@ export const Config =
164
164
  * the official surface replacement.
165
165
  */
166
166
  replyMode: z.string().default('auto'),
167
+ /**
168
+ * Which editor presentation the browser half draws. `frame` outlines the
169
+ * whole message row (reference-UI style); `bubble` is exactly the
170
+ * message bubble; `center` is a large centred panel. The setting exists
171
+ * because the best shape is a matter of taste and was hard to pick
172
+ * without trying them side by side.
173
+ */
174
+ editorStyle: z.union([z.const('frame'), z.const('bubble'), z.const('center')]).default('frame'),
167
175
  })
168
176
 
169
177
  // ---------------------------------------------------------------------------
@@ -1300,6 +1308,25 @@ export function editableReplies(events, surfaceNodes, options = {}) {
1300
1308
  if (!patch && text.trim() === '') continue
1301
1309
  if (patch && blocks.every((block) => block.text.trim() === '')) continue
1302
1310
  const turn = turnOf.get(event.seq)
1311
+ // One turn runs many steps, and every step's assistant message carries its
1312
+ // own reasoning. The answer's row is the one with the action strip, so its
1313
+ // block list is widened to the whole turn: every reasoning and every answer
1314
+ // text of that turn is offered, each annotated with the seq it lives in.
1315
+ let listed = blocks.map((block) => ({ ...block, seq: event.seq }))
1316
+ const carriesAnswer = blocks.some((block) => block.kind === 'text' && block.text.trim() !== '')
1317
+ if (patch && carriesAnswer && typeof turn === 'number') {
1318
+ const acrossTurn = []
1319
+ for (const other of events) {
1320
+ if (!isAssistantReply(other) || turnOf.get(other.seq) !== turn) continue
1321
+ if (!nodeIndex.has(other.seq)) continue
1322
+ const otherData = other.data && typeof other.data === 'object' ? other.data : {}
1323
+ const otherMessage = otherData.message && typeof otherData.message === 'object' ? otherData.message : otherData
1324
+ for (const block of projectedBlocks(otherMessage, other.seq, patches)) {
1325
+ acrossTurn.push({ ...block, seq: other.seq })
1326
+ }
1327
+ }
1328
+ if (acrossTurn.length > listed.length) listed = acrossTurn
1329
+ }
1303
1330
  out.push({
1304
1331
  seq: event.seq,
1305
1332
  turn: typeof turn === 'number' ? turn : null,
@@ -1311,7 +1338,7 @@ export function editableReplies(events, surfaceNodes, options = {}) {
1311
1338
  patched: blocks.some((block) => block.patched),
1312
1339
  // Present only where the editor can target one: the replay path can only
1313
1340
  // replace the whole reply, so it has no block picker.
1314
- ...(patch ? { blocks } : {}),
1341
+ ...(patch ? { blocks: listed } : {}),
1315
1342
  })
1316
1343
  }
1317
1344
  return out
@@ -1706,7 +1733,12 @@ async function stateOf(ctx, sessionId, config) {
1706
1733
  // path (a chip that may draw its own thumbnail); both are answered from a
1707
1734
  // live service probe, so the editor never promises what the host cannot do.
1708
1735
  capabilities: { attachments: store !== null, preview: canPreviewAttachments(store) },
1709
- config: { confirm: config.confirm, replyMode: config.replyMode, patch: config.patch === true },
1736
+ config: {
1737
+ confirm: config.confirm,
1738
+ replyMode: config.replyMode,
1739
+ patch: config.patch === true,
1740
+ editorStyle: config.editorStyle,
1741
+ },
1710
1742
  version: PLUGIN_VERSION,
1711
1743
  }
1712
1744
  }
@@ -1809,7 +1841,82 @@ export function buildCorrection(plan, text, turn, step) {
1809
1841
  }
1810
1842
  }
1811
1843
 
1844
+ /**
1845
+ * The all-blocks save: one plugin record carrying a target per edited block,
1846
+ * possibly across several messages of the same turn. Each target is planned
1847
+ * exactly like a single-block save, and unchanged blocks are simply left out.
1848
+ *
1849
+ * @param ctx - cordis context.
1850
+ * @param sessionId - the addressed session.
1851
+ * @param body - `{ targets: [{ seq, blockIndex, kind, text }] }`.
1852
+ * @param config - plugin config.
1853
+ * @returns the same shape `/apply` returns for a reply patch.
1854
+ */
1855
+ async function applyTargetsEdit(ctx, sessionId, body, config) {
1856
+ const targets = Array.isArray(body.targets) ? body.targets : []
1857
+ if (targets.length === 0 || targets.length > MAX_PARTS) {
1858
+ throw new HttpError(400, 'invalid', 'targets must be a nonempty, bounded array')
1859
+ }
1860
+ for (const target of targets) {
1861
+ if (target === null || typeof target !== 'object') throw new HttpError(400, 'invalid', 'each target must be an object')
1862
+ if (typeof target.text !== 'string' || target.text.trim() === '') throw new HttpError(400, 'invalid', 'an edited block cannot be empty')
1863
+ if (target.text.length > MAX_TEXT) throw new HttpError(400, 'invalid', 'the revised block is too long')
1864
+ }
1865
+ const session = await resolveSession(ctx, sessionId)
1866
+ if (!session || typeof session.append !== 'function') {
1867
+ throw new HttpError(409, 'session-not-active', 'the session is not open in DSH')
1868
+ }
1869
+ const events = await readEvents(ctx, sessionId)
1870
+ if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
1871
+ if (isBusy(events)) throw new HttpError(409, 'busy', 'the session is still working')
1872
+ const surfaceNodes = surfaceOf(ctx, sessionId, events)
1873
+ const patches = patchesOf(events)
1874
+ const plans = []
1875
+ for (const target of targets) {
1876
+ let plan
1877
+ try {
1878
+ plan = planRollback(events, surfaceNodes, { seq: target.seq, blockIndex: target.blockIndex, kind: target.kind }, { patches, patch: config.patch === true })
1879
+ } catch (error) {
1880
+ if (error instanceof EditPlanError) {
1881
+ const status = error.code === 'not-editable' ? 400 : 409
1882
+ throw new HttpError(status, error.code, error.message)
1883
+ }
1884
+ throw error
1885
+ }
1886
+ if (plan.patch !== true) throw new HttpError(400, 'not-editable', 'all-blocks editing needs the in-place reply path')
1887
+ if (target.text !== plan.original) plans.push({ plan, text: target.text })
1888
+ }
1889
+ if (plans.length === 0) {
1890
+ return { kind: 'reply', applied: false, unchanged: true, patch: true, shadowed: [], targets: [] }
1891
+ }
1892
+ if (typeof sessionApi.appendPluginRecord !== 'function') {
1893
+ throw new HttpError(409, 'unsupported', 'this harness does not expose plugin records; reply edits need the replay path')
1894
+ }
1895
+ let recordSeq
1896
+ try {
1897
+ recordSeq = sessionApi.appendPluginRecord(
1898
+ session,
1899
+ PATCH_TYPE,
1900
+ buildPatchData(plans.map(({ plan, text }) => ({ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }))),
1901
+ )
1902
+ } catch (error) {
1903
+ throw new HttpError(409, 'stale', `the session refused the patch record: ${String((error && error.message) || error)}`)
1904
+ }
1905
+ const flush = await flushSession(ctx, session)
1906
+ return {
1907
+ kind: 'reply',
1908
+ applied: true,
1909
+ mode: 'patch',
1910
+ patch: true,
1911
+ recordSeq,
1912
+ targets: plans.map(({ plan, text }) => ({ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text, original: plan.original })),
1913
+ shadowed: [],
1914
+ ...flush,
1915
+ }
1916
+ }
1917
+
1812
1918
  async function applyEdit(ctx, sessionId, body, config) {
1919
+ if (Array.isArray(body.targets) && body.targets.length > 0) return applyTargetsEdit(ctx, sessionId, body, config)
1813
1920
  const text = typeof body.text === 'string' ? body.text : ''
1814
1921
  if (text.trim() === '') throw new HttpError(400, 'invalid', 'the revised message cannot be empty')
1815
1922
  if (text.length > MAX_TEXT) throw new HttpError(400, 'invalid', 'the revised message is too long')
@@ -1828,7 +1935,9 @@ async function applyEdit(ctx, sessionId, body, config) {
1828
1935
  let plan
1829
1936
  try {
1830
1937
  plan = planRollback(events, surfaceNodes, {
1831
- seq: typeof body.seq === 'number' ? body.seq : undefined,
1938
+ // A block from another step of the same turn names its own seq; that is
1939
+ // the message the projection has to patch.
1940
+ seq: typeof body.targetSeq === 'number' ? body.targetSeq : typeof body.seq === 'number' ? body.seq : undefined,
1832
1941
  messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
1833
1942
  turn: typeof body.turn === 'number' ? body.turn : undefined,
1834
1943
  blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
@@ -2341,6 +2450,9 @@ export function apply(ctx, config) {
2341
2450
  carrier: config && config.carrier === 'user/message' ? 'user/message' : 'system/message',
2342
2451
  confirm: config && typeof config.confirm === 'boolean' ? config.confirm : false,
2343
2452
  replyMode: config && (config.replyMode === 'replay' || config.replyMode === 'patch') ? config.replyMode : 'auto',
2453
+ editorStyle: config && (config.editorStyle === 'bubble' || config.editorStyle === 'center')
2454
+ ? config.editorStyle
2455
+ : 'frame',
2344
2456
  }
2345
2457
 
2346
2458
  // 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.0",
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",