dsh-edit-turn 0.2.21 → 0.3.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/README.md +82 -25
- package/lib/client.js +401 -13
- package/lib/index.js +211 -30
- 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.0'
|
|
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,
|
|
@@ -1197,34 +1257,48 @@ export function editableTurns(events, surfaceNodes, options = {}) {
|
|
|
1197
1257
|
/**
|
|
1198
1258
|
* Every model reply that can currently be rewritten.
|
|
1199
1259
|
*
|
|
1200
|
-
*
|
|
1201
|
-
*
|
|
1202
|
-
*
|
|
1203
|
-
*
|
|
1260
|
+
* In patch mode (the in-place projection path) a reply is offered when it has
|
|
1261
|
+
* any editable block - output text or a reasoning/thinking block - and the
|
|
1262
|
+
* blocks travel with the entry so the editor can pick one. In the legacy replay
|
|
1263
|
+
* mode only text is editable, and a reply without text is skipped.
|
|
1204
1264
|
*
|
|
1205
1265
|
* @param events - complete contiguous raw event log.
|
|
1206
1266
|
* @param surfaceNodes - current surface seqs in model order.
|
|
1267
|
+
* @param options - `{ patches, patch }`: landed patch overlays and whether the
|
|
1268
|
+
* deployment can write a patch record.
|
|
1207
1269
|
* @returns one entry per editable reply, in conversation order.
|
|
1208
1270
|
*/
|
|
1209
|
-
export function editableReplies(events, surfaceNodes) {
|
|
1271
|
+
export function editableReplies(events, surfaceNodes, options = {}) {
|
|
1210
1272
|
const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
|
|
1211
1273
|
const turnOf = turnIndex(events)
|
|
1212
1274
|
const head = surfaceNodes[0]
|
|
1275
|
+
const patches = options.patches instanceof Map ? options.patches : new Map()
|
|
1276
|
+
const patch = options.patch === true
|
|
1213
1277
|
const out = []
|
|
1214
1278
|
for (const event of events) {
|
|
1215
1279
|
if (!isAssistantReply(event)) continue
|
|
1216
1280
|
if (!nodeIndex.has(event.seq)) continue
|
|
1217
1281
|
if (event.seq === head) continue
|
|
1218
|
-
const
|
|
1219
|
-
|
|
1282
|
+
const data = event.data && typeof event.data === 'object' ? event.data : {}
|
|
1283
|
+
const message = data.message && typeof data.message === 'object' ? data.message : data
|
|
1284
|
+
const blocks = projectedBlocks(message, event.seq, patches)
|
|
1285
|
+
if (blocks.length === 0) continue
|
|
1286
|
+
const text = blocks.filter((block) => block.kind === 'text').map((block) => block.text).join('\n')
|
|
1287
|
+
if (!patch && text.trim() === '') continue
|
|
1288
|
+
if (patch && blocks.every((block) => block.text.trim() === '')) continue
|
|
1220
1289
|
const turn = turnOf.get(event.seq)
|
|
1221
1290
|
out.push({
|
|
1222
1291
|
seq: event.seq,
|
|
1223
1292
|
turn: typeof turn === 'number' ? turn : null,
|
|
1224
1293
|
messageId: messageIdOf(event) ?? null,
|
|
1225
1294
|
text,
|
|
1226
|
-
// For a reply these are the
|
|
1227
|
-
|
|
1295
|
+
// For a reply these are the blocks a text-only rewrite would drop:
|
|
1296
|
+
// tool calls and any other non-editable content.
|
|
1297
|
+
attachments: readMessageBlocks(event).filter((block) => patch ? kindOfBlock(block) === null : !isTextBlock(block)).length,
|
|
1298
|
+
patched: blocks.some((block) => block.patched),
|
|
1299
|
+
// Present only where the editor can target one: the replay path can only
|
|
1300
|
+
// replace the whole reply, so it has no block picker.
|
|
1301
|
+
...(patch ? { blocks } : {}),
|
|
1228
1302
|
})
|
|
1229
1303
|
}
|
|
1230
1304
|
return out
|
|
@@ -1570,13 +1644,14 @@ async function stateOf(ctx, sessionId, config) {
|
|
|
1570
1644
|
}
|
|
1571
1645
|
const folded = foldSurface(events)
|
|
1572
1646
|
const ledger = rollbackLedger(events)
|
|
1647
|
+
const patches = patchesOf(events)
|
|
1573
1648
|
const live = findLiveSession(ctx, sessionId)
|
|
1574
1649
|
// Probed per request: the editor offers what THIS deployment can do right
|
|
1575
1650
|
// now, so a store that is unmounted (or not yet mounted) cannot leave the
|
|
1576
1651
|
// editor promising to keep blocks the host would have to drop.
|
|
1577
1652
|
const store = resolveAttachmentStore(ctx)
|
|
1578
1653
|
const turns = editableTurns(events, folded.nodes, { preview: canPreviewAttachments(store) })
|
|
1579
|
-
const replies = editableReplies(events, folded.nodes)
|
|
1654
|
+
const replies = editableReplies(events, folded.nodes, { patches, patch: config.patch === true })
|
|
1580
1655
|
noteRequest({
|
|
1581
1656
|
kind: 'state',
|
|
1582
1657
|
sessionId,
|
|
@@ -1598,6 +1673,11 @@ async function stateOf(ctx, sessionId, config) {
|
|
|
1598
1673
|
surface: folded.nodes,
|
|
1599
1674
|
turns,
|
|
1600
1675
|
replies,
|
|
1676
|
+
// The landed in-place patches, latest per block. The browser half uses them
|
|
1677
|
+
// to draw the projected text in place of the recorded one; the model already
|
|
1678
|
+
// sees them through the host projection.
|
|
1679
|
+
patches: patchList(patches),
|
|
1680
|
+
mode: config.patch === true ? 'patch' : 'replay',
|
|
1601
1681
|
live: Boolean(live),
|
|
1602
1682
|
busy: isBusy(events),
|
|
1603
1683
|
lastSeq: events.length > 0 ? events[events.length - 1].seq : -1,
|
|
@@ -1606,7 +1686,7 @@ async function stateOf(ctx, sessionId, config) {
|
|
|
1606
1686
|
// path (a chip that may draw its own thumbnail); both are answered from a
|
|
1607
1687
|
// live service probe, so the editor never promises what the host cannot do.
|
|
1608
1688
|
capabilities: { attachments: store !== null, preview: canPreviewAttachments(store) },
|
|
1609
|
-
config: { confirm: config.confirm },
|
|
1689
|
+
config: { confirm: config.confirm, replyMode: config.replyMode, patch: config.patch === true },
|
|
1610
1690
|
version: PLUGIN_VERSION,
|
|
1611
1691
|
}
|
|
1612
1692
|
}
|
|
@@ -1723,13 +1803,17 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1723
1803
|
if (isBusy(events)) throw new HttpError(409, 'busy', 'the session is still working')
|
|
1724
1804
|
|
|
1725
1805
|
const surfaceNodes = surfaceOf(ctx, sessionId, events)
|
|
1806
|
+
const patches = patchesOf(events)
|
|
1807
|
+
const patchMode = config.patch === true
|
|
1726
1808
|
let plan
|
|
1727
1809
|
try {
|
|
1728
1810
|
plan = planRollback(events, surfaceNodes, {
|
|
1729
1811
|
seq: typeof body.seq === 'number' ? body.seq : undefined,
|
|
1730
1812
|
messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
|
|
1731
1813
|
turn: typeof body.turn === 'number' ? body.turn : undefined,
|
|
1732
|
-
|
|
1814
|
+
blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
|
|
1815
|
+
kind: typeof body.kind === 'string' ? body.kind : undefined,
|
|
1816
|
+
}, { patches, patch: patchMode })
|
|
1733
1817
|
} catch (error) {
|
|
1734
1818
|
if (error instanceof EditPlanError) {
|
|
1735
1819
|
const status = error.code === 'not-editable' ? 400 : 409
|
|
@@ -1738,6 +1822,56 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1738
1822
|
throw error
|
|
1739
1823
|
}
|
|
1740
1824
|
|
|
1825
|
+
// The in-place reply path: one small plugin record, interpreted by this
|
|
1826
|
+
// plugin's projection. Nothing leaves the surface, so there is no window to
|
|
1827
|
+
// check, no turn to open and no tail to replay. The record is append-only and
|
|
1828
|
+
// the original event keeps its bytes; the model sees the patched block on the
|
|
1829
|
+
// next fold.
|
|
1830
|
+
if (plan.patch === true) {
|
|
1831
|
+
if (typeof sessionApi.appendPluginRecord !== 'function') {
|
|
1832
|
+
throw new HttpError(409, 'unsupported', 'this harness does not expose plugin records; reply edits need the replay path')
|
|
1833
|
+
}
|
|
1834
|
+
if (text === plan.original) {
|
|
1835
|
+
return {
|
|
1836
|
+
kind: 'reply',
|
|
1837
|
+
applied: false,
|
|
1838
|
+
unchanged: true,
|
|
1839
|
+
patch: true,
|
|
1840
|
+
shadowed: [],
|
|
1841
|
+
blockIndex: plan.blockIndex,
|
|
1842
|
+
kindOfBlock: plan.kind,
|
|
1843
|
+
original: plan.original,
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
let recordSeq
|
|
1847
|
+
try {
|
|
1848
|
+
recordSeq = sessionApi.appendPluginRecord(
|
|
1849
|
+
session,
|
|
1850
|
+
PATCH_TYPE,
|
|
1851
|
+
buildPatchData([{ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }]),
|
|
1852
|
+
)
|
|
1853
|
+
} catch (error) {
|
|
1854
|
+
throw new HttpError(409, 'stale', `the session refused the patch record: ${String((error && error.message) || error)}`)
|
|
1855
|
+
}
|
|
1856
|
+
const flush = await flushSession(ctx, session)
|
|
1857
|
+
return {
|
|
1858
|
+
kind: 'reply',
|
|
1859
|
+
applied: true,
|
|
1860
|
+
mode: 'patch',
|
|
1861
|
+
patch: true,
|
|
1862
|
+
recordSeq,
|
|
1863
|
+
target: {
|
|
1864
|
+
seq: plan.targetSeq,
|
|
1865
|
+
blockIndex: plan.blockIndex,
|
|
1866
|
+
kind: plan.kind,
|
|
1867
|
+
text,
|
|
1868
|
+
original: plan.original,
|
|
1869
|
+
},
|
|
1870
|
+
shadowed: [],
|
|
1871
|
+
...flush,
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1741
1875
|
// The live surface is the append authority; a node that vanished between the
|
|
1742
1876
|
// read and this check means another writer landed first.
|
|
1743
1877
|
for (const seq of plan.shadowed) {
|
|
@@ -2008,14 +2142,14 @@ function sessionIdFromExec(exec) {
|
|
|
2008
2142
|
return undefined
|
|
2009
2143
|
}
|
|
2010
2144
|
|
|
2011
|
-
function registerTool(ctx) {
|
|
2145
|
+
function registerTool(ctx, config) {
|
|
2012
2146
|
const tools = ctx.get('tools')
|
|
2013
2147
|
if (!tools || typeof tools.register !== 'function') return
|
|
2014
2148
|
tools.register(
|
|
2015
2149
|
defineTool({
|
|
2016
2150
|
name: 'edit_turn_targets',
|
|
2017
2151
|
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
|
|
2152
|
+
'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
2153
|
parameters: {
|
|
2020
2154
|
sessionId: {
|
|
2021
2155
|
type: 'string',
|
|
@@ -2035,8 +2169,9 @@ function registerTool(ctx) {
|
|
|
2035
2169
|
const events = await readEvents(ctx, sessionId)
|
|
2036
2170
|
if (!events) throw new Error(`no session log for ${sessionId}`)
|
|
2037
2171
|
const nodes = surfaceOf(ctx, sessionId, events)
|
|
2172
|
+
const patches = patchesOf(events)
|
|
2038
2173
|
const turns = editableTurns(events, nodes)
|
|
2039
|
-
const replies = editableReplies(events, nodes)
|
|
2174
|
+
const replies = editableReplies(events, nodes, { patches, patch: config && config.patch === true })
|
|
2040
2175
|
const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.floor(args.limit) : 20
|
|
2041
2176
|
const shown = turns.slice(-limit)
|
|
2042
2177
|
const shownReplies = replies.slice(-limit)
|
|
@@ -2050,10 +2185,13 @@ function registerTool(ctx) {
|
|
|
2050
2185
|
if (turns.length > shown.length) lines.push(`(${turns.length - shown.length} earlier turn(s) omitted)`)
|
|
2051
2186
|
for (const reply of shownReplies) {
|
|
2052
2187
|
const preview = reply.text.length > 160 ? `${reply.text.slice(0, 160)}...` : reply.text
|
|
2053
|
-
|
|
2188
|
+
const blocks = Array.isArray(reply.blocks)
|
|
2189
|
+
? ` [${reply.blocks.map((block) => `${block.index}:${block.kind}${block.patched ? '*' : ''}`).join(', ')}]`
|
|
2190
|
+
: ''
|
|
2191
|
+
lines.push(`- reply (seq ${reply.seq}, ${reply.attachments} non-text part(s))${blocks}: ${preview}`)
|
|
2054
2192
|
}
|
|
2055
2193
|
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
|
|
2194
|
+
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
2195
|
return lines.join('\n')
|
|
2058
2196
|
},
|
|
2059
2197
|
}),
|
|
@@ -2182,7 +2320,38 @@ export function apply(ctx, config) {
|
|
|
2182
2320
|
: DEFAULT_MARKER,
|
|
2183
2321
|
carrier: config && config.carrier === 'user/message' ? 'user/message' : 'system/message',
|
|
2184
2322
|
confirm: config && typeof config.confirm === 'boolean' ? config.confirm : false,
|
|
2323
|
+
replyMode: config && (config.replyMode === 'replay' || config.replyMode === 'patch') ? config.replyMode : 'auto',
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
// The in-place reply path needs two host seams: a plugin record with the
|
|
2327
|
+
// `ignorable` envelope, and a message projection registry. Both are probed at
|
|
2328
|
+
// load; when either is missing the reply path keeps the official replay
|
|
2329
|
+
// splice, and `replyMode: 'patch'` turns that silence into a load error.
|
|
2330
|
+
const sessionsService = ctx.get('sessions')
|
|
2331
|
+
let patch = false
|
|
2332
|
+
if (
|
|
2333
|
+
resolved.replyMode !== 'replay' &&
|
|
2334
|
+
typeof sessionApi.appendPluginRecord === 'function' &&
|
|
2335
|
+
sessionsService &&
|
|
2336
|
+
typeof sessionsService.registerMessageProjection === 'function'
|
|
2337
|
+
) {
|
|
2338
|
+
try {
|
|
2339
|
+
// A live reload (HMR, a re-applied bundle) leaves the previous
|
|
2340
|
+
// registration in place: the registry rejects a second definition of the
|
|
2341
|
+
// same type. Reuse ours instead of failing back to replay - the
|
|
2342
|
+
// projection is a stateless pure function, so the old instance is exactly
|
|
2343
|
+
// as good as a fresh one.
|
|
2344
|
+
const registry = sessionsService.messageProjections
|
|
2345
|
+
const existing = Array.isArray(registry)
|
|
2346
|
+
? registry.find((entry) => entry && entry.type === PATCH_TYPE)
|
|
2347
|
+
: undefined
|
|
2348
|
+
if (existing === undefined) sessionsService.registerMessageProjection(patchProjection())
|
|
2349
|
+
patch = true
|
|
2350
|
+
} catch (error) {
|
|
2351
|
+
if (resolved.replyMode === 'patch') throw error
|
|
2352
|
+
}
|
|
2185
2353
|
}
|
|
2354
|
+
resolved.patch = patch
|
|
2186
2355
|
|
|
2187
2356
|
const registerRoutes = (webServer, fiber) => {
|
|
2188
2357
|
fiber.effect(() =>
|
|
@@ -2261,7 +2430,19 @@ export function apply(ctx, config) {
|
|
|
2261
2430
|
sendJson(res, 405, { ok: false, code: 'method', error: 'GET only' })
|
|
2262
2431
|
return
|
|
2263
2432
|
}
|
|
2264
|
-
sendJson(res, 200, {
|
|
2433
|
+
sendJson(res, 200, {
|
|
2434
|
+
ok: true,
|
|
2435
|
+
version: PLUGIN_VERSION,
|
|
2436
|
+
// How this deployment resolves a reply edit. `patch` means both
|
|
2437
|
+
// host seams were found and the projection registered; `replay`
|
|
2438
|
+
// means the historical splice is what a save will use.
|
|
2439
|
+
mode: resolved.patch === true ? 'patch' : 'replay',
|
|
2440
|
+
capabilities: {
|
|
2441
|
+
pluginRecords: typeof sessionApi.appendPluginRecord === 'function',
|
|
2442
|
+
projection: resolved.patch === true,
|
|
2443
|
+
},
|
|
2444
|
+
requests: recentRequests(),
|
|
2445
|
+
})
|
|
2265
2446
|
},
|
|
2266
2447
|
}),
|
|
2267
2448
|
)
|
|
@@ -2342,9 +2523,9 @@ export function apply(ctx, config) {
|
|
|
2342
2523
|
|
|
2343
2524
|
const tools = ctx.get('tools')
|
|
2344
2525
|
if (tools) {
|
|
2345
|
-
registerTool(ctx)
|
|
2526
|
+
registerTool(ctx, resolved)
|
|
2346
2527
|
} else {
|
|
2347
|
-
ctx.inject(['tools'], (sub) => registerTool(sub))
|
|
2528
|
+
ctx.inject(['tools'], (sub) => registerTool(sub, resolved))
|
|
2348
2529
|
}
|
|
2349
2530
|
}
|
|
2350
2531
|
|
package/lib/patch.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// In-place reply edits through a plugin-owned session record and a message
|
|
2
|
+
// projection.
|
|
3
|
+
//
|
|
4
|
+
// The host refuses to let an `assistant/message` be a surface-replacement
|
|
5
|
+
// carrier (it may never carry `sourceEventSeqs`), so a corrected reply cannot be
|
|
6
|
+
// put back at its own position with a surface event. The official seam for that
|
|
7
|
+
// case is a message projection: append a log-only plugin record, then register a
|
|
8
|
+
// pure projection for its type that rewrites the model-visible message at the
|
|
9
|
+
// target seq. Nothing leaves the surface, so there is nothing to replay and the
|
|
10
|
+
// log grows by one small record per edit.
|
|
11
|
+
//
|
|
12
|
+
// `appendPluginRecord` writes the record with the `ignorable` envelope the
|
|
13
|
+
// persistence gate requires (DSH >= 0.2.1-rc.2). The projection is registered
|
|
14
|
+
// through `ctx.sessions.registerMessageProjection` and is fiber-owned: unloading
|
|
15
|
+
// the plugin removes it, and the record is then skipped, so the original text
|
|
16
|
+
// comes back. That is the deliberate trade against the surface path: prompt
|
|
17
|
+
// edits are official events that outlive the plugin, patch edits are an overlay
|
|
18
|
+
// that needs its owner.
|
|
19
|
+
|
|
20
|
+
/** The plugin record type. The grammar requires the `plugin:` namespace. */
|
|
21
|
+
export const PATCH_TYPE = 'plugin:dsh-edit-turn/patch'
|
|
22
|
+
|
|
23
|
+
/** Patch payload version. */
|
|
24
|
+
export const PATCH_VERSION = 1
|
|
25
|
+
|
|
26
|
+
/** Block kinds this plugin can edit, mapped to the recorded block types. */
|
|
27
|
+
const KIND_TO_TYPES = {
|
|
28
|
+
text: ['text'],
|
|
29
|
+
reasoning: ['reasoning', 'thinking'],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The editable kind of one content block.
|
|
34
|
+
* @param block - one message content block.
|
|
35
|
+
* @returns 'text' | 'reasoning' | null.
|
|
36
|
+
*/
|
|
37
|
+
export function kindOfBlock(block) {
|
|
38
|
+
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'
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Every block of a message an editor may target, in message order.
|
|
46
|
+
* @param message - a message payload with a `content` array.
|
|
47
|
+
* @returns `[{ index, kind, text }]`.
|
|
48
|
+
*/
|
|
49
|
+
export function editableBlocks(message) {
|
|
50
|
+
const content = message && Array.isArray(message.content) ? message.content : []
|
|
51
|
+
const out = []
|
|
52
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
53
|
+
const block = content[index]
|
|
54
|
+
const kind = kindOfBlock(block)
|
|
55
|
+
if (kind === null) continue
|
|
56
|
+
out.push({ index, kind, text: block.text })
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A non-negative safe integer, matching the host's seq/blockIndex checks. */
|
|
62
|
+
function isIndex(value) {
|
|
63
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validate one patch payload (shape and target grammar).
|
|
68
|
+
* @param data - the record payload.
|
|
69
|
+
* @returns the same payload when valid.
|
|
70
|
+
* @throws when malformed.
|
|
71
|
+
*/
|
|
72
|
+
export function validatePatchData(data) {
|
|
73
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
74
|
+
throw new Error(`${PATCH_TYPE}: data must be an object`)
|
|
75
|
+
}
|
|
76
|
+
if (data.version !== PATCH_VERSION) {
|
|
77
|
+
throw new Error(`${PATCH_TYPE}: unsupported version ${String(data.version)}`)
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(data.targets) || data.targets.length === 0) {
|
|
80
|
+
throw new Error(`${PATCH_TYPE}: targets must be a nonempty array`)
|
|
81
|
+
}
|
|
82
|
+
for (const target of data.targets) {
|
|
83
|
+
if (target === null || typeof target !== 'object' || Array.isArray(target)) {
|
|
84
|
+
throw new Error(`${PATCH_TYPE}: each target must be an object`)
|
|
85
|
+
}
|
|
86
|
+
if (!isIndex(target.seq)) throw new Error(`${PATCH_TYPE}: target seq must be a non-negative safe integer`)
|
|
87
|
+
if (!isIndex(target.blockIndex)) throw new Error(`${PATCH_TYPE}: target blockIndex must be a non-negative safe integer`)
|
|
88
|
+
if (!(target.kind in KIND_TO_TYPES)) throw new Error(`${PATCH_TYPE}: target kind must be text or reasoning`)
|
|
89
|
+
if (typeof target.text !== 'string') throw new Error(`${PATCH_TYPE}: target text must be a string`)
|
|
90
|
+
}
|
|
91
|
+
return data
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build a validated patch payload.
|
|
96
|
+
* @param targets - `[{ seq, blockIndex, kind, text }]`.
|
|
97
|
+
* @returns the payload for `appendPluginRecord`.
|
|
98
|
+
*/
|
|
99
|
+
export function buildPatchData(targets) {
|
|
100
|
+
const data = {
|
|
101
|
+
version: PATCH_VERSION,
|
|
102
|
+
targets: targets.map((target) => ({
|
|
103
|
+
seq: target.seq,
|
|
104
|
+
blockIndex: target.blockIndex,
|
|
105
|
+
kind: target.kind,
|
|
106
|
+
text: target.text,
|
|
107
|
+
})),
|
|
108
|
+
}
|
|
109
|
+
validatePatchData(data)
|
|
110
|
+
return data
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Apply the targets that belong to one seq to one message.
|
|
115
|
+
*
|
|
116
|
+
* Targets are grouped by the caller: a record may carry several blocks of the
|
|
117
|
+
* same message, and they all have to land in one projected message. A target
|
|
118
|
+
* whose block is gone (or changed kind) is skipped; the rest still apply.
|
|
119
|
+
*
|
|
120
|
+
* @param message - the base message payload.
|
|
121
|
+
* @param targets - validated targets, all for this message.
|
|
122
|
+
* @returns a patched message copy.
|
|
123
|
+
*/
|
|
124
|
+
export function applyTargetsToMessage(message, targets) {
|
|
125
|
+
const content = Array.isArray(message.content) ? message.content.slice() : []
|
|
126
|
+
for (const target of targets) {
|
|
127
|
+
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') {
|
|
132
|
+
// The signature signs the original text; keep it and some providers
|
|
133
|
+
// reject the whole request. The recorded block keeps its signature; the
|
|
134
|
+
// projected copy drops it, exactly like the disk path does.
|
|
135
|
+
delete patched.signature
|
|
136
|
+
delete patched.thinkingSignature
|
|
137
|
+
}
|
|
138
|
+
content[target.blockIndex] = patched
|
|
139
|
+
}
|
|
140
|
+
return { ...message, content }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The message projection for this plugin's record type.
|
|
145
|
+
*
|
|
146
|
+
* Pure and never throws: a projection error fails the whole session fold, which
|
|
147
|
+
* would make the session unreadable. A malformed record or a target that is no
|
|
148
|
+
* longer on the surface is simply skipped.
|
|
149
|
+
*
|
|
150
|
+
* Several records compose: the base for one target is the message the previous
|
|
151
|
+
* projections already produced (`context.messages`), falling back to the
|
|
152
|
+
* recorded message, so editing block A and later block B keeps both edits.
|
|
153
|
+
*
|
|
154
|
+
* @returns `{ type, project(event, context) }` for the host registry.
|
|
155
|
+
*/
|
|
156
|
+
export function patchProjection() {
|
|
157
|
+
return {
|
|
158
|
+
type: PATCH_TYPE,
|
|
159
|
+
project(event, context) {
|
|
160
|
+
const out = new Map()
|
|
161
|
+
let data
|
|
162
|
+
try {
|
|
163
|
+
data = validatePatchData(event && event.data)
|
|
164
|
+
} catch {
|
|
165
|
+
return out
|
|
166
|
+
}
|
|
167
|
+
const nodes = new Set(Array.isArray(context && context.nodes) ? context.nodes : [])
|
|
168
|
+
const events = Array.isArray(context && context.events) ? context.events : []
|
|
169
|
+
const baseSeq = typeof (context && context.baseSeq) === 'number' ? context.baseSeq : 0
|
|
170
|
+
const messages = context && context.messages
|
|
171
|
+
const groups = new Map()
|
|
172
|
+
for (const target of data.targets) {
|
|
173
|
+
// A retired target is not in the model context any more: editing it
|
|
174
|
+
// would be a no-op for the conversation.
|
|
175
|
+
if (!nodes.has(target.seq)) continue
|
|
176
|
+
const group = groups.get(target.seq)
|
|
177
|
+
if (group === undefined) groups.set(target.seq, [target])
|
|
178
|
+
else group.push(target)
|
|
179
|
+
}
|
|
180
|
+
for (const [seq, targets] of groups) {
|
|
181
|
+
const source = events[seq - baseSeq]
|
|
182
|
+
if (!source || source.type !== 'assistant/message') continue
|
|
183
|
+
const previous = messages && typeof messages.get === 'function' ? messages.get(seq) : undefined
|
|
184
|
+
const base = previous && typeof previous === 'object' ? previous : source.data && source.data.message
|
|
185
|
+
if (!base || !Array.isArray(base.content)) continue
|
|
186
|
+
try {
|
|
187
|
+
out.set(seq, applyTargetsToMessage(base, targets))
|
|
188
|
+
} catch {
|
|
189
|
+
// Keep the rest of the record; a broken target must not take the
|
|
190
|
+
// session's whole fold down.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Read every landed patch from the log, latest per (seq, blockIndex).
|
|
200
|
+
* @param events - complete contiguous event log.
|
|
201
|
+
* @returns `Map<seq, Map<blockIndex, { kind, text }>>`.
|
|
202
|
+
*/
|
|
203
|
+
export function patchesOf(events) {
|
|
204
|
+
const out = new Map()
|
|
205
|
+
for (const event of events) {
|
|
206
|
+
if (!event || event.type !== PATCH_TYPE) continue
|
|
207
|
+
let data
|
|
208
|
+
try {
|
|
209
|
+
data = validatePatchData(event.data)
|
|
210
|
+
} catch {
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
for (const target of data.targets) {
|
|
214
|
+
let byBlock = out.get(target.seq)
|
|
215
|
+
if (byBlock === undefined) {
|
|
216
|
+
byBlock = new Map()
|
|
217
|
+
out.set(target.seq, byBlock)
|
|
218
|
+
}
|
|
219
|
+
byBlock.set(target.blockIndex, { kind: target.kind, text: target.text })
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return out
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The latest patch per block as a flat list for the browser half.
|
|
227
|
+
* @param patches - `patchesOf(events)`.
|
|
228
|
+
* @returns `[{ seq, blockIndex, kind, text }]`.
|
|
229
|
+
*/
|
|
230
|
+
export function patchList(patches) {
|
|
231
|
+
const out = []
|
|
232
|
+
for (const [seq, byBlock] of patches) {
|
|
233
|
+
for (const [blockIndex, patch] of byBlock) {
|
|
234
|
+
out.push({ seq, blockIndex, kind: patch.kind, text: patch.text })
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return out.sort((a, b) => a.seq - b.seq || a.blockIndex - b.blockIndex)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* One message's blocks with the landed patch text applied.
|
|
242
|
+
* @param message - the recorded message payload.
|
|
243
|
+
* @param seq - the event seq that carries the message.
|
|
244
|
+
* @param patches - `patchesOf(events)`.
|
|
245
|
+
* @returns `[{ index, kind, text, original, patched }]`.
|
|
246
|
+
*/
|
|
247
|
+
export function projectedBlocks(message, seq, patches) {
|
|
248
|
+
const byBlock = patches.get(seq)
|
|
249
|
+
return editableBlocks(message).map((block) => {
|
|
250
|
+
const patch = byBlock && byBlock.get(block.index)
|
|
251
|
+
const applied = patch !== undefined && patch.kind === block.kind ? patch.text : block.text
|
|
252
|
+
return {
|
|
253
|
+
index: block.index,
|
|
254
|
+
kind: block.kind,
|
|
255
|
+
text: applied,
|
|
256
|
+
original: block.text,
|
|
257
|
+
patched: applied !== block.text,
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
}
|