dsh-edit-turn 0.2.21 → 0.3.1
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/README.md +92 -25
- package/lib/client.js +516 -13
- package/lib/index.js +232 -31
- package/lib/patch.js +260 -0
- package/package.json +11 -7
package/lib/index.js
CHANGED
|
@@ -51,6 +51,17 @@ import { randomUUID } from 'node:crypto'
|
|
|
51
51
|
import { readFile } from 'node:fs/promises'
|
|
52
52
|
import { createRequire } from 'node:module'
|
|
53
53
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
54
|
+
import * as sessionApi from '@deepseek-ai/dsh-session'
|
|
55
|
+
import {
|
|
56
|
+
PATCH_TYPE,
|
|
57
|
+
buildPatchData,
|
|
58
|
+
editableBlocks,
|
|
59
|
+
kindOfBlock,
|
|
60
|
+
patchList,
|
|
61
|
+
patchProjection,
|
|
62
|
+
patchesOf,
|
|
63
|
+
projectedBlocks,
|
|
64
|
+
} from './patch.js'
|
|
54
65
|
|
|
55
66
|
const nodeRequire = createRequire(import.meta.url)
|
|
56
67
|
const schemaPackage = (() => {
|
|
@@ -69,7 +80,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
|
|
|
69
80
|
export const name = PLUGIN_ID
|
|
70
81
|
|
|
71
82
|
/** Keep in sync with package.json and lib/client.js. */
|
|
72
|
-
export const PLUGIN_VERSION = '0.
|
|
83
|
+
export const PLUGIN_VERSION = '0.3.1'
|
|
73
84
|
|
|
74
85
|
// A ring of the last few requests, answering "did the browser half even ask, and
|
|
75
86
|
// what did it get?" without needing its console. Read-only diagnostics: the
|
|
@@ -145,6 +156,14 @@ export const Config =
|
|
|
145
156
|
* step.
|
|
146
157
|
*/
|
|
147
158
|
confirm: z.boolean().default(false),
|
|
159
|
+
/**
|
|
160
|
+
* How a reply edit lands. `auto` uses the in-place plugin-record
|
|
161
|
+
* projection when the host exposes it and falls back to the historical
|
|
162
|
+
* replay splice otherwise; `patch` refuses to run without the
|
|
163
|
+
* projection; `replay` always uses the old path. Prompt edits always use
|
|
164
|
+
* the official surface replacement.
|
|
165
|
+
*/
|
|
166
|
+
replyMode: z.string().default('auto'),
|
|
148
167
|
})
|
|
149
168
|
|
|
150
169
|
// ---------------------------------------------------------------------------
|
|
@@ -768,7 +787,7 @@ function resolveTarget(events, request) {
|
|
|
768
787
|
* @returns `{ targetSeq, messageId, startSeq, endSeq, shadowed, mode, turn, step, provider, model, original, attachments, replayFrom, logTo, turnStart, turnEnd }`.
|
|
769
788
|
* @throws {EditPlanError} with a stable code when the target cannot be planned.
|
|
770
789
|
*/
|
|
771
|
-
export function planRollback(events, surfaceNodes, request) {
|
|
790
|
+
export function planRollback(events, surfaceNodes, request, options = {}) {
|
|
772
791
|
const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
|
|
773
792
|
const turnOf = turnIndex(events)
|
|
774
793
|
const target = resolveTarget(events, request, turnOf)
|
|
@@ -781,12 +800,56 @@ export function planRollback(events, surfaceNodes, request) {
|
|
|
781
800
|
throw new EditPlanError('not-editable', 'the system prompt head cannot be edited')
|
|
782
801
|
}
|
|
783
802
|
// Two editable kinds. A prompt is rewritten in place and the conversation
|
|
784
|
-
// continues; a reply is
|
|
785
|
-
//
|
|
803
|
+
// continues; a reply is either patched in place through the message projection
|
|
804
|
+
// or, on a host without it, replaced by an appended correction and a replay.
|
|
786
805
|
const mode = isHumanPrompt(target) ? 'prompt' : isAssistantReply(target) ? 'reply' : null
|
|
787
806
|
if (mode === null) {
|
|
788
807
|
throw new EditPlanError('not-editable', 'only your own messages and the model replies can be edited')
|
|
789
808
|
}
|
|
809
|
+
const turn = turnOf.get(target.seq)
|
|
810
|
+
const data = target.data && typeof target.data === 'object' ? target.data : {}
|
|
811
|
+
const message = data.message && typeof data.message === 'object' ? data.message : data
|
|
812
|
+
const source = message.source && typeof message.source === 'object' ? message.source : null
|
|
813
|
+
const step = typeof data.step === 'number' ? data.step : 1
|
|
814
|
+
const contentBlocks = readMessageBlocks(target)
|
|
815
|
+
|
|
816
|
+
// The in-place path: one block of the reply, rewritten by a plugin record and
|
|
817
|
+
// its projection. Nothing leaves the surface, so there is no window and no
|
|
818
|
+
// replay; the tool calls and the other blocks stay exactly as recorded.
|
|
819
|
+
if (mode === 'reply' && options.patch === true) {
|
|
820
|
+
const patches = options.patches instanceof Map ? options.patches : new Map()
|
|
821
|
+
const blocks = projectedBlocks(message, target.seq, patches)
|
|
822
|
+
if (blocks.length === 0) {
|
|
823
|
+
throw new EditPlanError('not-editable', 'this reply has no editable block')
|
|
824
|
+
}
|
|
825
|
+
const wanted = typeof request.blockIndex === 'number'
|
|
826
|
+
? blocks.find((block) => block.index === request.blockIndex)
|
|
827
|
+
: blocks.find((block) => block.kind === 'text') ?? blocks[0]
|
|
828
|
+
if (wanted === undefined) throw new EditPlanError('not-editable', 'the requested block does not exist')
|
|
829
|
+
if (typeof request.kind === 'string' && request.kind !== wanted.kind) {
|
|
830
|
+
throw new EditPlanError('not-editable', 'the requested block changed kind')
|
|
831
|
+
}
|
|
832
|
+
return {
|
|
833
|
+
targetSeq: target.seq,
|
|
834
|
+
messageId: messageIdOf(target),
|
|
835
|
+
mode: 'reply',
|
|
836
|
+
patch: true,
|
|
837
|
+
startSeq: null,
|
|
838
|
+
endSeq: null,
|
|
839
|
+
shadowed: [],
|
|
840
|
+
blockIndex: wanted.index,
|
|
841
|
+
kind: wanted.kind,
|
|
842
|
+
original: wanted.text,
|
|
843
|
+
// `blocks` here are the EDITOR's blocks (index/kind/text/original), not
|
|
844
|
+
// the raw content array; patch mode never rebuilds a message carrier.
|
|
845
|
+
blocks,
|
|
846
|
+
turn: typeof turn === 'number' ? turn : null,
|
|
847
|
+
step,
|
|
848
|
+
provider: source && typeof source.provider === 'string' ? source.provider : null,
|
|
849
|
+
model: source && typeof source.model === 'string' ? source.model : null,
|
|
850
|
+
attachments: contentBlocks.filter((block) => kindOfBlock(block) === null).length,
|
|
851
|
+
}
|
|
852
|
+
}
|
|
790
853
|
const startIdx = nodeIndex.get(target.seq)
|
|
791
854
|
// Two different windows, because the two edits mean different things.
|
|
792
855
|
//
|
|
@@ -842,11 +905,7 @@ export function planRollback(events, surfaceNodes, request) {
|
|
|
842
905
|
const { text, attachments } = readMessageText(target)
|
|
843
906
|
// The blocks themselves travel with the plan: the carrier is rebuilt from
|
|
844
907
|
// them, so no caller has to re-read the message to know what it carries.
|
|
845
|
-
const blocks =
|
|
846
|
-
const turn = turnOf.get(target.seq)
|
|
847
|
-
const data = target.data && typeof target.data === 'object' ? target.data : {}
|
|
848
|
-
const message = data.message && typeof data.message === 'object' ? data.message : data
|
|
849
|
-
const source = message.source && typeof message.source === 'object' ? message.source : null
|
|
908
|
+
const blocks = contentBlocks
|
|
850
909
|
return {
|
|
851
910
|
targetSeq: target.seq,
|
|
852
911
|
messageId: messageIdOf(target),
|
|
@@ -854,10 +913,11 @@ export function planRollback(events, surfaceNodes, request) {
|
|
|
854
913
|
endSeq: shadowed[shadowed.length - 1],
|
|
855
914
|
shadowed,
|
|
856
915
|
mode,
|
|
916
|
+
patch: false,
|
|
857
917
|
turn: typeof turn === 'number' ? turn : null,
|
|
858
918
|
// The appended correction is keyed by turn:step in the client's reducer, so a
|
|
859
919
|
// missing step would make two corrections collide on "undefined:undefined".
|
|
860
|
-
step
|
|
920
|
+
step,
|
|
861
921
|
provider: source && typeof source.provider === 'string' ? source.provider : null,
|
|
862
922
|
model: source && typeof source.model === 'string' ? source.model : null,
|
|
863
923
|
original: text,
|
|
@@ -1179,7 +1239,20 @@ export function editableTurns(events, surfaceNodes, options = {}) {
|
|
|
1179
1239
|
if (!event || !isHumanPrompt(event)) continue
|
|
1180
1240
|
if (seq === head) continue
|
|
1181
1241
|
const { text, attachments } = readMessageText(event)
|
|
1182
|
-
|
|
1242
|
+
// A replacement carrier is appended after its original turn's bracket has
|
|
1243
|
+
// closed, so turnIndex cannot name its turn. It still stands at that
|
|
1244
|
+
// prompt's position in the conversation: inherit the turn from the event
|
|
1245
|
+
// it replaced, or the editor's turn list loses the row (turn: null).
|
|
1246
|
+
let turn = turnOf.get(seq)
|
|
1247
|
+
if (typeof turn !== 'number' && Array.isArray(event.sourceEventSeqs)) {
|
|
1248
|
+
for (const source of event.sourceEventSeqs) {
|
|
1249
|
+
const inherited = turnOf.get(source)
|
|
1250
|
+
if (typeof inherited === 'number') {
|
|
1251
|
+
turn = inherited
|
|
1252
|
+
break
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1183
1256
|
out.push({
|
|
1184
1257
|
seq,
|
|
1185
1258
|
turn: typeof turn === 'number' ? turn : null,
|
|
@@ -1197,34 +1270,48 @@ export function editableTurns(events, surfaceNodes, options = {}) {
|
|
|
1197
1270
|
/**
|
|
1198
1271
|
* Every model reply that can currently be rewritten.
|
|
1199
1272
|
*
|
|
1200
|
-
*
|
|
1201
|
-
*
|
|
1202
|
-
*
|
|
1203
|
-
*
|
|
1273
|
+
* In patch mode (the in-place projection path) a reply is offered when it has
|
|
1274
|
+
* any editable block - output text or a reasoning/thinking block - and the
|
|
1275
|
+
* blocks travel with the entry so the editor can pick one. In the legacy replay
|
|
1276
|
+
* mode only text is editable, and a reply without text is skipped.
|
|
1204
1277
|
*
|
|
1205
1278
|
* @param events - complete contiguous raw event log.
|
|
1206
1279
|
* @param surfaceNodes - current surface seqs in model order.
|
|
1280
|
+
* @param options - `{ patches, patch }`: landed patch overlays and whether the
|
|
1281
|
+
* deployment can write a patch record.
|
|
1207
1282
|
* @returns one entry per editable reply, in conversation order.
|
|
1208
1283
|
*/
|
|
1209
|
-
export function editableReplies(events, surfaceNodes) {
|
|
1284
|
+
export function editableReplies(events, surfaceNodes, options = {}) {
|
|
1210
1285
|
const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
|
|
1211
1286
|
const turnOf = turnIndex(events)
|
|
1212
1287
|
const head = surfaceNodes[0]
|
|
1288
|
+
const patches = options.patches instanceof Map ? options.patches : new Map()
|
|
1289
|
+
const patch = options.patch === true
|
|
1213
1290
|
const out = []
|
|
1214
1291
|
for (const event of events) {
|
|
1215
1292
|
if (!isAssistantReply(event)) continue
|
|
1216
1293
|
if (!nodeIndex.has(event.seq)) continue
|
|
1217
1294
|
if (event.seq === head) continue
|
|
1218
|
-
const
|
|
1219
|
-
|
|
1295
|
+
const data = event.data && typeof event.data === 'object' ? event.data : {}
|
|
1296
|
+
const message = data.message && typeof data.message === 'object' ? data.message : data
|
|
1297
|
+
const blocks = projectedBlocks(message, event.seq, patches)
|
|
1298
|
+
if (blocks.length === 0) continue
|
|
1299
|
+
const text = blocks.filter((block) => block.kind === 'text').map((block) => block.text).join('\n')
|
|
1300
|
+
if (!patch && text.trim() === '') continue
|
|
1301
|
+
if (patch && blocks.every((block) => block.text.trim() === '')) continue
|
|
1220
1302
|
const turn = turnOf.get(event.seq)
|
|
1221
1303
|
out.push({
|
|
1222
1304
|
seq: event.seq,
|
|
1223
1305
|
turn: typeof turn === 'number' ? turn : null,
|
|
1224
1306
|
messageId: messageIdOf(event) ?? null,
|
|
1225
1307
|
text,
|
|
1226
|
-
// For a reply these are the
|
|
1227
|
-
|
|
1308
|
+
// For a reply these are the blocks a text-only rewrite would drop:
|
|
1309
|
+
// tool calls and any other non-editable content.
|
|
1310
|
+
attachments: readMessageBlocks(event).filter((block) => patch ? kindOfBlock(block) === null : !isTextBlock(block)).length,
|
|
1311
|
+
patched: blocks.some((block) => block.patched),
|
|
1312
|
+
// Present only where the editor can target one: the replay path can only
|
|
1313
|
+
// replace the whole reply, so it has no block picker.
|
|
1314
|
+
...(patch ? { blocks } : {}),
|
|
1228
1315
|
})
|
|
1229
1316
|
}
|
|
1230
1317
|
return out
|
|
@@ -1570,13 +1657,21 @@ async function stateOf(ctx, sessionId, config) {
|
|
|
1570
1657
|
}
|
|
1571
1658
|
const folded = foldSurface(events)
|
|
1572
1659
|
const ledger = rollbackLedger(events)
|
|
1660
|
+
const patches = patchesOf(events)
|
|
1661
|
+
// A patch whose target has been retired (a later rerun shadowed it) is inert:
|
|
1662
|
+
// the projection skips it and no row can show it. Reporting it in /state made
|
|
1663
|
+
// the browser draw a patch that could never land, and made "which patches are
|
|
1664
|
+
// live" ambiguous. Keep the internal map for the replies; publish the active
|
|
1665
|
+
// subset only.
|
|
1666
|
+
const surfaceSet = new Set(folded.nodes)
|
|
1667
|
+
const activePatches = new Map([...patches].filter(([seq]) => surfaceSet.has(seq)))
|
|
1573
1668
|
const live = findLiveSession(ctx, sessionId)
|
|
1574
1669
|
// Probed per request: the editor offers what THIS deployment can do right
|
|
1575
1670
|
// now, so a store that is unmounted (or not yet mounted) cannot leave the
|
|
1576
1671
|
// editor promising to keep blocks the host would have to drop.
|
|
1577
1672
|
const store = resolveAttachmentStore(ctx)
|
|
1578
1673
|
const turns = editableTurns(events, folded.nodes, { preview: canPreviewAttachments(store) })
|
|
1579
|
-
const replies = editableReplies(events, folded.nodes)
|
|
1674
|
+
const replies = editableReplies(events, folded.nodes, { patches, patch: config.patch === true })
|
|
1580
1675
|
noteRequest({
|
|
1581
1676
|
kind: 'state',
|
|
1582
1677
|
sessionId,
|
|
@@ -1598,6 +1693,11 @@ async function stateOf(ctx, sessionId, config) {
|
|
|
1598
1693
|
surface: folded.nodes,
|
|
1599
1694
|
turns,
|
|
1600
1695
|
replies,
|
|
1696
|
+
// The landed in-place patches, latest per block. The browser half uses them
|
|
1697
|
+
// to draw the projected text in place of the recorded one; the model already
|
|
1698
|
+
// sees them through the host projection.
|
|
1699
|
+
patches: patchList(activePatches),
|
|
1700
|
+
mode: config.patch === true ? 'patch' : 'replay',
|
|
1601
1701
|
live: Boolean(live),
|
|
1602
1702
|
busy: isBusy(events),
|
|
1603
1703
|
lastSeq: events.length > 0 ? events[events.length - 1].seq : -1,
|
|
@@ -1606,7 +1706,7 @@ async function stateOf(ctx, sessionId, config) {
|
|
|
1606
1706
|
// path (a chip that may draw its own thumbnail); both are answered from a
|
|
1607
1707
|
// live service probe, so the editor never promises what the host cannot do.
|
|
1608
1708
|
capabilities: { attachments: store !== null, preview: canPreviewAttachments(store) },
|
|
1609
|
-
config: { confirm: config.confirm },
|
|
1709
|
+
config: { confirm: config.confirm, replyMode: config.replyMode, patch: config.patch === true },
|
|
1610
1710
|
version: PLUGIN_VERSION,
|
|
1611
1711
|
}
|
|
1612
1712
|
}
|
|
@@ -1723,13 +1823,17 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1723
1823
|
if (isBusy(events)) throw new HttpError(409, 'busy', 'the session is still working')
|
|
1724
1824
|
|
|
1725
1825
|
const surfaceNodes = surfaceOf(ctx, sessionId, events)
|
|
1826
|
+
const patches = patchesOf(events)
|
|
1827
|
+
const patchMode = config.patch === true
|
|
1726
1828
|
let plan
|
|
1727
1829
|
try {
|
|
1728
1830
|
plan = planRollback(events, surfaceNodes, {
|
|
1729
1831
|
seq: typeof body.seq === 'number' ? body.seq : undefined,
|
|
1730
1832
|
messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
|
|
1731
1833
|
turn: typeof body.turn === 'number' ? body.turn : undefined,
|
|
1732
|
-
|
|
1834
|
+
blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
|
|
1835
|
+
kind: typeof body.kind === 'string' ? body.kind : undefined,
|
|
1836
|
+
}, { patches, patch: patchMode })
|
|
1733
1837
|
} catch (error) {
|
|
1734
1838
|
if (error instanceof EditPlanError) {
|
|
1735
1839
|
const status = error.code === 'not-editable' ? 400 : 409
|
|
@@ -1738,6 +1842,56 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1738
1842
|
throw error
|
|
1739
1843
|
}
|
|
1740
1844
|
|
|
1845
|
+
// The in-place reply path: one small plugin record, interpreted by this
|
|
1846
|
+
// plugin's projection. Nothing leaves the surface, so there is no window to
|
|
1847
|
+
// check, no turn to open and no tail to replay. The record is append-only and
|
|
1848
|
+
// the original event keeps its bytes; the model sees the patched block on the
|
|
1849
|
+
// next fold.
|
|
1850
|
+
if (plan.patch === true) {
|
|
1851
|
+
if (typeof sessionApi.appendPluginRecord !== 'function') {
|
|
1852
|
+
throw new HttpError(409, 'unsupported', 'this harness does not expose plugin records; reply edits need the replay path')
|
|
1853
|
+
}
|
|
1854
|
+
if (text === plan.original) {
|
|
1855
|
+
return {
|
|
1856
|
+
kind: 'reply',
|
|
1857
|
+
applied: false,
|
|
1858
|
+
unchanged: true,
|
|
1859
|
+
patch: true,
|
|
1860
|
+
shadowed: [],
|
|
1861
|
+
blockIndex: plan.blockIndex,
|
|
1862
|
+
kindOfBlock: plan.kind,
|
|
1863
|
+
original: plan.original,
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
let recordSeq
|
|
1867
|
+
try {
|
|
1868
|
+
recordSeq = sessionApi.appendPluginRecord(
|
|
1869
|
+
session,
|
|
1870
|
+
PATCH_TYPE,
|
|
1871
|
+
buildPatchData([{ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }]),
|
|
1872
|
+
)
|
|
1873
|
+
} catch (error) {
|
|
1874
|
+
throw new HttpError(409, 'stale', `the session refused the patch record: ${String((error && error.message) || error)}`)
|
|
1875
|
+
}
|
|
1876
|
+
const flush = await flushSession(ctx, session)
|
|
1877
|
+
return {
|
|
1878
|
+
kind: 'reply',
|
|
1879
|
+
applied: true,
|
|
1880
|
+
mode: 'patch',
|
|
1881
|
+
patch: true,
|
|
1882
|
+
recordSeq,
|
|
1883
|
+
target: {
|
|
1884
|
+
seq: plan.targetSeq,
|
|
1885
|
+
blockIndex: plan.blockIndex,
|
|
1886
|
+
kind: plan.kind,
|
|
1887
|
+
text,
|
|
1888
|
+
original: plan.original,
|
|
1889
|
+
},
|
|
1890
|
+
shadowed: [],
|
|
1891
|
+
...flush,
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1741
1895
|
// The live surface is the append authority; a node that vanished between the
|
|
1742
1896
|
// read and this check means another writer landed first.
|
|
1743
1897
|
for (const seq of plan.shadowed) {
|
|
@@ -2008,14 +2162,14 @@ function sessionIdFromExec(exec) {
|
|
|
2008
2162
|
return undefined
|
|
2009
2163
|
}
|
|
2010
2164
|
|
|
2011
|
-
function registerTool(ctx) {
|
|
2165
|
+
function registerTool(ctx, config) {
|
|
2012
2166
|
const tools = ctx.get('tools')
|
|
2013
2167
|
if (!tools || typeof tools.register !== 'function') return
|
|
2014
2168
|
tools.register(
|
|
2015
2169
|
defineTool({
|
|
2016
2170
|
name: 'edit_turn_targets',
|
|
2017
2171
|
description:
|
|
2018
|
-
'List the user turns and model replies of a DeepSeek Harness session that the edit-turn plugin can rewrite, with each message\'s current text. A prompt is replaced where it stands; a reply correction is appended and
|
|
2172
|
+
'List the user turns and model replies of a DeepSeek Harness session that the edit-turn plugin can rewrite, with each message\'s current text and, for a reply, every editable block (output text and reasoning). A prompt is replaced where it stands; a reply block is patched in place when the host supports projections, otherwise the reply correction is appended and the later turns are replayed behind it. Read-only: it changes nothing.',
|
|
2019
2173
|
parameters: {
|
|
2020
2174
|
sessionId: {
|
|
2021
2175
|
type: 'string',
|
|
@@ -2035,8 +2189,9 @@ function registerTool(ctx) {
|
|
|
2035
2189
|
const events = await readEvents(ctx, sessionId)
|
|
2036
2190
|
if (!events) throw new Error(`no session log for ${sessionId}`)
|
|
2037
2191
|
const nodes = surfaceOf(ctx, sessionId, events)
|
|
2192
|
+
const patches = patchesOf(events)
|
|
2038
2193
|
const turns = editableTurns(events, nodes)
|
|
2039
|
-
const replies = editableReplies(events, nodes)
|
|
2194
|
+
const replies = editableReplies(events, nodes, { patches, patch: config && config.patch === true })
|
|
2040
2195
|
const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.floor(args.limit) : 20
|
|
2041
2196
|
const shown = turns.slice(-limit)
|
|
2042
2197
|
const shownReplies = replies.slice(-limit)
|
|
@@ -2050,10 +2205,13 @@ function registerTool(ctx) {
|
|
|
2050
2205
|
if (turns.length > shown.length) lines.push(`(${turns.length - shown.length} earlier turn(s) omitted)`)
|
|
2051
2206
|
for (const reply of shownReplies) {
|
|
2052
2207
|
const preview = reply.text.length > 160 ? `${reply.text.slice(0, 160)}...` : reply.text
|
|
2053
|
-
|
|
2208
|
+
const blocks = Array.isArray(reply.blocks)
|
|
2209
|
+
? ` [${reply.blocks.map((block) => `${block.index}:${block.kind}${block.patched ? '*' : ''}`).join(', ')}]`
|
|
2210
|
+
: ''
|
|
2211
|
+
lines.push(`- reply (seq ${reply.seq}, ${reply.attachments} non-text part(s))${blocks}: ${preview}`)
|
|
2054
2212
|
}
|
|
2055
2213
|
if (replies.length > shownReplies.length) lines.push(`(${replies.length - shownReplies.length} earlier reply/replies omitted)`)
|
|
2056
|
-
lines.push('editing a turn replaces its wording in place (no model call); editing a reply replaces
|
|
2214
|
+
lines.push('editing a turn replaces its wording in place (no model call); editing a reply block patches it in place when the host supports plugin-record projections, otherwise it replaces the reply and replays the tail. Re-running a turn is dsh-rerun-turn\'s button.')
|
|
2057
2215
|
return lines.join('\n')
|
|
2058
2216
|
},
|
|
2059
2217
|
}),
|
|
@@ -2182,7 +2340,38 @@ export function apply(ctx, config) {
|
|
|
2182
2340
|
: DEFAULT_MARKER,
|
|
2183
2341
|
carrier: config && config.carrier === 'user/message' ? 'user/message' : 'system/message',
|
|
2184
2342
|
confirm: config && typeof config.confirm === 'boolean' ? config.confirm : false,
|
|
2343
|
+
replyMode: config && (config.replyMode === 'replay' || config.replyMode === 'patch') ? config.replyMode : 'auto',
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
// The in-place reply path needs two host seams: a plugin record with the
|
|
2347
|
+
// `ignorable` envelope, and a message projection registry. Both are probed at
|
|
2348
|
+
// load; when either is missing the reply path keeps the official replay
|
|
2349
|
+
// splice, and `replyMode: 'patch'` turns that silence into a load error.
|
|
2350
|
+
const sessionsService = ctx.get('sessions')
|
|
2351
|
+
let patch = false
|
|
2352
|
+
if (
|
|
2353
|
+
resolved.replyMode !== 'replay' &&
|
|
2354
|
+
typeof sessionApi.appendPluginRecord === 'function' &&
|
|
2355
|
+
sessionsService &&
|
|
2356
|
+
typeof sessionsService.registerMessageProjection === 'function'
|
|
2357
|
+
) {
|
|
2358
|
+
try {
|
|
2359
|
+
// A live reload (HMR, a re-applied bundle) leaves the previous
|
|
2360
|
+
// registration in place: the registry rejects a second definition of the
|
|
2361
|
+
// same type. Reuse ours instead of failing back to replay - the
|
|
2362
|
+
// projection is a stateless pure function, so the old instance is exactly
|
|
2363
|
+
// as good as a fresh one.
|
|
2364
|
+
const registry = sessionsService.messageProjections
|
|
2365
|
+
const existing = Array.isArray(registry)
|
|
2366
|
+
? registry.find((entry) => entry && entry.type === PATCH_TYPE)
|
|
2367
|
+
: undefined
|
|
2368
|
+
if (existing === undefined) sessionsService.registerMessageProjection(patchProjection())
|
|
2369
|
+
patch = true
|
|
2370
|
+
} catch (error) {
|
|
2371
|
+
if (resolved.replyMode === 'patch') throw error
|
|
2372
|
+
}
|
|
2185
2373
|
}
|
|
2374
|
+
resolved.patch = patch
|
|
2186
2375
|
|
|
2187
2376
|
const registerRoutes = (webServer, fiber) => {
|
|
2188
2377
|
fiber.effect(() =>
|
|
@@ -2261,7 +2450,19 @@ export function apply(ctx, config) {
|
|
|
2261
2450
|
sendJson(res, 405, { ok: false, code: 'method', error: 'GET only' })
|
|
2262
2451
|
return
|
|
2263
2452
|
}
|
|
2264
|
-
sendJson(res, 200, {
|
|
2453
|
+
sendJson(res, 200, {
|
|
2454
|
+
ok: true,
|
|
2455
|
+
version: PLUGIN_VERSION,
|
|
2456
|
+
// How this deployment resolves a reply edit. `patch` means both
|
|
2457
|
+
// host seams were found and the projection registered; `replay`
|
|
2458
|
+
// means the historical splice is what a save will use.
|
|
2459
|
+
mode: resolved.patch === true ? 'patch' : 'replay',
|
|
2460
|
+
capabilities: {
|
|
2461
|
+
pluginRecords: typeof sessionApi.appendPluginRecord === 'function',
|
|
2462
|
+
projection: resolved.patch === true,
|
|
2463
|
+
},
|
|
2464
|
+
requests: recentRequests(),
|
|
2465
|
+
})
|
|
2265
2466
|
},
|
|
2266
2467
|
}),
|
|
2267
2468
|
)
|
|
@@ -2342,9 +2543,9 @@ export function apply(ctx, config) {
|
|
|
2342
2543
|
|
|
2343
2544
|
const tools = ctx.get('tools')
|
|
2344
2545
|
if (tools) {
|
|
2345
|
-
registerTool(ctx)
|
|
2546
|
+
registerTool(ctx, resolved)
|
|
2346
2547
|
} else {
|
|
2347
|
-
ctx.inject(['tools'], (sub) => registerTool(sub))
|
|
2548
|
+
ctx.inject(['tools'], (sub) => registerTool(sub, resolved))
|
|
2348
2549
|
}
|
|
2349
2550
|
}
|
|
2350
2551
|
|