dsh-edit-turn 0.2.18 → 0.2.19

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
@@ -19,6 +19,17 @@
19
19
  // pair can be left dangling.
20
20
  //
21
21
  //
22
+ // A revised PROMPT carries the message itself: the carrier's content is the
23
+ // user's block list after the edit, rebuilt block by block. A block the user
24
+ // did not touch is copied out of the original message verbatim - same object,
25
+ // same fields, same attachment reference - a block the user added is admitted
26
+ // through the platform's attachment store (`ctx.get('attachments')`, the seam
27
+ // dsh-acp uses) and a block the user removed is simply not written. The revised
28
+ // text lands where the original's first text block was, so the layout survives
29
+ // the edit. Nothing in that path names a block shape: the only test in the
30
+ // module is "does this block carry text", which is the editor's own field, so a
31
+ // block type the platform adds later needs no change here.
32
+ //
22
33
  // The silent replacement carrier is an EMPTY `developer/message` (0.2.12;
23
34
  // before that an empty `system/message`, which no reader accepts - see the
24
35
  // README). Empty content projects to no model message, so the rollback adds
@@ -37,6 +48,7 @@
37
48
  // degrades to "no config form, defaults apply" instead, which costs a settings
38
49
  // surface rather than the feature. Every field is optional in `apply` anyway.
39
50
  import { randomUUID } from 'node:crypto'
51
+ import { readFile } from 'node:fs/promises'
40
52
  import { createRequire } from 'node:module'
41
53
  import { defineTool } from '@deepseek-ai/dsh-tools'
42
54
 
@@ -57,7 +69,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
57
69
  export const name = PLUGIN_ID
58
70
 
59
71
  /** Keep in sync with package.json and lib/client.js. */
60
- export const PLUGIN_VERSION = '0.2.18'
72
+ export const PLUGIN_VERSION = '0.2.19'
61
73
 
62
74
  // A ring of the last few requests, answering "did the browser half even ask, and
63
75
  // what did it get?" without needing its console. Read-only diagnostics: the
@@ -81,6 +93,30 @@ const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{
81
93
  const MAX_TEXT = 200_000
82
94
  const FLUSH_DEADLINE_MS = 5_000
83
95
 
96
+ // A revised block list travels as base64 inside the JSON body, so the body limit
97
+ // IS the upload limit. The platform's own per-image ceiling defaults to 20 MiB
98
+ // (dsh-attachment-local), which base64 carries in ~27 MiB of text; the store
99
+ // enforces its own limit independently, this one only bounds the socket read.
100
+ const MAX_BODY_BYTES = 64 * 1024 * 1024
101
+ const MAX_UPLOAD_BYTES = 20 * 1024 * 1024
102
+ // A preview is a courtesy: above this the chip keeps its label and asks for
103
+ // nothing. Never worth streaming a whole file to draw a thumbnail.
104
+ const MAX_PREVIEW_BYTES = 32 * 1024 * 1024
105
+ // One message cannot carry more blocks than this. The cap exists so a malformed
106
+ // or hostile body cannot make the host loop over an unbounded list.
107
+ const MAX_PARTS = 64
108
+
109
+ /**
110
+ * Service names the durable attachment store is reachable under.
111
+ *
112
+ * `attachments` is the platform's own registration (the `AttachmentStore`
113
+ * base class in @deepseek-ai/dsh-attachment, and every `ctx.get('attachments')`
114
+ * consumer such as dsh-acp); the other two are the package names, accepted so a
115
+ * rename cannot silently cost the feature. Absence is a supported state, never
116
+ * an error: the edit degrades to the text-only rewrite it was before blocks.
117
+ */
118
+ const ATTACHMENT_SERVICES = ['attachments', 'attachment', 'attachment-local']
119
+
84
120
  /**
85
121
  * Model-visible text of the replacement carrier.
86
122
  *
@@ -295,30 +331,287 @@ export function isAssistantReply(event) {
295
331
  return Boolean(event) && event.type === 'assistant/message'
296
332
  }
297
333
 
334
+ /**
335
+ * Whether one content block carries the editable text.
336
+ *
337
+ * This is the module's ONLY block-shape test, and it is deliberately about the
338
+ * field the editor owns rather than about a block class. Every other block - a
339
+ * picture, a file, and whatever type a later platform adds - is carried through
340
+ * an edit untouched unless the user removed it, so no code below ever has to
341
+ * learn a new name.
342
+ *
343
+ * @param block - one content block.
344
+ * @returns true when the block holds text the editor prefills.
345
+ */
346
+ export function isTextBlock(block) {
347
+ return Boolean(block) && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string'
348
+ }
349
+
350
+ /**
351
+ * The content blocks of a message payload, in message order.
352
+ *
353
+ * `user/message` carries its message fields on `data`; the other three surface
354
+ * types nest them under `data.message`, so both shapes read the same way now
355
+ * that the model's replies are editable too.
356
+ *
357
+ * @param event - raw session event.
358
+ * @returns the block array, or an empty one when the message carries none.
359
+ */
360
+ export function readMessageBlocks(event) {
361
+ const data = (event && event.data) || {}
362
+ const message = data.message && typeof data.message === 'object' ? data.message : data
363
+ return Array.isArray(message.content) ? message.content : []
364
+ }
365
+
298
366
  /**
299
367
  * Read the plain text of a message payload, plus how many non-text parts it
300
- * carries. Editing is text-only, so a caller must be told when a rewrite would
301
- * drop attachments.
368
+ * carries. The count is what tells a reader that a text-only carrier would drop
369
+ * something, and it stays exactly what it always was.
302
370
  * @param event - raw session event.
303
371
  * @returns `{ text, attachments }`.
304
372
  */
305
373
  export function readMessageText(event) {
306
- const data = (event && event.data) || {}
307
- // `user/message` carries its message fields on `data`; the other three surface
308
- // types nest them under `data.message`. Both shapes have to read the same way
309
- // now that the model's replies are editable too.
310
- const message = data.message && typeof data.message === 'object' ? data.message : data
311
- const blocks = Array.isArray(message.content) ? message.content : []
312
374
  let text = ''
313
375
  let attachments = 0
314
- for (const block of blocks) {
376
+ for (const block of readMessageBlocks(event)) {
315
377
  if (!block || typeof block !== 'object') continue
316
- if (block.type === 'text' && typeof block.text === 'string') text += (text === '' ? '' : '\n') + block.text
378
+ if (isTextBlock(block)) text += (text === '' ? '' : '\n') + block.text
317
379
  else attachments += 1
318
380
  }
319
381
  return { text, attachments }
320
382
  }
321
383
 
384
+ /**
385
+ * The non-text blocks of a message, described for the editor's chips.
386
+ *
387
+ * Read by FIELD PRESENCE, never by type. A block that carries a durable
388
+ * `attachment` reference is described from that reference (name, media type,
389
+ * byte length, pixel size) whatever the platform calls the block; a block
390
+ * without one is described by its own type name alone, which is what a chip can
391
+ * honestly show. This list is for DRAWING and for offering a delete - it never
392
+ * decides what an edit writes, and a block the user keeps is copied verbatim
393
+ * whether or not this function could describe it.
394
+ *
395
+ * @param blocks - the message content, in message order.
396
+ * @param preview - whether the deployment can hand these bytes back for a thumbnail.
397
+ * @returns one entry per non-text block, in message order.
398
+ */
399
+ export function blockSummaries(blocks, preview = false) {
400
+ const out = []
401
+ for (let index = 0; index < blocks.length; index += 1) {
402
+ const block = blocks[index]
403
+ if (!block || typeof block !== 'object' || isTextBlock(block)) continue
404
+ const raw = block.attachment
405
+ const attachment = raw && typeof raw === 'object' ? raw : null
406
+ const summary = {
407
+ index,
408
+ type: typeof block.type === 'string' && block.type !== '' ? block.type : 'block',
409
+ preview: false,
410
+ }
411
+ if (attachment !== null) {
412
+ if (typeof attachment.name === 'string' && attachment.name !== '') summary.name = attachment.name
413
+ if (typeof attachment.mediaType === 'string' && attachment.mediaType !== '') summary.mediaType = attachment.mediaType
414
+ if (typeof attachment.bytes === 'number') summary.bytes = attachment.bytes
415
+ if (typeof attachment.width === 'number') summary.width = attachment.width
416
+ if (typeof attachment.height === 'number') summary.height = attachment.height
417
+ summary.preview = preview && typeof attachment.attachmentId === 'string' && attachment.attachmentId !== ''
418
+ }
419
+ out.push(summary)
420
+ }
421
+ return out
422
+ }
423
+
424
+ /**
425
+ * Whether two JSON values are the same value.
426
+ *
427
+ * Used to answer "did this edit change anything at all": the rebuilt block list
428
+ * is compared against the original one, field by field. Key ORDER is not part
429
+ * of a JSON value - a session reader is free to hand the same object back with
430
+ * its keys in another order - so this walks the keys instead of comparing
431
+ * stringified text.
432
+ *
433
+ * @param a - first JSON value.
434
+ * @param b - second JSON value.
435
+ * @returns true when the two are indistinguishable JSON.
436
+ */
437
+ export function sameJson(a, b) {
438
+ if (a === b) return true
439
+ if (Array.isArray(a) || Array.isArray(b)) {
440
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
441
+ for (let index = 0; index < a.length; index += 1) {
442
+ if (!sameJson(a[index], b[index])) return false
443
+ }
444
+ return true
445
+ }
446
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false
447
+ const keys = Object.keys(a)
448
+ if (keys.length !== Object.keys(b).length) return false
449
+ for (const key of keys) {
450
+ if (!Object.prototype.hasOwnProperty.call(b, key)) return false
451
+ if (!sameJson(a[key], b[key])) return false
452
+ }
453
+ return true
454
+ }
455
+
456
+ /**
457
+ * Normalise one submitted block list.
458
+ *
459
+ * The wire vocabulary is exactly two entries:
460
+ *
461
+ * `{ keep: <index> }` - the original block at that index, untouched;
462
+ * `{ add: { data, mediaType?, name? } }` - bytes the user just picked.
463
+ *
464
+ * Text is never listed: the revised text rides in its own field and the host
465
+ * puts it back where the original's first text block was. A `keep` that names
466
+ * a text block is therefore not an error - it is the text, and the text is
467
+ * already accounted for.
468
+ *
469
+ * @param raw - the submitted `parts` array.
470
+ * @param blockCount - how many blocks the original message carries.
471
+ * @returns `[{ keep } | { add }]` in submitted order.
472
+ * @throws {HttpError} 400 `invalid` for anything this vocabulary does not name.
473
+ */
474
+ export function normalizeParts(raw, blockCount) {
475
+ if (!Array.isArray(raw)) throw new HttpError(400, 'invalid', 'parts must be an array')
476
+ if (raw.length > MAX_PARTS) throw new HttpError(400, 'invalid', `a message cannot carry more than ${MAX_PARTS} blocks`)
477
+ const parts = []
478
+ for (const entry of raw) {
479
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
480
+ throw new HttpError(400, 'invalid', 'every part must be an object')
481
+ }
482
+ if (entry.keep !== undefined) {
483
+ const index = entry.keep
484
+ if (!Number.isInteger(index) || index < 0 || index >= blockCount) {
485
+ throw new HttpError(400, 'invalid', 'a kept block index is out of range')
486
+ }
487
+ if (parts.some((part) => part.keep === index)) throw new HttpError(400, 'invalid', 'a block was kept twice')
488
+ parts.push({ keep: index })
489
+ continue
490
+ }
491
+ const upload = entry.add
492
+ if (upload && typeof upload === 'object' && typeof upload.data === 'string') {
493
+ parts.push({
494
+ add: {
495
+ data: upload.data,
496
+ ...(typeof upload.mediaType === 'string' && upload.mediaType !== '' ? { mediaType: upload.mediaType } : {}),
497
+ ...(typeof upload.name === 'string' && upload.name !== '' ? { name: upload.name } : {}),
498
+ },
499
+ })
500
+ continue
501
+ }
502
+ throw new HttpError(400, 'invalid', 'every part is either a kept block or an upload')
503
+ }
504
+ return parts
505
+ }
506
+
507
+ /**
508
+ * The ordered slots a submitted block list describes, plus where the text goes.
509
+ *
510
+ * Layout is the original message's, never a re-invention: the revised text is
511
+ * placed where the original's FIRST text block stood (the editor holds every
512
+ * text block at once, so the others merge into it), and the kept blocks keep
513
+ * their relative order around it. A message with no text block at all - a
514
+ * picture-only prompt - gets its text appended, which is the only position that
515
+ * does not move something the user did not touch.
516
+ *
517
+ * @param originalBlocks - the message content before the edit.
518
+ * @param parts - normalised parts (see {@link normalizeParts}).
519
+ * @returns `{ slots, textAt, added }`; `textAt` indexes `slots`.
520
+ */
521
+ export function planBlockLayout(originalBlocks, parts) {
522
+ const slots = []
523
+ let added = 0
524
+ for (const part of parts) {
525
+ if (part.keep !== undefined) {
526
+ // The text blocks are the editor's own content, carried by `text`.
527
+ if (isTextBlock(originalBlocks[part.keep])) continue
528
+ slots.push({ kind: 'original', index: part.keep })
529
+ continue
530
+ }
531
+ slots.push({ kind: 'add', upload: part.add })
532
+ added += 1
533
+ }
534
+ let firstText = -1
535
+ for (let index = 0; index < originalBlocks.length; index += 1) {
536
+ if (isTextBlock(originalBlocks[index])) {
537
+ firstText = index
538
+ break
539
+ }
540
+ }
541
+ let textAt = slots.length
542
+ if (firstText >= 0) {
543
+ // The text stands before the first kept block that came after it in the
544
+ // original message. When no kept block did - everything after it was
545
+ // removed, or it was the last block of all - it keeps its own place at the
546
+ // head of what is left, ahead of the blocks that used to stand before it.
547
+ let anchored = false
548
+ for (let index = 0; index < slots.length; index += 1) {
549
+ const slot = slots[index]
550
+ if (slot.kind === 'original' && slot.index > firstText) {
551
+ textAt = index
552
+ anchored = true
553
+ break
554
+ }
555
+ }
556
+ if (!anchored) {
557
+ let before = 0
558
+ for (const slot of slots) {
559
+ if (slot.kind === 'original' && slot.index < firstText) before += 1
560
+ }
561
+ textAt = before
562
+ }
563
+ }
564
+ return { slots, textAt, added }
565
+ }
566
+
567
+ /**
568
+ * Build the carrier content from a layout.
569
+ *
570
+ * A kept block is copied out of the original VERBATIM - a structured clone of
571
+ * the very object the message carries, so an attachment reference, a name, a
572
+ * dimension and every field a later platform adds cross the edit untouched and
573
+ * byte-identical. Nothing here inspects what the block is.
574
+ *
575
+ * @param originalBlocks - the message content before the edit.
576
+ * @param layout - the layout from {@link planBlockLayout}.
577
+ * @param admitted - blocks for the layout's `add` slots, in slot order.
578
+ * @param text - the revised text.
579
+ * @returns the block array the carrier is written with.
580
+ */
581
+ export function assembleBlocks(originalBlocks, layout, admitted, text) {
582
+ const content = []
583
+ let next = 0
584
+ for (let index = 0; index <= layout.slots.length; index += 1) {
585
+ if (index === layout.textAt) content.push({ type: 'text', text })
586
+ const slot = layout.slots[index]
587
+ if (slot === undefined) continue
588
+ if (slot.kind === 'original') content.push(copyBlock(originalBlocks[slot.index]))
589
+ else content.push(admitted[next++])
590
+ }
591
+ return content
592
+ }
593
+
594
+ /**
595
+ * A verbatim copy of one block.
596
+ *
597
+ * `structuredClone` is what makes "untouched" literally true rather than
598
+ * nearly true: the carrier is serialized into the session log, so an identical
599
+ * copy and the same reference produce identical bytes - the clone only removes
600
+ * the chance that a later writer mutates the original through it.
601
+ *
602
+ * @param block - one content block.
603
+ * @returns an independent copy of the same JSON value.
604
+ */
605
+ function copyBlock(block) {
606
+ try {
607
+ return structuredClone(block)
608
+ } catch {
609
+ // Not clonable (an exotic in-memory block): the block itself is still the
610
+ // verbatim value, which is what this function promises.
611
+ return block
612
+ }
613
+ }
614
+
322
615
  /**
323
616
  * Rebuild this plugin's rollback ledger from the log alone.
324
617
  *
@@ -462,6 +755,9 @@ export function planRollback(events, surfaceNodes, request) {
462
755
  throw new EditPlanError('not-editable', 'the target does not open a rollback window')
463
756
  }
464
757
  const { text, attachments } = readMessageText(target)
758
+ // The blocks themselves travel with the plan: the carrier is rebuilt from
759
+ // them, so no caller has to re-read the message to know what it carries.
760
+ const blocks = readMessageBlocks(target)
465
761
  const turn = turnOf.get(target.seq)
466
762
  const data = target.data && typeof target.data === 'object' ? target.data : {}
467
763
  const message = data.message && typeof data.message === 'object' ? data.message : data
@@ -481,6 +777,7 @@ export function planRollback(events, surfaceNodes, request) {
481
777
  model: source && typeof source.model === 'string' ? source.model : null,
482
778
  original: text,
483
779
  attachments,
780
+ blocks,
484
781
  }
485
782
  }
486
783
 
@@ -493,9 +790,11 @@ export function planRollback(events, surfaceNodes, request) {
493
790
  *
494
791
  * @param events - complete contiguous raw event log.
495
792
  * @param surfaceNodes - current surface seqs in model order.
793
+ * @param options - `{ preview }`: whether the deployment can hand attachment
794
+ * bytes back, which decides if a chip may offer a thumbnail.
496
795
  * @returns one entry per editable prompt, in conversation order.
497
796
  */
498
- export function editableTurns(events, surfaceNodes) {
797
+ export function editableTurns(events, surfaceNodes, options = {}) {
499
798
  const eventBySeq = new Map(events.map((event) => [event.seq, event]))
500
799
  const turnOf = turnIndex(events)
501
800
  const head = surfaceNodes[0]
@@ -516,6 +815,9 @@ export function editableTurns(events, surfaceNodes) {
516
815
  messageId: messageIdOf(event) ?? null,
517
816
  text,
518
817
  attachments,
818
+ // What the editor draws beside the text: one chip per non-text block,
819
+ // described by field presence only (see blockSummaries).
820
+ blocks: blockSummaries(readMessageBlocks(event), options.preview === true),
519
821
  })
520
822
  }
521
823
  return out
@@ -727,6 +1029,166 @@ async function flushSession(ctx, session) {
727
1029
  return { flushed: false, flushError: errors.join(' | ') }
728
1030
  }
729
1031
 
1032
+ // --- attachment admission ----------------------------------------------------
1033
+ //
1034
+ // The platform's durable store is the only path by which bytes become a
1035
+ // reference a session may cite (@deepseek-ai/dsh-attachment's `AttachmentStore`;
1036
+ // the local backend is @deepseek-ai/dsh-attachment-local). It is resolved at
1037
+ // REQUEST time, never assumed: a deployment that mounts none must keep working
1038
+ // exactly as this plugin did before blocks travelled at all - text only, with
1039
+ // the editor saying so - and one that mounts a store later starts working with
1040
+ // no restart.
1041
+
1042
+ /**
1043
+ * The durable attachment store this deployment mounts, when it has one.
1044
+ *
1045
+ * Candidate names are probed the way the platform's own consumers reach it
1046
+ * (`ctx.get('attachments')`, e.g. dsh-acp). An object is only accepted when it
1047
+ * can actually admit something: a service that merely shares the name must not
1048
+ * make the editor promise to keep a block the host cannot store.
1049
+ *
1050
+ * @param ctx - plugin context.
1051
+ * @returns the store, or null when the deployment mounts none.
1052
+ */
1053
+ export function resolveAttachmentStore(ctx) {
1054
+ if (!ctx || typeof ctx.get !== 'function') return null
1055
+ for (const name of ATTACHMENT_SERVICES) {
1056
+ let store
1057
+ try {
1058
+ store = ctx.get(name)
1059
+ } catch {
1060
+ store = null
1061
+ }
1062
+ if (!store || typeof store !== 'object') continue
1063
+ if (typeof store.saveImage === 'function' || typeof store.saveFile === 'function') return store
1064
+ }
1065
+ return null
1066
+ }
1067
+
1068
+ /**
1069
+ * Whether this deployment can hand attachment bytes back for a chip thumbnail.
1070
+ * @param store - the resolved store, or null.
1071
+ * @returns true when a read path exists.
1072
+ */
1073
+ export function canPreviewAttachments(store) {
1074
+ return Boolean(store) && (typeof store.readImage === 'function' || typeof store.fileHostPath === 'function')
1075
+ }
1076
+
1077
+ /**
1078
+ * Decode one upload exactly the way the platform's wire admission does.
1079
+ *
1080
+ * Canonical base64 only (the platform's `admission.js` test): a payload whose
1081
+ * re-encoding differs is refused rather than silently decoded into different
1082
+ * bytes than the browser meant to send.
1083
+ *
1084
+ * @param data - base64 text.
1085
+ * @returns the decoded bytes.
1086
+ * @throws {HttpError} 400 `invalid` for anything but bounded canonical base64.
1087
+ */
1088
+ function decodeUpload(data) {
1089
+ if (typeof data !== 'string' || data === '') throw new HttpError(400, 'invalid', 'an upload carried no data')
1090
+ if (data.length > MAX_UPLOAD_BYTES * 2) throw new HttpError(400, 'invalid', 'an upload is too large')
1091
+ const decoded = Buffer.from(data, 'base64')
1092
+ if (decoded.toString('base64') !== data) throw new HttpError(400, 'invalid', 'an upload is not canonical base64')
1093
+ if (decoded.byteLength > MAX_UPLOAD_BYTES) throw new HttpError(400, 'invalid', 'an upload is too large')
1094
+ return new Uint8Array(decoded)
1095
+ }
1096
+
1097
+ /**
1098
+ * Admit one uploaded payload and hand back the block that cites it.
1099
+ *
1100
+ * The STORE decides the shape, not this plugin: bytes it accepts as an image
1101
+ * become the platform's image block, anything else is stored verbatim as a file
1102
+ * block. That is the platform's own vocabulary (`AttachmentStore.saveImage` /
1103
+ * `saveFile`, and `admitPromptContent` builds the same two shapes), and it is
1104
+ * the only place a block type is named in the whole edit path - a block the
1105
+ * user did not touch never passes through here at all.
1106
+ *
1107
+ * @param store - the resolved attachment store.
1108
+ * @param upload - `{ data, mediaType?, name? }`.
1109
+ * @returns the content block citing the durable reference.
1110
+ * @throws {HttpError} 400 `attachment-refused` when the store refuses it.
1111
+ */
1112
+ export async function admitUpload(store, upload) {
1113
+ const data = decodeUpload(upload.data)
1114
+ const name = typeof upload.name === 'string' && upload.name !== '' ? upload.name : undefined
1115
+ const mediaType = typeof upload.mediaType === 'string' && upload.mediaType !== '' ? upload.mediaType : undefined
1116
+ // The store's own advertised media types decide whether it can take this as
1117
+ // an image; without that list, the declared type family is the only signal.
1118
+ const imageTypes = store.imageLimits && Array.isArray(store.imageLimits.mediaTypes) ? store.imageLimits.mediaTypes : null
1119
+ const asImage = mediaType !== undefined && typeof store.saveImage === 'function' &&
1120
+ (imageTypes === null ? mediaType.startsWith('image/') : imageTypes.includes(mediaType))
1121
+ try {
1122
+ if (asImage) {
1123
+ const ref = await store.saveImage(name === undefined ? { data, mediaType } : { data, mediaType, name })
1124
+ return { type: 'image', attachment: ref }
1125
+ }
1126
+ if (typeof store.saveFile !== 'function') {
1127
+ throw new HttpError(400, 'attachment-refused', 'this deployment can only store images')
1128
+ }
1129
+ const ref = await store.saveFile(name === undefined ? { data } : { data, name })
1130
+ return { type: 'file', attachment: ref }
1131
+ } catch (error) {
1132
+ if (error instanceof HttpError) throw error
1133
+ // The store's own refusal (too many images, wrong bytes, an unwritable
1134
+ // root) is reported as-is and NOTHING is written: admission runs before the
1135
+ // first append, so a refused upload can never leave a half-built carrier.
1136
+ const detail = String((error && error.message) || error)
1137
+ throw new HttpError(400, 'attachment-refused', detail)
1138
+ }
1139
+ }
1140
+
1141
+ /**
1142
+ * The bytes of one block's attachment, for a chip's thumbnail.
1143
+ *
1144
+ * A display-only read, deliberately kept out of the edit path. It answers from
1145
+ * whatever read seam the mounted store offers - an image reader, or the host
1146
+ * path of a verbatim file - and gives up quietly when neither exists, because a
1147
+ * chip without a picture is still a usable chip. The block is located by the
1148
+ * seq of the message and its index inside that message's content, and the only
1149
+ * authorization is that the session's own log really does cite it: the same bar
1150
+ * the platform's attachment RPC applies before handing bytes over.
1151
+ *
1152
+ * @param ctx - plugin context.
1153
+ * @param sessionId - the session whose log owns the block.
1154
+ * @param seq - seq of the message event.
1155
+ * @param index - the block's index in that message's content.
1156
+ * @returns `{ data, mediaType }`, or null when there is nothing to serve.
1157
+ */
1158
+ export async function attachmentBytes(ctx, sessionId, seq, index) {
1159
+ const events = await readEvents(ctx, sessionId)
1160
+ if (!events) return null
1161
+ const event = events.find((item) => item && item.seq === seq)
1162
+ if (!event) return null
1163
+ const block = readMessageBlocks(event)[index]
1164
+ if (!block || typeof block !== 'object') return null
1165
+ const ref = block.attachment
1166
+ if (!ref || typeof ref !== 'object' || typeof ref.attachmentId !== 'string' || ref.attachmentId === '') return null
1167
+ if (typeof ref.bytes === 'number' && ref.bytes > MAX_PREVIEW_BYTES) return null
1168
+ const store = resolveAttachmentStore(ctx)
1169
+ if (store === null) return null
1170
+ if (typeof store.readImage === 'function') {
1171
+ try {
1172
+ const stored = await store.readImage(ref)
1173
+ if (stored && stored.data) {
1174
+ const mediaType = stored.ref && typeof stored.ref.mediaType === 'string' ? stored.ref.mediaType : ref.mediaType
1175
+ return { data: Buffer.from(stored.data), mediaType: typeof mediaType === 'string' ? mediaType : undefined }
1176
+ }
1177
+ } catch {
1178
+ // Not an image reference; a stored file is read below.
1179
+ }
1180
+ }
1181
+ if (typeof store.fileHostPath === 'function') {
1182
+ try {
1183
+ const path = store.fileHostPath(ref)
1184
+ if (typeof path === 'string' && path !== '') return { data: await readFile(path), mediaType: undefined }
1185
+ } catch {
1186
+ // The reference resolves to nothing this deployment can serve.
1187
+ }
1188
+ }
1189
+ return null
1190
+ }
1191
+
730
1192
  // --- operations --------------------------------------------------------------
731
1193
 
732
1194
  async function stateOf(ctx, sessionId, config) {
@@ -738,7 +1200,11 @@ async function stateOf(ctx, sessionId, config) {
738
1200
  const folded = foldSurface(events)
739
1201
  const ledger = rollbackLedger(events)
740
1202
  const live = findLiveSession(ctx, sessionId)
741
- const turns = editableTurns(events, folded.nodes)
1203
+ // Probed per request: the editor offers what THIS deployment can do right
1204
+ // now, so a store that is unmounted (or not yet mounted) cannot leave the
1205
+ // editor promising to keep blocks the host would have to drop.
1206
+ const store = resolveAttachmentStore(ctx)
1207
+ const turns = editableTurns(events, folded.nodes, { preview: canPreviewAttachments(store) })
742
1208
  const replies = editableReplies(events, folded.nodes)
743
1209
  noteRequest({
744
1210
  kind: 'state',
@@ -764,6 +1230,11 @@ async function stateOf(ctx, sessionId, config) {
764
1230
  live: Boolean(live),
765
1231
  busy: isBusy(events),
766
1232
  lastSeq: events.length > 0 ? events[events.length - 1].seq : -1,
1233
+ // What this deployment can do with a block list. `attachments` is the
1234
+ // admission path (a chip the editor may keep) and `preview` is the read
1235
+ // path (a chip that may draw its own thumbnail); both are answered from a
1236
+ // live service probe, so the editor never promises what the host cannot do.
1237
+ capabilities: { attachments: store !== null, preview: canPreviewAttachments(store) },
767
1238
  config: { confirm: config.confirm },
768
1239
  version: PLUGIN_VERSION,
769
1240
  }
@@ -775,9 +1246,13 @@ async function stateOf(ctx, sessionId, config) {
775
1246
  * @param plan - the planned window.
776
1247
  * @param lastTurn - highest turn number in the log, for a coherent bracket.
777
1248
  * @param config - resolved deployment policy.
1249
+ * @param content - the block list the revised prompt carries. Omitted, the
1250
+ * carrier falls back to the marker text alone, which is what every caller
1251
+ * that predates block-preserving edits (and the silent developer carrier)
1252
+ * still asks for.
778
1253
  * @returns `{ type, data }` for `session.append`.
779
1254
  */
780
- export function buildCarrier(plan, lastTurn, config) {
1255
+ export function buildCarrier(plan, lastTurn, config, content) {
781
1256
  const silent = config.carrier !== 'user/message'
782
1257
  if (silent) {
783
1258
  // The silent carrier is an empty message that projects to no model message.
@@ -807,7 +1282,7 @@ export function buildCarrier(plan, lastTurn, config) {
807
1282
  data: {
808
1283
  id: randomUUID(),
809
1284
  role: 'user',
810
- content: [{ type: 'text', text: config.markerText }],
1285
+ content: Array.isArray(content) && content.length > 0 ? content : [{ type: 'text', text: config.markerText }],
811
1286
  // `kind: 'user'`, NOT a plugin-owned kind. This carrier stands in a human's
812
1287
  // place - it IS the wording the user is asking with now - and the platform
813
1288
  // owns that judgement: a human prompt is exactly a `user/message` whose
@@ -898,7 +1373,25 @@ async function applyEdit(ctx, sessionId, body, config) {
898
1373
  if (!surfaceNodes.includes(seq)) throw new HttpError(409, 'stale', 'the session changed, retry')
899
1374
  }
900
1375
 
901
- // Nothing to write when the revision IS the live text.
1376
+ // The submitted block list, when the browser half sent one: which of the
1377
+ // message's blocks the user kept, in order, plus anything they just picked.
1378
+ // A caller that sends `text` only is a client from before blocks travelled -
1379
+ // a supported caller, not a broken one: it gets exactly the text-only carrier
1380
+ // this plugin has always written, and the answer says what that cost.
1381
+ let parts = plan.mode === 'prompt' && Array.isArray(body.parts) ? normalizeParts(body.parts, plan.blocks.length) : null
1382
+ let layout = parts === null ? null : planBlockLayout(plan.blocks, parts)
1383
+ // What this deployment can admit, probed NOW rather than remembered: with no
1384
+ // store mounted, nothing can be uploaded and no block may be promised, so the
1385
+ // edit degrades to the text-only carrier - the exact behaviour every release
1386
+ // before this one had - and the answer reports the blocks it had to leave
1387
+ // behind instead of writing a reference nothing can resolve.
1388
+ const store = plan.mode === 'prompt' ? resolveAttachmentStore(ctx) : null
1389
+ if (layout !== null && store === null) {
1390
+ parts = null
1391
+ layout = null
1392
+ }
1393
+
1394
+ // Nothing to write when the revision changes nothing.
902
1395
  //
903
1396
  // The editor opens prefilled with exactly this text - it comes from
904
1397
  // `readMessageText`, the same parse behind `/state`'s `turns[].text` and
@@ -908,6 +1401,14 @@ async function applyEdit(ctx, sessionId, body, config) {
908
1401
  // unchanged save used to add a revision with no revision behind it and a whole
909
1402
  // turn to the rail while the conversation had not moved at all.
910
1403
  //
1404
+ // Since blocks travel, the same question is asked of the whole message: the
1405
+ // carrier is rebuilt from the submitted list and compared against the original
1406
+ // block for block (see sameJson), so a save that keeps every block where it was
1407
+ // writes nothing either. A caller that sends text only keeps the original,
1408
+ // narrower test - its carrier IS text-only by construction, and comparing that
1409
+ // against a message with a picture in it would turn "I changed nothing" into a
1410
+ // write that drops the picture.
1411
+ //
911
1412
  // Compared verbatim, deliberately, which is the same caliber the editor was
912
1413
  // filled with: a trailing space is a real difference and keeps the old
913
1414
  // behaviour, and nothing here trims (the only trim in this function is the
@@ -917,7 +1418,9 @@ async function applyEdit(ctx, sessionId, body, config) {
917
1418
  // first and re-runs regardless, and its whole point is that the user wants the
918
1419
  // turn regenerated even when the wording is what it already was. So the caller
919
1420
  // decides what happens next, and this returns before anything is appended.
920
- if (text === plan.original) {
1421
+ const keepsEveryBlock = parts !== null && layout !== null && layout.added === 0 &&
1422
+ sameJson(assembleBlocks(plan.blocks, layout, [], text), plan.blocks)
1423
+ if (text === plan.original && (parts === null || keepsEveryBlock)) {
921
1424
  return {
922
1425
  kind: plan.mode,
923
1426
  applied: false,
@@ -926,9 +1429,30 @@ async function applyEdit(ctx, sessionId, body, config) {
926
1429
  // hide a row on account of this answer.
927
1430
  shadowed: [],
928
1431
  original: plan.original,
1432
+ // Nothing was written, so nothing was left behind either - and the
1433
+ // message's blocks are the ones it already had.
1434
+ dropped: false,
1435
+ blocks: blockSummaries(plan.blocks, canPreviewAttachments(store)),
929
1436
  }
930
1437
  }
931
1438
 
1439
+ // Admission happens here, BEFORE the first append and outside the try blocks
1440
+ // below: a refused upload must not be able to leave a rollback (or a synthetic
1441
+ // turn) behind in the log. The blocks come back in slot order, which is the
1442
+ // order the layout put them in.
1443
+ const admitted = []
1444
+ if (layout !== null) {
1445
+ for (const slot of layout.slots) {
1446
+ if (slot.kind === 'add') admitted.push(await admitUpload(store, slot.upload))
1447
+ }
1448
+ }
1449
+ const content = layout === null ? [{ type: 'text', text }] : assembleBlocks(plan.blocks, layout, admitted, text)
1450
+ // Whether this save left non-text blocks behind, told to the caller instead of
1451
+ // being discovered later: true for a reply (its tool calls and reasoning
1452
+ // cannot survive a replacement) and true for a message rewritten without its
1453
+ // blocks. False whenever the carrier really did carry them.
1454
+ const dropped = layout === null && plan.blocks.some((block) => !isTextBlock(block))
1455
+
932
1456
  // A revised prompt has to (a) replace the old wording in the model context and
933
1457
  // (b) NOT be answered. The platform answers any user message it finds on the
934
1458
  // surface, so the revision cannot be *appended* - appending one made the loop
@@ -941,7 +1465,7 @@ async function applyEdit(ctx, sessionId, body, config) {
941
1465
  // (it projects `lastTurn` from every turn/start it observes). A prompt edit
942
1466
  // does not: `user/message` carries the revision and needs no turn.
943
1467
  const editTurn = plan.mode === 'reply' ? nextTurnOf(events) : lastTurnOf(events)
944
- const carrier = buildCarrier(plan, editTurn, plan.mode === 'prompt' ? { ...config, carrier: 'user/message', markerText: text } : config)
1468
+ const carrier = buildCarrier(plan, editTurn, plan.mode === 'prompt' ? { ...config, carrier: 'user/message', markerText: text } : config, content)
945
1469
  if (plan.mode === 'reply') {
946
1470
  try {
947
1471
  session.append('turn/start', { turn: editTurn })
@@ -952,6 +1476,7 @@ async function applyEdit(ctx, sessionId, body, config) {
952
1476
  applied: false,
953
1477
  applyError: `could not open a turn for the correction: ${String((error && error.message) || error)}`,
954
1478
  shadowed: plan.shadowed,
1479
+ dropped,
955
1480
  }
956
1481
  }
957
1482
  }
@@ -998,6 +1523,7 @@ async function applyEdit(ctx, sessionId, body, config) {
998
1523
  applied: false,
999
1524
  applyError: `the corrected reply was refused: ${String((error && error.message) || error)}`,
1000
1525
  shadowed: plan.shadowed,
1526
+ dropped,
1001
1527
  ...flush,
1002
1528
  }
1003
1529
  }
@@ -1022,6 +1548,7 @@ async function applyEdit(ctx, sessionId, body, config) {
1022
1548
  applied: true,
1023
1549
  loopTurn,
1024
1550
  shadowed: plan.shadowed,
1551
+ dropped,
1025
1552
  ...corrected,
1026
1553
  }
1027
1554
  }
@@ -1037,6 +1564,12 @@ async function applyEdit(ctx, sessionId, body, config) {
1037
1564
  replacementSeq: replacement.seq,
1038
1565
  applied: true,
1039
1566
  shadowed: plan.shadowed,
1567
+ dropped,
1568
+ // What the carrier now carries, described exactly the way /state describes
1569
+ // it. The browser half adopts this for the row it re-draws before its own
1570
+ // refresh lands - without it a second save could not know which blocks are
1571
+ // still there and would rewrite the message text-only.
1572
+ blocks: blockSummaries(content, canPreviewAttachments(store)),
1040
1573
  ...flush,
1041
1574
  }
1042
1575
  }
@@ -1143,16 +1676,32 @@ function sendJson(res, status, body) {
1143
1676
  res.end(payload)
1144
1677
  }
1145
1678
 
1146
- function readBody(req) {
1679
+ // The body limit is the upload limit: a revised message with blocks arrives as
1680
+ // base64 inside this JSON, so /apply asks for a larger ceiling than the tiny
1681
+ // default. The read is refused rather than truncated, and the socket keeps
1682
+ // draining so the refusal can still be answered.
1683
+ function readBody(req, limit = 1e6) {
1147
1684
  return new Promise((resolve, reject) => {
1148
1685
  let data = ''
1686
+ let refused = false
1149
1687
  req.on('data', (chunk) => {
1688
+ if (refused) return
1150
1689
  data += chunk
1151
- if (data.length > 1e6) req.destroy()
1690
+ if (data.length > limit) {
1691
+ refused = true
1692
+ data = ''
1693
+ reject(new HttpError(413, 'invalid', 'the request body is too large'))
1694
+ }
1695
+ })
1696
+ req.on('end', () => {
1697
+ if (!refused) resolve(data)
1698
+ })
1699
+ req.on('error', (error) => {
1700
+ if (!refused) reject(error)
1701
+ })
1702
+ req.on('aborted', () => {
1703
+ if (!refused) reject(new Error('aborted'))
1152
1704
  })
1153
- req.on('end', () => resolve(data))
1154
- req.on('error', reject)
1155
- req.on('aborted', () => reject(new Error('aborted')))
1156
1705
  })
1157
1706
  }
1158
1707
 
@@ -1238,6 +1787,49 @@ export function apply(ctx, config) {
1238
1787
  }),
1239
1788
  )
1240
1789
 
1790
+ // The bytes behind one block, for the editor's chips. Read-only, loopback
1791
+ // and same-origin like everything else here, and answered only for a block
1792
+ // the session's own log cites.
1793
+ fiber.effect(() =>
1794
+ webServer.register({
1795
+ kind: 'exact',
1796
+ path: `${ROUTE_PREFIX}/attachment`,
1797
+ handler: async (req, res) => {
1798
+ if (!guard(req, res)) return
1799
+ if (req.method !== 'GET') {
1800
+ sendJson(res, 405, { ok: false, code: 'method', error: 'GET only' })
1801
+ return
1802
+ }
1803
+ try {
1804
+ const url = new URL(req.url, 'http://localhost')
1805
+ const sessionId = requireSessionId((url.searchParams.get('sessionId') || '').trim())
1806
+ const seq = Number(url.searchParams.get('seq'))
1807
+ const index = Number(url.searchParams.get('index'))
1808
+ if (!Number.isInteger(seq) || seq < 0 || !Number.isInteger(index) || index < 0) {
1809
+ throw new HttpError(400, 'invalid', 'seq and index must be whole numbers')
1810
+ }
1811
+ const found = await attachmentBytes(ctx, sessionId, seq, index)
1812
+ if (found === null) {
1813
+ // Deliberately without a code: the editor translates codes into
1814
+ // sentences, and "this chip has no picture" is not one the user
1815
+ // needs to read - the chip simply draws its label.
1816
+ sendJson(res, 404, { ok: false, error: 'no attachment bytes for this block' })
1817
+ return
1818
+ }
1819
+ res.writeHead(200, {
1820
+ 'content-type': typeof found.mediaType === 'string' && found.mediaType !== '' ? found.mediaType : 'application/octet-stream',
1821
+ 'content-length': found.data.byteLength,
1822
+ // The stored object is immutable and content-addressed.
1823
+ 'cache-control': 'private, max-age=600',
1824
+ })
1825
+ res.end(found.data)
1826
+ } catch (error) {
1827
+ failure(res, error)
1828
+ }
1829
+ },
1830
+ }),
1831
+ )
1832
+
1241
1833
  // Read-only diagnostics. The browser half's own state cannot be observed
1242
1834
  // from here, so the next best thing is an honest record of what its requests
1243
1835
  // asked for and what they got back.
@@ -1266,9 +1858,20 @@ export function apply(ctx, config) {
1266
1858
  sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
1267
1859
  return
1268
1860
  }
1861
+ let raw
1862
+ try {
1863
+ // A block list travels as base64 inside this body, so the ceiling is
1864
+ // the upload ceiling (see MAX_BODY_BYTES) - and an oversized body is
1865
+ // reported as such instead of being read as far as it goes.
1866
+ raw = await readBody(req, MAX_BODY_BYTES)
1867
+ } catch (error) {
1868
+ const status = error instanceof HttpError ? error.status : 400
1869
+ const detail = error instanceof HttpError ? error.message : 'malformed JSON body'
1870
+ sendJson(res, status, { ok: false, code: 'invalid', error: detail })
1871
+ return
1872
+ }
1269
1873
  let body = {}
1270
1874
  try {
1271
- const raw = await readBody(req)
1272
1875
  if (raw) body = JSON.parse(raw)
1273
1876
  } catch {
1274
1877
  sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
@@ -1292,6 +1895,10 @@ export function apply(ctx, config) {
1292
1895
  // shows nothing for it, so this ring is the only place that records
1293
1896
  // "the user pressed save and nothing needed saving".
1294
1897
  unchanged: result.unchanged === true,
1898
+ // Whether non-text blocks were left behind by this save (see
1899
+ // applyEdit). The log cannot show this: the carrier is one event
1900
+ // either way.
1901
+ dropped: result.dropped === true,
1295
1902
  })
1296
1903
  sendJson(res, 200, { ok: true, ...result })
1297
1904
  } catch (error) {