dsh-edit-turn 0.3.3 → 0.5.2
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 +88 -1
- package/lib/client.js +1613 -134
- package/lib/index.js +170 -12
- 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.2'
|
|
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
|
|
@@ -162,8 +162,20 @@ export const Config =
|
|
|
162
162
|
* replay splice otherwise; `patch` refuses to run without the
|
|
163
163
|
* projection; `replay` always uses the old path. Prompt edits always use
|
|
164
164
|
* the official surface replacement.
|
|
165
|
+
*
|
|
166
|
+
* There is deliberately no `replace` value: appending a fresh
|
|
167
|
+
* `assistant/message` with a replace op is refused by the session's own
|
|
168
|
+
* surface validation (see the note in the README).
|
|
165
169
|
*/
|
|
166
170
|
replyMode: z.string().default('auto'),
|
|
171
|
+
/**
|
|
172
|
+
* Which editor presentation the browser half draws. `frame` outlines the
|
|
173
|
+
* whole message row (reference-UI style); `bubble` is exactly the
|
|
174
|
+
* message bubble; `center` is a large centred panel. The setting exists
|
|
175
|
+
* because the best shape is a matter of taste and was hard to pick
|
|
176
|
+
* without trying them side by side.
|
|
177
|
+
*/
|
|
178
|
+
editorStyle: z.union([z.const('frame'), z.const('bubble'), z.const('center')]).default('frame'),
|
|
167
179
|
})
|
|
168
180
|
|
|
169
181
|
// ---------------------------------------------------------------------------
|
|
@@ -1300,6 +1312,25 @@ export function editableReplies(events, surfaceNodes, options = {}) {
|
|
|
1300
1312
|
if (!patch && text.trim() === '') continue
|
|
1301
1313
|
if (patch && blocks.every((block) => block.text.trim() === '')) continue
|
|
1302
1314
|
const turn = turnOf.get(event.seq)
|
|
1315
|
+
// One turn runs many steps, and every step's assistant message carries its
|
|
1316
|
+
// own reasoning. The answer's row is the one with the action strip, so its
|
|
1317
|
+
// block list is widened to the whole turn: every reasoning and every answer
|
|
1318
|
+
// text of that turn is offered, each annotated with the seq it lives in.
|
|
1319
|
+
let listed = blocks.map((block) => ({ ...block, seq: event.seq }))
|
|
1320
|
+
const carriesAnswer = blocks.some((block) => block.kind === 'text' && block.text.trim() !== '')
|
|
1321
|
+
if (patch && carriesAnswer && typeof turn === 'number') {
|
|
1322
|
+
const acrossTurn = []
|
|
1323
|
+
for (const other of events) {
|
|
1324
|
+
if (!isAssistantReply(other) || turnOf.get(other.seq) !== turn) continue
|
|
1325
|
+
if (!nodeIndex.has(other.seq)) continue
|
|
1326
|
+
const otherData = other.data && typeof other.data === 'object' ? other.data : {}
|
|
1327
|
+
const otherMessage = otherData.message && typeof otherData.message === 'object' ? otherData.message : otherData
|
|
1328
|
+
for (const block of projectedBlocks(otherMessage, other.seq, patches)) {
|
|
1329
|
+
acrossTurn.push({ ...block, seq: other.seq })
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
if (acrossTurn.length > listed.length) listed = acrossTurn
|
|
1333
|
+
}
|
|
1303
1334
|
out.push({
|
|
1304
1335
|
seq: event.seq,
|
|
1305
1336
|
turn: typeof turn === 'number' ? turn : null,
|
|
@@ -1311,7 +1342,7 @@ export function editableReplies(events, surfaceNodes, options = {}) {
|
|
|
1311
1342
|
patched: blocks.some((block) => block.patched),
|
|
1312
1343
|
// Present only where the editor can target one: the replay path can only
|
|
1313
1344
|
// replace the whole reply, so it has no block picker.
|
|
1314
|
-
...(patch ? { blocks } : {}),
|
|
1345
|
+
...(patch ? { blocks: listed } : {}),
|
|
1315
1346
|
})
|
|
1316
1347
|
}
|
|
1317
1348
|
return out
|
|
@@ -1706,7 +1737,12 @@ async function stateOf(ctx, sessionId, config) {
|
|
|
1706
1737
|
// path (a chip that may draw its own thumbnail); both are answered from a
|
|
1707
1738
|
// live service probe, so the editor never promises what the host cannot do.
|
|
1708
1739
|
capabilities: { attachments: store !== null, preview: canPreviewAttachments(store) },
|
|
1709
|
-
config: {
|
|
1740
|
+
config: {
|
|
1741
|
+
confirm: config.confirm,
|
|
1742
|
+
replyMode: config.replyMode,
|
|
1743
|
+
patch: config.patch === true,
|
|
1744
|
+
editorStyle: config.editorStyle,
|
|
1745
|
+
},
|
|
1710
1746
|
version: PLUGIN_VERSION,
|
|
1711
1747
|
}
|
|
1712
1748
|
}
|
|
@@ -1809,7 +1845,88 @@ export function buildCorrection(plan, text, turn, step) {
|
|
|
1809
1845
|
}
|
|
1810
1846
|
}
|
|
1811
1847
|
|
|
1848
|
+
/**
|
|
1849
|
+
* The all-blocks save: one plugin record carrying a target per edited block,
|
|
1850
|
+
* possibly across several messages of the same turn. Each target is planned
|
|
1851
|
+
* exactly like a single-block save, and unchanged blocks are simply left out.
|
|
1852
|
+
*
|
|
1853
|
+
* @param ctx - cordis context.
|
|
1854
|
+
* @param sessionId - the addressed session.
|
|
1855
|
+
* @param body - `{ targets: [{ seq, blockIndex, kind, text }] }`.
|
|
1856
|
+
* @param config - plugin config.
|
|
1857
|
+
* @returns the same shape `/apply` returns for a reply patch.
|
|
1858
|
+
*/
|
|
1859
|
+
async function applyTargetsEdit(ctx, sessionId, body, config) {
|
|
1860
|
+
const targets = Array.isArray(body.targets) ? body.targets : []
|
|
1861
|
+
if (targets.length === 0 || targets.length > MAX_PARTS) {
|
|
1862
|
+
throw new HttpError(400, 'invalid', 'targets must be a nonempty, bounded array')
|
|
1863
|
+
}
|
|
1864
|
+
for (const target of targets) {
|
|
1865
|
+
if (target === null || typeof target !== 'object') throw new HttpError(400, 'invalid', 'each target must be an object')
|
|
1866
|
+
if (typeof target.text !== 'string' || target.text.trim() === '') throw new HttpError(400, 'invalid', 'an edited block cannot be empty')
|
|
1867
|
+
if (target.text.length > MAX_TEXT) throw new HttpError(400, 'invalid', 'the revised block is too long')
|
|
1868
|
+
}
|
|
1869
|
+
const session = await resolveSession(ctx, sessionId)
|
|
1870
|
+
if (!session || typeof session.append !== 'function') {
|
|
1871
|
+
throw new HttpError(409, 'session-not-active', 'the session is not open in DSH')
|
|
1872
|
+
}
|
|
1873
|
+
const events = await readEvents(ctx, sessionId)
|
|
1874
|
+
if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
|
|
1875
|
+
// No busy gate here: this path only appends the in-place patch record, which
|
|
1876
|
+
// leaves every surface node where it is. A running turn keeps writing its own
|
|
1877
|
+
// events; the record lands between them and the fold ignores it.
|
|
1878
|
+
const surfaceNodes = surfaceOf(ctx, sessionId, events)
|
|
1879
|
+
const patches = patchesOf(events)
|
|
1880
|
+
const plans = []
|
|
1881
|
+
for (const target of targets) {
|
|
1882
|
+
let plan
|
|
1883
|
+
try {
|
|
1884
|
+
plan = planRollback(events, surfaceNodes, { seq: target.seq, blockIndex: target.blockIndex, kind: target.kind }, { patches, patch: config.patch === true })
|
|
1885
|
+
} catch (error) {
|
|
1886
|
+
if (error instanceof EditPlanError) {
|
|
1887
|
+
const status = error.code === 'not-editable' ? 400 : 409
|
|
1888
|
+
throw new HttpError(status, error.code, error.message)
|
|
1889
|
+
}
|
|
1890
|
+
throw error
|
|
1891
|
+
}
|
|
1892
|
+
if (plan.patch !== true) throw new HttpError(400, 'not-editable', 'all-blocks editing needs the in-place reply path')
|
|
1893
|
+
if (target.text !== plan.original) plans.push({ plan, text: target.text })
|
|
1894
|
+
}
|
|
1895
|
+
if (plans.length === 0) {
|
|
1896
|
+
return { kind: 'reply', applied: false, unchanged: true, patch: true, shadowed: [], targets: [] }
|
|
1897
|
+
}
|
|
1898
|
+
if (typeof sessionApi.appendPluginRecord !== 'function') {
|
|
1899
|
+
throw new HttpError(409, 'unsupported', 'this harness does not expose plugin records; reply edits need the replay path')
|
|
1900
|
+
}
|
|
1901
|
+
let recordSeq
|
|
1902
|
+
try {
|
|
1903
|
+
recordSeq = sessionApi.appendPluginRecord(
|
|
1904
|
+
session,
|
|
1905
|
+
PATCH_TYPE,
|
|
1906
|
+
buildPatchData(plans.map(({ plan, text }) => ({ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }))),
|
|
1907
|
+
)
|
|
1908
|
+
} catch (error) {
|
|
1909
|
+
const message = String((error && error.message) || error)
|
|
1910
|
+
if (isProjectionUnavailable(message)) {
|
|
1911
|
+
throw new HttpError(409, 'projection-missing', 'the edit-turn projection is not registered for this session; restart DSH so the owning plugin registers it, then retry')
|
|
1912
|
+
}
|
|
1913
|
+
throw new HttpError(409, 'stale', `the session refused the patch record: ${message}`)
|
|
1914
|
+
}
|
|
1915
|
+
const flush = await flushSession(ctx, session)
|
|
1916
|
+
return {
|
|
1917
|
+
kind: 'reply',
|
|
1918
|
+
applied: true,
|
|
1919
|
+
mode: 'patch',
|
|
1920
|
+
patch: true,
|
|
1921
|
+
recordSeq,
|
|
1922
|
+
targets: plans.map(({ plan, text }) => ({ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text, original: plan.original })),
|
|
1923
|
+
shadowed: [],
|
|
1924
|
+
...flush,
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1812
1928
|
async function applyEdit(ctx, sessionId, body, config) {
|
|
1929
|
+
if (Array.isArray(body.targets) && body.targets.length > 0) return applyTargetsEdit(ctx, sessionId, body, config)
|
|
1813
1930
|
const text = typeof body.text === 'string' ? body.text : ''
|
|
1814
1931
|
if (text.trim() === '') throw new HttpError(400, 'invalid', 'the revised message cannot be empty')
|
|
1815
1932
|
if (text.length > MAX_TEXT) throw new HttpError(400, 'invalid', 'the revised message is too long')
|
|
@@ -1820,20 +1937,23 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1820
1937
|
}
|
|
1821
1938
|
const events = await readEvents(ctx, sessionId)
|
|
1822
1939
|
if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
|
|
1823
|
-
|
|
1940
|
+
const busy = isBusy(events)
|
|
1824
1941
|
|
|
1825
1942
|
const surfaceNodes = surfaceOf(ctx, sessionId, events)
|
|
1826
1943
|
const patches = patchesOf(events)
|
|
1827
1944
|
const patchMode = config.patch === true
|
|
1945
|
+
const request = {
|
|
1946
|
+
// A block from another step of the same turn names its own seq; that is
|
|
1947
|
+
// the message the projection has to patch.
|
|
1948
|
+
seq: typeof body.targetSeq === 'number' ? body.targetSeq : typeof body.seq === 'number' ? body.seq : undefined,
|
|
1949
|
+
messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
|
|
1950
|
+
turn: typeof body.turn === 'number' ? body.turn : undefined,
|
|
1951
|
+
blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
|
|
1952
|
+
kind: typeof body.kind === 'string' ? body.kind : undefined,
|
|
1953
|
+
}
|
|
1828
1954
|
let plan
|
|
1829
1955
|
try {
|
|
1830
|
-
plan = planRollback(events, surfaceNodes, {
|
|
1831
|
-
seq: typeof body.seq === 'number' ? body.seq : undefined,
|
|
1832
|
-
messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
|
|
1833
|
-
turn: typeof body.turn === 'number' ? body.turn : undefined,
|
|
1834
|
-
blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
|
|
1835
|
-
kind: typeof body.kind === 'string' ? body.kind : undefined,
|
|
1836
|
-
}, { patches, patch: patchMode })
|
|
1956
|
+
plan = planRollback(events, surfaceNodes, request, { patches, patch: patchMode })
|
|
1837
1957
|
} catch (error) {
|
|
1838
1958
|
if (error instanceof EditPlanError) {
|
|
1839
1959
|
const status = error.code === 'not-editable' ? 400 : 409
|
|
@@ -1842,6 +1962,13 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1842
1962
|
throw error
|
|
1843
1963
|
}
|
|
1844
1964
|
|
|
1965
|
+
// A running turn is a hard stop for every path that moves the surface: the
|
|
1966
|
+
// prompt rewrite shadows a message the turn may still be reading, and the
|
|
1967
|
+
// replay splice shadows a window and regenerates the tail. The in-place record
|
|
1968
|
+
// below moves nothing, so it is allowed to land while the agent is still
|
|
1969
|
+
// writing - the model reads it on its next fold.
|
|
1970
|
+
if (busy && plan.patch !== true) throw new HttpError(409, 'busy', 'the session is still working')
|
|
1971
|
+
|
|
1845
1972
|
// The in-place reply path: one small plugin record, interpreted by this
|
|
1846
1973
|
// plugin's projection. Nothing leaves the surface, so there is no window to
|
|
1847
1974
|
// check, no turn to open and no tail to replay. The record is append-only and
|
|
@@ -1871,8 +1998,19 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1871
1998
|
buildPatchData([{ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }]),
|
|
1872
1999
|
)
|
|
1873
2000
|
} catch (error) {
|
|
1874
|
-
|
|
2001
|
+
const message = String((error && error.message) || error)
|
|
2002
|
+
// The projection is not registered for this session (a live reload that
|
|
2003
|
+
// dropped it). The same edit can land through the replay path, which
|
|
2004
|
+
// needs no projection - so fall through instead of failing the save.
|
|
2005
|
+
if (isProjectionUnavailable(message)) {
|
|
2006
|
+
// The fallback replays the tail, which a running turn forbids.
|
|
2007
|
+
if (busy) throw new HttpError(409, 'busy', 'the session is still working')
|
|
2008
|
+
plan = planRollback(events, surfaceNodes, request, { patches, patch: false })
|
|
2009
|
+
} else {
|
|
2010
|
+
throw new HttpError(409, 'stale', `the session refused the patch record: ${message}`)
|
|
2011
|
+
}
|
|
1875
2012
|
}
|
|
2013
|
+
if (plan.patch === true) {
|
|
1876
2014
|
const flush = await flushSession(ctx, session)
|
|
1877
2015
|
return {
|
|
1878
2016
|
kind: 'reply',
|
|
@@ -1890,6 +2028,7 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1890
2028
|
shadowed: [],
|
|
1891
2029
|
...flush,
|
|
1892
2030
|
}
|
|
2031
|
+
}
|
|
1893
2032
|
}
|
|
1894
2033
|
|
|
1895
2034
|
// The live surface is the append authority; a node that vanished between the
|
|
@@ -2324,9 +2463,25 @@ function requireSessionId(value) {
|
|
|
2324
2463
|
return value
|
|
2325
2464
|
}
|
|
2326
2465
|
|
|
2466
|
+
/**
|
|
2467
|
+
* Whether the host refused a plugin record because its projection is no longer
|
|
2468
|
+
* registered for this session (a live reload that dropped it, or a session
|
|
2469
|
+
* loaded before the owning plugin). Such a save can still go through the replay
|
|
2470
|
+
* path, so it is not fatal.
|
|
2471
|
+
*
|
|
2472
|
+
* @param message - the host's error text.
|
|
2473
|
+
* @returns true when the record's projection is gone.
|
|
2474
|
+
*/
|
|
2475
|
+
export function isProjectionUnavailable(message) {
|
|
2476
|
+
return typeof message === 'string' && /projection .*was removed or replaced/i.test(message)
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2327
2479
|
function failure(res, error) {
|
|
2328
2480
|
const status = error instanceof HttpError ? error.status : 500
|
|
2329
2481
|
const code = error instanceof HttpError ? error.code : 'internal'
|
|
2482
|
+
// An unexpected throw is the only case the client cannot explain; leave the
|
|
2483
|
+
// stack in the host log so a report can name the cause.
|
|
2484
|
+
if (!(error instanceof HttpError)) console.error('[dsh-edit-turn] internal error', error)
|
|
2330
2485
|
sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
|
|
2331
2486
|
}
|
|
2332
2487
|
|
|
@@ -2341,6 +2496,9 @@ export function apply(ctx, config) {
|
|
|
2341
2496
|
carrier: config && config.carrier === 'user/message' ? 'user/message' : 'system/message',
|
|
2342
2497
|
confirm: config && typeof config.confirm === 'boolean' ? config.confirm : false,
|
|
2343
2498
|
replyMode: config && (config.replyMode === 'replay' || config.replyMode === 'patch') ? config.replyMode : 'auto',
|
|
2499
|
+
editorStyle: config && (config.editorStyle === 'bubble' || config.editorStyle === 'center')
|
|
2500
|
+
? config.editorStyle
|
|
2501
|
+
: 'frame',
|
|
2344
2502
|
}
|
|
2345
2503
|
|
|
2346
2504
|
// 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.2",
|
|
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",
|