dsh-edit-turn 0.3.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -1
- package/lib/client.js +1221 -70
- package/lib/index.js +116 -4
- package/lib/patch.js +27 -9
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -80,7 +80,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
|
|
|
80
80
|
export const name = PLUGIN_ID
|
|
81
81
|
|
|
82
82
|
/** Keep in sync with package.json and lib/client.js. */
|
|
83
|
-
export const PLUGIN_VERSION = '0.
|
|
83
|
+
export const PLUGIN_VERSION = '0.5.0'
|
|
84
84
|
|
|
85
85
|
// A ring of the last few requests, answering "did the browser half even ask, and
|
|
86
86
|
// what did it get?" without needing its console. Read-only diagnostics: the
|
|
@@ -164,6 +164,14 @@ export const Config =
|
|
|
164
164
|
* the official surface replacement.
|
|
165
165
|
*/
|
|
166
166
|
replyMode: z.string().default('auto'),
|
|
167
|
+
/**
|
|
168
|
+
* Which editor presentation the browser half draws. `frame` outlines the
|
|
169
|
+
* whole message row (reference-UI style); `bubble` is exactly the
|
|
170
|
+
* message bubble; `center` is a large centred panel. The setting exists
|
|
171
|
+
* because the best shape is a matter of taste and was hard to pick
|
|
172
|
+
* without trying them side by side.
|
|
173
|
+
*/
|
|
174
|
+
editorStyle: z.union([z.const('frame'), z.const('bubble'), z.const('center')]).default('frame'),
|
|
167
175
|
})
|
|
168
176
|
|
|
169
177
|
// ---------------------------------------------------------------------------
|
|
@@ -1300,6 +1308,25 @@ export function editableReplies(events, surfaceNodes, options = {}) {
|
|
|
1300
1308
|
if (!patch && text.trim() === '') continue
|
|
1301
1309
|
if (patch && blocks.every((block) => block.text.trim() === '')) continue
|
|
1302
1310
|
const turn = turnOf.get(event.seq)
|
|
1311
|
+
// One turn runs many steps, and every step's assistant message carries its
|
|
1312
|
+
// own reasoning. The answer's row is the one with the action strip, so its
|
|
1313
|
+
// block list is widened to the whole turn: every reasoning and every answer
|
|
1314
|
+
// text of that turn is offered, each annotated with the seq it lives in.
|
|
1315
|
+
let listed = blocks.map((block) => ({ ...block, seq: event.seq }))
|
|
1316
|
+
const carriesAnswer = blocks.some((block) => block.kind === 'text' && block.text.trim() !== '')
|
|
1317
|
+
if (patch && carriesAnswer && typeof turn === 'number') {
|
|
1318
|
+
const acrossTurn = []
|
|
1319
|
+
for (const other of events) {
|
|
1320
|
+
if (!isAssistantReply(other) || turnOf.get(other.seq) !== turn) continue
|
|
1321
|
+
if (!nodeIndex.has(other.seq)) continue
|
|
1322
|
+
const otherData = other.data && typeof other.data === 'object' ? other.data : {}
|
|
1323
|
+
const otherMessage = otherData.message && typeof otherData.message === 'object' ? otherData.message : otherData
|
|
1324
|
+
for (const block of projectedBlocks(otherMessage, other.seq, patches)) {
|
|
1325
|
+
acrossTurn.push({ ...block, seq: other.seq })
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
if (acrossTurn.length > listed.length) listed = acrossTurn
|
|
1329
|
+
}
|
|
1303
1330
|
out.push({
|
|
1304
1331
|
seq: event.seq,
|
|
1305
1332
|
turn: typeof turn === 'number' ? turn : null,
|
|
@@ -1311,7 +1338,7 @@ export function editableReplies(events, surfaceNodes, options = {}) {
|
|
|
1311
1338
|
patched: blocks.some((block) => block.patched),
|
|
1312
1339
|
// Present only where the editor can target one: the replay path can only
|
|
1313
1340
|
// replace the whole reply, so it has no block picker.
|
|
1314
|
-
...(patch ? { blocks } : {}),
|
|
1341
|
+
...(patch ? { blocks: listed } : {}),
|
|
1315
1342
|
})
|
|
1316
1343
|
}
|
|
1317
1344
|
return out
|
|
@@ -1706,7 +1733,12 @@ async function stateOf(ctx, sessionId, config) {
|
|
|
1706
1733
|
// path (a chip that may draw its own thumbnail); both are answered from a
|
|
1707
1734
|
// live service probe, so the editor never promises what the host cannot do.
|
|
1708
1735
|
capabilities: { attachments: store !== null, preview: canPreviewAttachments(store) },
|
|
1709
|
-
config: {
|
|
1736
|
+
config: {
|
|
1737
|
+
confirm: config.confirm,
|
|
1738
|
+
replyMode: config.replyMode,
|
|
1739
|
+
patch: config.patch === true,
|
|
1740
|
+
editorStyle: config.editorStyle,
|
|
1741
|
+
},
|
|
1710
1742
|
version: PLUGIN_VERSION,
|
|
1711
1743
|
}
|
|
1712
1744
|
}
|
|
@@ -1809,7 +1841,82 @@ export function buildCorrection(plan, text, turn, step) {
|
|
|
1809
1841
|
}
|
|
1810
1842
|
}
|
|
1811
1843
|
|
|
1844
|
+
/**
|
|
1845
|
+
* The all-blocks save: one plugin record carrying a target per edited block,
|
|
1846
|
+
* possibly across several messages of the same turn. Each target is planned
|
|
1847
|
+
* exactly like a single-block save, and unchanged blocks are simply left out.
|
|
1848
|
+
*
|
|
1849
|
+
* @param ctx - cordis context.
|
|
1850
|
+
* @param sessionId - the addressed session.
|
|
1851
|
+
* @param body - `{ targets: [{ seq, blockIndex, kind, text }] }`.
|
|
1852
|
+
* @param config - plugin config.
|
|
1853
|
+
* @returns the same shape `/apply` returns for a reply patch.
|
|
1854
|
+
*/
|
|
1855
|
+
async function applyTargetsEdit(ctx, sessionId, body, config) {
|
|
1856
|
+
const targets = Array.isArray(body.targets) ? body.targets : []
|
|
1857
|
+
if (targets.length === 0 || targets.length > MAX_PARTS) {
|
|
1858
|
+
throw new HttpError(400, 'invalid', 'targets must be a nonempty, bounded array')
|
|
1859
|
+
}
|
|
1860
|
+
for (const target of targets) {
|
|
1861
|
+
if (target === null || typeof target !== 'object') throw new HttpError(400, 'invalid', 'each target must be an object')
|
|
1862
|
+
if (typeof target.text !== 'string' || target.text.trim() === '') throw new HttpError(400, 'invalid', 'an edited block cannot be empty')
|
|
1863
|
+
if (target.text.length > MAX_TEXT) throw new HttpError(400, 'invalid', 'the revised block is too long')
|
|
1864
|
+
}
|
|
1865
|
+
const session = await resolveSession(ctx, sessionId)
|
|
1866
|
+
if (!session || typeof session.append !== 'function') {
|
|
1867
|
+
throw new HttpError(409, 'session-not-active', 'the session is not open in DSH')
|
|
1868
|
+
}
|
|
1869
|
+
const events = await readEvents(ctx, sessionId)
|
|
1870
|
+
if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
|
|
1871
|
+
if (isBusy(events)) throw new HttpError(409, 'busy', 'the session is still working')
|
|
1872
|
+
const surfaceNodes = surfaceOf(ctx, sessionId, events)
|
|
1873
|
+
const patches = patchesOf(events)
|
|
1874
|
+
const plans = []
|
|
1875
|
+
for (const target of targets) {
|
|
1876
|
+
let plan
|
|
1877
|
+
try {
|
|
1878
|
+
plan = planRollback(events, surfaceNodes, { seq: target.seq, blockIndex: target.blockIndex, kind: target.kind }, { patches, patch: config.patch === true })
|
|
1879
|
+
} catch (error) {
|
|
1880
|
+
if (error instanceof EditPlanError) {
|
|
1881
|
+
const status = error.code === 'not-editable' ? 400 : 409
|
|
1882
|
+
throw new HttpError(status, error.code, error.message)
|
|
1883
|
+
}
|
|
1884
|
+
throw error
|
|
1885
|
+
}
|
|
1886
|
+
if (plan.patch !== true) throw new HttpError(400, 'not-editable', 'all-blocks editing needs the in-place reply path')
|
|
1887
|
+
if (target.text !== plan.original) plans.push({ plan, text: target.text })
|
|
1888
|
+
}
|
|
1889
|
+
if (plans.length === 0) {
|
|
1890
|
+
return { kind: 'reply', applied: false, unchanged: true, patch: true, shadowed: [], targets: [] }
|
|
1891
|
+
}
|
|
1892
|
+
if (typeof sessionApi.appendPluginRecord !== 'function') {
|
|
1893
|
+
throw new HttpError(409, 'unsupported', 'this harness does not expose plugin records; reply edits need the replay path')
|
|
1894
|
+
}
|
|
1895
|
+
let recordSeq
|
|
1896
|
+
try {
|
|
1897
|
+
recordSeq = sessionApi.appendPluginRecord(
|
|
1898
|
+
session,
|
|
1899
|
+
PATCH_TYPE,
|
|
1900
|
+
buildPatchData(plans.map(({ plan, text }) => ({ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }))),
|
|
1901
|
+
)
|
|
1902
|
+
} catch (error) {
|
|
1903
|
+
throw new HttpError(409, 'stale', `the session refused the patch record: ${String((error && error.message) || error)}`)
|
|
1904
|
+
}
|
|
1905
|
+
const flush = await flushSession(ctx, session)
|
|
1906
|
+
return {
|
|
1907
|
+
kind: 'reply',
|
|
1908
|
+
applied: true,
|
|
1909
|
+
mode: 'patch',
|
|
1910
|
+
patch: true,
|
|
1911
|
+
recordSeq,
|
|
1912
|
+
targets: plans.map(({ plan, text }) => ({ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text, original: plan.original })),
|
|
1913
|
+
shadowed: [],
|
|
1914
|
+
...flush,
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1812
1918
|
async function applyEdit(ctx, sessionId, body, config) {
|
|
1919
|
+
if (Array.isArray(body.targets) && body.targets.length > 0) return applyTargetsEdit(ctx, sessionId, body, config)
|
|
1813
1920
|
const text = typeof body.text === 'string' ? body.text : ''
|
|
1814
1921
|
if (text.trim() === '') throw new HttpError(400, 'invalid', 'the revised message cannot be empty')
|
|
1815
1922
|
if (text.length > MAX_TEXT) throw new HttpError(400, 'invalid', 'the revised message is too long')
|
|
@@ -1828,7 +1935,9 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1828
1935
|
let plan
|
|
1829
1936
|
try {
|
|
1830
1937
|
plan = planRollback(events, surfaceNodes, {
|
|
1831
|
-
|
|
1938
|
+
// A block from another step of the same turn names its own seq; that is
|
|
1939
|
+
// the message the projection has to patch.
|
|
1940
|
+
seq: typeof body.targetSeq === 'number' ? body.targetSeq : typeof body.seq === 'number' ? body.seq : undefined,
|
|
1832
1941
|
messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
|
|
1833
1942
|
turn: typeof body.turn === 'number' ? body.turn : undefined,
|
|
1834
1943
|
blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
|
|
@@ -2341,6 +2450,9 @@ export function apply(ctx, config) {
|
|
|
2341
2450
|
carrier: config && config.carrier === 'user/message' ? 'user/message' : 'system/message',
|
|
2342
2451
|
confirm: config && typeof config.confirm === 'boolean' ? config.confirm : false,
|
|
2343
2452
|
replyMode: config && (config.replyMode === 'replay' || config.replyMode === 'patch') ? config.replyMode : 'auto',
|
|
2453
|
+
editorStyle: config && (config.editorStyle === 'bubble' || config.editorStyle === 'center')
|
|
2454
|
+
? config.editorStyle
|
|
2455
|
+
: 'frame',
|
|
2344
2456
|
}
|
|
2345
2457
|
|
|
2346
2458
|
// The in-place reply path needs two host seams: a plugin record with the
|
package/lib/patch.js
CHANGED
|
@@ -35,9 +35,27 @@ const KIND_TO_TYPES = {
|
|
|
35
35
|
* @returns 'text' | 'reasoning' | null.
|
|
36
36
|
*/
|
|
37
37
|
export function kindOfBlock(block) {
|
|
38
|
+
return blockField(block) === null ? null : blockField(block).kind
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The editable text of one block, plus which field carries it.
|
|
43
|
+
*
|
|
44
|
+
* A platform is free to name the reasoning payload `text`, `reasoning` or
|
|
45
|
+
* `thinking`; the editor reads all three and writes back to the field it read,
|
|
46
|
+
* so a shape this build has not seen before is still editable.
|
|
47
|
+
*
|
|
48
|
+
* @param block - one content block.
|
|
49
|
+
* @returns `{ kind, field, text }`, or null when the block carries no text.
|
|
50
|
+
*/
|
|
51
|
+
export function blockField(block) {
|
|
38
52
|
if (!block || typeof block !== 'object') return null
|
|
39
|
-
if (block.type === 'text' && typeof block.text === 'string') return 'text'
|
|
40
|
-
if (
|
|
53
|
+
if (block.type === 'text' && typeof block.text === 'string') return { kind: 'text', field: 'text', text: block.text }
|
|
54
|
+
if (block.type === 'reasoning' || block.type === 'thinking') {
|
|
55
|
+
for (const field of ['text', 'reasoning', 'thinking']) {
|
|
56
|
+
if (typeof block[field] === 'string') return { kind: 'reasoning', field, text: block[field] }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
41
59
|
return null
|
|
42
60
|
}
|
|
43
61
|
|
|
@@ -51,9 +69,9 @@ export function editableBlocks(message) {
|
|
|
51
69
|
const out = []
|
|
52
70
|
for (let index = 0; index < content.length; index += 1) {
|
|
53
71
|
const block = content[index]
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
56
|
-
out.push({ index, kind, text:
|
|
72
|
+
const found = blockField(block)
|
|
73
|
+
if (found === null) continue
|
|
74
|
+
out.push({ index, kind: found.kind, text: found.text })
|
|
57
75
|
}
|
|
58
76
|
return out
|
|
59
77
|
}
|
|
@@ -125,10 +143,10 @@ export function applyTargetsToMessage(message, targets) {
|
|
|
125
143
|
const content = Array.isArray(message.content) ? message.content.slice() : []
|
|
126
144
|
for (const target of targets) {
|
|
127
145
|
const block = content[target.blockIndex]
|
|
128
|
-
const
|
|
129
|
-
if (
|
|
130
|
-
const patched = { ...block,
|
|
131
|
-
if (kind === 'reasoning') {
|
|
146
|
+
const found = blockField(block)
|
|
147
|
+
if (found === null || found.kind !== target.kind) continue
|
|
148
|
+
const patched = { ...block, [found.field]: target.text }
|
|
149
|
+
if (found.kind === 'reasoning') {
|
|
132
150
|
// The signature signs the original text; keep it and some providers
|
|
133
151
|
// reject the whole request. The recorded block keeps its signature; the
|
|
134
152
|
// projected copy drops it, exactly like the disk path does.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-edit-turn",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Edit any message in DeepSeek Harness in place: a prompt is replaced where it stands; a model reply block (answer text or reasoning) is patched through a plugin record and a message projection, so the turn stays where it is, later turns are untouched and the log grows by one small record. Falls back to the replay splice on hosts without the projection API. The append-only session log is never rewritten.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|