dsh-edit-turn 0.2.20 → 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 +99 -29
- package/lib/client.js +412 -17
- package/lib/index.js +693 -94
- 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
|
// ---------------------------------------------------------------------------
|
|
@@ -237,6 +256,44 @@ export function turnIndex(events) {
|
|
|
237
256
|
return turnOf
|
|
238
257
|
}
|
|
239
258
|
|
|
259
|
+
/**
|
|
260
|
+
* One turn bracket: its number, log range, first prompt and reply count.
|
|
261
|
+
*
|
|
262
|
+
* A turn NUMBER is not unique in a log - a replay reuses numbers on fresh
|
|
263
|
+
* brackets - so planning must locate the bracket that CONTAINS an event rather
|
|
264
|
+
* than the first bracket carrying its number (the same rule dsh-rerun-turn
|
|
265
|
+
* learned in 0.1.23; this is the same walk).
|
|
266
|
+
*
|
|
267
|
+
* @param events - complete contiguous raw event log.
|
|
268
|
+
* @returns one entry per `turn/start`, in log order.
|
|
269
|
+
*/
|
|
270
|
+
export function turnSpans(events) {
|
|
271
|
+
const spans = []
|
|
272
|
+
let open = null
|
|
273
|
+
for (const event of events) {
|
|
274
|
+
if (event.type === 'turn/start') {
|
|
275
|
+
open = {
|
|
276
|
+
turn: event.data && event.data.turn,
|
|
277
|
+
startSeq: event.seq,
|
|
278
|
+
endSeq: null,
|
|
279
|
+
promptSeq: null,
|
|
280
|
+
replies: 0,
|
|
281
|
+
}
|
|
282
|
+
spans.push(open)
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
if (open === null) continue
|
|
286
|
+
if (event.type === 'turn/end') {
|
|
287
|
+
open.endSeq = event.seq
|
|
288
|
+
open = null
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
if (event.type === 'user/message' && open.promptSeq === null) open.promptSeq = event.seq
|
|
292
|
+
if (event.type === 'assistant/message') open.replies += 1
|
|
293
|
+
}
|
|
294
|
+
return spans
|
|
295
|
+
}
|
|
296
|
+
|
|
240
297
|
/** The turn still awaiting its `turn/end`, or null when every turn closed. */
|
|
241
298
|
export function openTurn(events) {
|
|
242
299
|
let open = null
|
|
@@ -710,21 +767,27 @@ function resolveTarget(events, request) {
|
|
|
710
767
|
}
|
|
711
768
|
|
|
712
769
|
/**
|
|
713
|
-
* Turn one UI target into the canonical rollback window
|
|
770
|
+
* Turn one UI target into the canonical rollback window plus, for a reply, the
|
|
771
|
+
* replay range that puts the later turns back after the correction.
|
|
714
772
|
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
773
|
+
* A revised PROMPT is replaced in place: its window is the message alone, so
|
|
774
|
+
* the reply under it and everything after stay exactly where they were.
|
|
775
|
+
*
|
|
776
|
+
* A revised REPLY cannot be swapped in place - the format refuses
|
|
777
|
+
* `sourceEventSeqs` on an `assistant/message`, so a new reply can only be
|
|
778
|
+
* APPENDED. The window therefore runs from the addressed reply to the last
|
|
779
|
+
* surface node, and the correction is appended in a fresh turn. Everything the
|
|
780
|
+
* window took is replayed back behind the correction by
|
|
781
|
+
* {@link buildReplayWrites}: the same infix splice dsh-rerun-turn performs, with
|
|
782
|
+
* the user's text instead of a regenerated answer.
|
|
720
783
|
*
|
|
721
784
|
* @param events - complete contiguous raw event log.
|
|
722
785
|
* @param surfaceNodes - current surface seqs in model order.
|
|
723
786
|
* @param request - `{ seq?, messageId?, turn? }` (`turn` addresses a prompt only).
|
|
724
|
-
* @returns `{ targetSeq, messageId, startSeq, endSeq, shadowed, mode, turn, step, provider, model, original, attachments }`.
|
|
787
|
+
* @returns `{ targetSeq, messageId, startSeq, endSeq, shadowed, mode, turn, step, provider, model, original, attachments, replayFrom, logTo, turnStart, turnEnd }`.
|
|
725
788
|
* @throws {EditPlanError} with a stable code when the target cannot be planned.
|
|
726
789
|
*/
|
|
727
|
-
export function planRollback(events, surfaceNodes, request) {
|
|
790
|
+
export function planRollback(events, surfaceNodes, request, options = {}) {
|
|
728
791
|
const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
|
|
729
792
|
const turnOf = turnIndex(events)
|
|
730
793
|
const target = resolveTarget(events, request, turnOf)
|
|
@@ -737,31 +800,112 @@ export function planRollback(events, surfaceNodes, request) {
|
|
|
737
800
|
throw new EditPlanError('not-editable', 'the system prompt head cannot be edited')
|
|
738
801
|
}
|
|
739
802
|
// Two editable kinds. A prompt is rewritten in place and the conversation
|
|
740
|
-
// continues; a reply is
|
|
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.
|
|
741
805
|
const mode = isHumanPrompt(target) ? 'prompt' : isAssistantReply(target) ? 'reply' : null
|
|
742
806
|
if (mode === null) {
|
|
743
807
|
throw new EditPlanError('not-editable', 'only your own messages and the model replies can be edited')
|
|
744
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
|
+
}
|
|
745
853
|
const startIdx = nodeIndex.get(target.seq)
|
|
746
854
|
// Two different windows, because the two edits mean different things.
|
|
747
855
|
//
|
|
748
856
|
// A revised PROMPT is replaced in place: only the message itself leaves the
|
|
749
|
-
// surface, so the reply under it and everything after stay exactly
|
|
750
|
-
// were - edit the wording, keep the conversation.
|
|
751
|
-
//
|
|
752
|
-
//
|
|
857
|
+
// surface, so the reply under it and everything after stay exactly where they
|
|
858
|
+
// were - edit the wording, keep the conversation.
|
|
859
|
+
//
|
|
860
|
+
// A revised REPLY cannot be a replacement carrier (the format refuses
|
|
861
|
+
// `sourceEventSeqs` on an assistant message), so the correction is appended
|
|
862
|
+
// at the tail and everything from the addressed reply to the end must leave
|
|
863
|
+
// the surface first. `buildReplayWrites` then puts every later turn back,
|
|
864
|
+
// event by event, with fresh turn numbers and message ids - the model reads
|
|
865
|
+
// prefix + corrected reply + the same later turns, in order.
|
|
753
866
|
const shadowed = mode === 'prompt' ? [target.seq] : surfaceNodes.slice(startIdx)
|
|
754
867
|
if (shadowed.length === 0 || shadowed[0] !== target.seq) {
|
|
755
868
|
throw new EditPlanError('not-editable', 'the target does not open a rollback window')
|
|
756
869
|
}
|
|
870
|
+
// The replay range: the log range the shadow covers, and the bracket where
|
|
871
|
+
// the later turns start. A reply in the last turn has neither, and the edit
|
|
872
|
+
// degenerates to the old tail-less rollback (there is nothing to replay).
|
|
873
|
+
let replayFrom = null
|
|
874
|
+
let logTo = null
|
|
875
|
+
let turnStart = null
|
|
876
|
+
let turnEnd = null
|
|
877
|
+
if (mode === 'reply') {
|
|
878
|
+
const spans = turnSpans(events)
|
|
879
|
+
// A closed bracket that CONTAINS the reply. The fallback (the last bracket
|
|
880
|
+
// that starts before it) only matters for a malformed log with a bracket
|
|
881
|
+
// that never closed; a valid log always takes the containment branch.
|
|
882
|
+
const owning = spans.find(
|
|
883
|
+
(entry) => entry.endSeq !== null && entry.startSeq <= target.seq && target.seq <= entry.endSeq,
|
|
884
|
+
) ?? spans.filter((entry) => entry.startSeq <= target.seq).at(-1)
|
|
885
|
+
if (owning !== undefined) {
|
|
886
|
+
turnStart = owning.startSeq
|
|
887
|
+
turnEnd = owning.endSeq
|
|
888
|
+
}
|
|
889
|
+
const lastShadowed = shadowed[shadowed.length - 1]
|
|
890
|
+
const bySeq = new Map(events.map((event) => [event.seq, event]))
|
|
891
|
+
logTo = lastShadowed
|
|
892
|
+
for (let seq = lastShadowed + 1; ; seq += 1) {
|
|
893
|
+
const event = bySeq.get(seq)
|
|
894
|
+
if (event === undefined) break
|
|
895
|
+
// The window already ends on the last surface node: only log-only events
|
|
896
|
+
// (step/turn closing records) can follow it.
|
|
897
|
+
if (isSurfaceEvent(event)) break
|
|
898
|
+
logTo = seq
|
|
899
|
+
if (event.type === 'turn/end') break
|
|
900
|
+
}
|
|
901
|
+
const owningIndex = owning === undefined ? -1 : spans.indexOf(owning)
|
|
902
|
+
const nextSpan = owningIndex >= 0 ? spans[owningIndex + 1] : undefined
|
|
903
|
+
if (nextSpan !== undefined && nextSpan.startSeq <= logTo) replayFrom = nextSpan.startSeq
|
|
904
|
+
}
|
|
757
905
|
const { text, attachments } = readMessageText(target)
|
|
758
906
|
// The blocks themselves travel with the plan: the carrier is rebuilt from
|
|
759
907
|
// them, so no caller has to re-read the message to know what it carries.
|
|
760
|
-
const blocks =
|
|
761
|
-
const turn = turnOf.get(target.seq)
|
|
762
|
-
const data = target.data && typeof target.data === 'object' ? target.data : {}
|
|
763
|
-
const message = data.message && typeof data.message === 'object' ? data.message : data
|
|
764
|
-
const source = message.source && typeof message.source === 'object' ? message.source : null
|
|
908
|
+
const blocks = contentBlocks
|
|
765
909
|
return {
|
|
766
910
|
targetSeq: target.seq,
|
|
767
911
|
messageId: messageIdOf(target),
|
|
@@ -769,18 +913,305 @@ export function planRollback(events, surfaceNodes, request) {
|
|
|
769
913
|
endSeq: shadowed[shadowed.length - 1],
|
|
770
914
|
shadowed,
|
|
771
915
|
mode,
|
|
916
|
+
patch: false,
|
|
772
917
|
turn: typeof turn === 'number' ? turn : null,
|
|
773
918
|
// The appended correction is keyed by turn:step in the client's reducer, so a
|
|
774
919
|
// missing step would make two corrections collide on "undefined:undefined".
|
|
775
|
-
step
|
|
920
|
+
step,
|
|
776
921
|
provider: source && typeof source.provider === 'string' ? source.provider : null,
|
|
777
922
|
model: source && typeof source.model === 'string' ? source.model : null,
|
|
778
923
|
original: text,
|
|
779
924
|
attachments,
|
|
780
925
|
blocks,
|
|
926
|
+
// Reply-only replay range. `replayFrom` is the next turn's bracket (null when
|
|
927
|
+
// the edited reply is in the last turn); `logTo` is the end of the last turn
|
|
928
|
+
// the window shadowed. Both are computed from the ORIGINAL log and stay valid
|
|
929
|
+
// while the correction is appended after it.
|
|
930
|
+
replayFrom,
|
|
931
|
+
logTo,
|
|
932
|
+
turnStart,
|
|
933
|
+
turnEnd,
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// --- reply replay -----------------------------------------------------------
|
|
938
|
+
//
|
|
939
|
+
// A reply edit lands exactly like a rerun's infix splice: the shadow takes the
|
|
940
|
+
// old answer and every later turn off the surface, the correction is appended in
|
|
941
|
+
// a fresh turn, and this walk puts the later turns back as fresh copies. The
|
|
942
|
+
// algorithm is the one dsh-rerun-turn ships (`buildReplayWrites` there); keep the
|
|
943
|
+
// two in step - `test/replay-parity.test.js` compares their output on one log
|
|
944
|
+
// when the sibling repository is checked out.
|
|
945
|
+
|
|
946
|
+
/** Marker written into every replayed event's source. */
|
|
947
|
+
export const REPLAY_MARKER = PLUGIN_ID
|
|
948
|
+
|
|
949
|
+
/** Text that carries nothing a model could read (spaces, zero-width glue). */
|
|
950
|
+
const INVISIBLE_TEXT_RE = /^[\s\u200b-\u200f\u2060\ufeff]*$/
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Whether content blocks carry anything readable.
|
|
954
|
+
* @param content - the `content` array of a message.
|
|
955
|
+
* @returns true when at least one block is not invisible text.
|
|
956
|
+
*/
|
|
957
|
+
export function hasReadableContent(content) {
|
|
958
|
+
if (!Array.isArray(content)) return false
|
|
959
|
+
for (const block of content) {
|
|
960
|
+
if (!block || typeof block !== 'object') continue
|
|
961
|
+
if (block.type !== 'text') return true
|
|
962
|
+
if (typeof block.text !== 'string' || !INVISIBLE_TEXT_RE.test(block.text)) return true
|
|
963
|
+
}
|
|
964
|
+
return false
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Whether an event is a plugin's silent surface carrier rather than conversation.
|
|
969
|
+
*
|
|
970
|
+
* `dsh-delete-turn` writes one for a deletion and `dsh-rerun-turn` for a rerun's
|
|
971
|
+
* shadow - a `user/message` whose source kind is `plugin:<id>` and whose content
|
|
972
|
+
* is empty or invisible. A replay must retire it without a copy: it projects to
|
|
973
|
+
* no model message, so copying it would resurrect an empty user node.
|
|
974
|
+
*
|
|
975
|
+
* A rewritten prompt is NOT this shape: `dsh-edit-turn` keeps `kind: 'user'` and
|
|
976
|
+
* carries the user's wording, so it stays a real prompt and is copied.
|
|
977
|
+
*
|
|
978
|
+
* @param event - raw session event.
|
|
979
|
+
* @returns true for a text-free plugin replacement.
|
|
980
|
+
*/
|
|
981
|
+
export function isSilentPluginCarrier(event) {
|
|
982
|
+
if (!event || event.type !== 'user/message') return false
|
|
983
|
+
const data = event.data || {}
|
|
984
|
+
const source = data.source
|
|
985
|
+
if (!source || typeof source.kind !== 'string' || !source.kind.startsWith('plugin:')) return false
|
|
986
|
+
return !hasReadableContent(data.content)
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** Stamp one copied event's source with this plugin's replay markers. */
|
|
990
|
+
function replaySource(source, replayId, originalSeq) {
|
|
991
|
+
return {
|
|
992
|
+
...(source && typeof source === 'object' ? source : {}),
|
|
993
|
+
replayBy: REPLAY_MARKER,
|
|
994
|
+
replayId,
|
|
995
|
+
originalSeq,
|
|
781
996
|
}
|
|
782
997
|
}
|
|
783
998
|
|
|
999
|
+
/**
|
|
1000
|
+
* Build the replay writes for a reply edit's shadowed tail.
|
|
1001
|
+
*
|
|
1002
|
+
* Every shadowed event after the edited reply is copied verbatim except for:
|
|
1003
|
+
* fresh turn numbers (the log requires them contiguous), fresh message ids,
|
|
1004
|
+
* remapped `sourceEventSeqs` on tool results, dropped usage numbers and embedded
|
|
1005
|
+
* streams, and the replay marker in each message source. System, developer and
|
|
1006
|
+
* every log-only record are skipped: they describe the original run, not the
|
|
1007
|
+
* conversation.
|
|
1008
|
+
*
|
|
1009
|
+
* Tool calls are copied only as complete pairs. A call whose result never landed
|
|
1010
|
+
* (an interrupted turn) is dropped along with its advertisement in the copied
|
|
1011
|
+
* assistant content, because the format refuses a turn that closes with an
|
|
1012
|
+
* unresolved call. The inverse repair shape - a `TOOL_NOT_STARTED` result with no
|
|
1013
|
+
* call event - is copied as-is and keeps its absent `sourceEventSeqs`: the
|
|
1014
|
+
* validator accepts that repair only without one.
|
|
1015
|
+
*
|
|
1016
|
+
* @param events - complete contiguous raw event log (the pre-edit snapshot).
|
|
1017
|
+
* @param plan - the planned window (`shadowed`, `replayFrom`, `logTo`).
|
|
1018
|
+
* @param baseTurn - the first fresh turn number the replay may use.
|
|
1019
|
+
* @param replayId - the operation identity recorded on every copy.
|
|
1020
|
+
* @param startSeq - the seq the first write will land at (writes are appended
|
|
1021
|
+
* back to back, so each predicted seq is `startSeq` + its offset).
|
|
1022
|
+
* @param promptCopied - parity with dsh-rerun-turn's builder: when true, the
|
|
1023
|
+
* plan's `promptSeq` is replayed too. A reply edit never shadows the prompt,
|
|
1024
|
+
* so its own plans leave this false.
|
|
1025
|
+
* @returns an ordered `{ type, data, surfaceOp?, sourceEventSeqs? }` write list.
|
|
1026
|
+
*/
|
|
1027
|
+
export function buildReplayWrites(events, plan, baseTurn, replayId, startSeq, promptCopied = false) {
|
|
1028
|
+
const shadowedSet = new Set(plan.shadowed)
|
|
1029
|
+
const bySeq = new Map(events.map((event) => [event.seq, event]))
|
|
1030
|
+
const turnOf = turnIndex(events)
|
|
1031
|
+
const shadowedTurns = new Set()
|
|
1032
|
+
for (const seq of plan.shadowed) {
|
|
1033
|
+
const turn = turnOf.get(seq)
|
|
1034
|
+
if (typeof turn === 'number') shadowedTurns.add(turn)
|
|
1035
|
+
}
|
|
1036
|
+
const included = (event) => {
|
|
1037
|
+
if (isSurfaceEvent(event)) return shadowedSet.has(event.seq)
|
|
1038
|
+
if (
|
|
1039
|
+
event.type === 'turn/start' ||
|
|
1040
|
+
event.type === 'turn/end' ||
|
|
1041
|
+
event.type === 'step/start' ||
|
|
1042
|
+
event.type === 'step/end' ||
|
|
1043
|
+
event.type === 'tool/call'
|
|
1044
|
+
) {
|
|
1045
|
+
return typeof event.data.turn === 'number' && shadowedTurns.has(event.data.turn)
|
|
1046
|
+
}
|
|
1047
|
+
return false
|
|
1048
|
+
}
|
|
1049
|
+
const range = []
|
|
1050
|
+
// `replayFrom` is this plugin's plan field; `logFrom` is the name the sibling
|
|
1051
|
+
// builder uses, accepted here so the parity test can feed one plan object.
|
|
1052
|
+
const walkFrom = typeof plan.replayFrom === 'number' ? plan.replayFrom : plan.logFrom
|
|
1053
|
+
for (let seq = walkFrom; seq <= plan.logTo; seq += 1) {
|
|
1054
|
+
const event = bySeq.get(seq)
|
|
1055
|
+
if (event === undefined) continue
|
|
1056
|
+
if (included(event)) range.push(event)
|
|
1057
|
+
}
|
|
1058
|
+
// Tool pairing facts for the whole range, so assistant content can be
|
|
1059
|
+
// corrected before anything is written.
|
|
1060
|
+
const calledIds = new Set()
|
|
1061
|
+
const resultIds = new Set()
|
|
1062
|
+
const repairIds = new Set()
|
|
1063
|
+
for (const event of range) {
|
|
1064
|
+
if (event.type === 'tool/call') calledIds.add(event.data.callId)
|
|
1065
|
+
else if (event.type === 'tool/result') {
|
|
1066
|
+
resultIds.add(event.data.message && event.data.message.source ? event.data.message.source.callId : event.data.callId)
|
|
1067
|
+
const error = event.data.error
|
|
1068
|
+
if (error && error.name === 'ToolNotStartedError' && error.code === 'TOOL_NOT_STARTED' && event.sourceEventSeqs === undefined) {
|
|
1069
|
+
repairIds.add(event.data.message && event.data.message.source ? event.data.message.source.callId : event.data.callId)
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
const healthyIds = new Set()
|
|
1074
|
+
for (const id of calledIds) if (resultIds.has(id)) healthyIds.add(id)
|
|
1075
|
+
for (const id of repairIds) if (!calledIds.has(id)) healthyIds.add(id)
|
|
1076
|
+
const pairedResultIds = new Set()
|
|
1077
|
+
for (const id of calledIds) if (resultIds.has(id)) pairedResultIds.add(id)
|
|
1078
|
+
for (const id of repairIds) if (!calledIds.has(id)) pairedResultIds.add(id)
|
|
1079
|
+
|
|
1080
|
+
const writes = []
|
|
1081
|
+
let predicted = startSeq
|
|
1082
|
+
let nextTurn = baseTurn
|
|
1083
|
+
const turnMap = new Map()
|
|
1084
|
+
const assistantSeqById = new Map()
|
|
1085
|
+
let lastAssistantSeq = null
|
|
1086
|
+
const seqOf = () => {
|
|
1087
|
+
const seq = predicted
|
|
1088
|
+
predicted += 1
|
|
1089
|
+
return seq
|
|
1090
|
+
}
|
|
1091
|
+
for (const event of range) {
|
|
1092
|
+
const data = event.data || {}
|
|
1093
|
+
if (event.type === 'turn/start') {
|
|
1094
|
+
turnMap.set(data.turn, nextTurn)
|
|
1095
|
+
writes.push({ type: 'turn/start', data: { turn: nextTurn } })
|
|
1096
|
+
seqOf()
|
|
1097
|
+
continue
|
|
1098
|
+
}
|
|
1099
|
+
if (event.type === 'turn/end') {
|
|
1100
|
+
writes.push({ type: 'turn/end', data: { turn: turnMap.get(data.turn), reason: data.reason } })
|
|
1101
|
+
seqOf()
|
|
1102
|
+
nextTurn += 1
|
|
1103
|
+
continue
|
|
1104
|
+
}
|
|
1105
|
+
if (event.type === 'step/start' || event.type === 'step/end') {
|
|
1106
|
+
writes.push({ type: event.type, data: { turn: turnMap.get(data.turn), step: data.step } })
|
|
1107
|
+
seqOf()
|
|
1108
|
+
continue
|
|
1109
|
+
}
|
|
1110
|
+
if (event.type === 'user/message') {
|
|
1111
|
+
if (event.seq === plan.promptSeq && promptCopied !== true) continue
|
|
1112
|
+
// A silent carrier standing where a prompt was is bookkeeping, not
|
|
1113
|
+
// conversation: retire it without a copy, exactly as rerun does.
|
|
1114
|
+
if (isSilentPluginCarrier(event)) continue
|
|
1115
|
+
writes.push({
|
|
1116
|
+
type: 'user/message',
|
|
1117
|
+
surfaceOp: 'append',
|
|
1118
|
+
data: {
|
|
1119
|
+
id: randomUUID(),
|
|
1120
|
+
role: 'user',
|
|
1121
|
+
content: data.content,
|
|
1122
|
+
source: replaySource(data.source, replayId, event.seq),
|
|
1123
|
+
},
|
|
1124
|
+
})
|
|
1125
|
+
seqOf()
|
|
1126
|
+
continue
|
|
1127
|
+
}
|
|
1128
|
+
if (event.type === 'assistant/message') {
|
|
1129
|
+
const message = data.message || {}
|
|
1130
|
+
const source = { ...(message.source || {}) }
|
|
1131
|
+
delete source.replayState
|
|
1132
|
+
const content = Array.isArray(message.content)
|
|
1133
|
+
? message.content.filter((block) => !(block && block.type === 'tool-call' && !healthyIds.has(block.id)))
|
|
1134
|
+
: []
|
|
1135
|
+
writes.push({
|
|
1136
|
+
type: 'assistant/message',
|
|
1137
|
+
surfaceOp: 'append',
|
|
1138
|
+
data: {
|
|
1139
|
+
turn: turnMap.get(data.turn),
|
|
1140
|
+
step: data.step,
|
|
1141
|
+
message: {
|
|
1142
|
+
id: randomUUID(),
|
|
1143
|
+
role: 'assistant',
|
|
1144
|
+
content,
|
|
1145
|
+
source: replaySource(source, replayId, event.seq),
|
|
1146
|
+
},
|
|
1147
|
+
stream: [],
|
|
1148
|
+
...(data.interrupted === true ? { interrupted: true } : {}),
|
|
1149
|
+
},
|
|
1150
|
+
})
|
|
1151
|
+
assistantSeqById.set(event.seq, predicted)
|
|
1152
|
+
lastAssistantSeq = predicted
|
|
1153
|
+
seqOf()
|
|
1154
|
+
continue
|
|
1155
|
+
}
|
|
1156
|
+
if (event.type === 'tool/call') {
|
|
1157
|
+
if (!pairedResultIds.has(data.callId)) continue
|
|
1158
|
+
writes.push({
|
|
1159
|
+
type: 'tool/call',
|
|
1160
|
+
data: { turn: turnMap.get(data.turn), step: data.step, callId: data.callId, name: data.name, arguments: data.arguments },
|
|
1161
|
+
})
|
|
1162
|
+
seqOf()
|
|
1163
|
+
continue
|
|
1164
|
+
}
|
|
1165
|
+
if (event.type === 'tool/result') {
|
|
1166
|
+
const callId = data.message && data.message.source ? data.message.source.callId : data.callId
|
|
1167
|
+
if (!pairedResultIds.has(callId)) continue
|
|
1168
|
+
const message = data.message
|
|
1169
|
+
const source = replaySource(message && message.source, replayId, event.seq)
|
|
1170
|
+
// The TOOL_NOT_STARTED repair is accepted only without sourceEventSeqs;
|
|
1171
|
+
// ordinary results remap theirs onto the copied assistant message.
|
|
1172
|
+
let sourceEventSeqs
|
|
1173
|
+
if (!repairIds.has(callId)) {
|
|
1174
|
+
const remapped = Array.isArray(event.sourceEventSeqs)
|
|
1175
|
+
? event.sourceEventSeqs.map((seq) => assistantSeqById.get(seq)).filter((seq) => typeof seq === 'number')
|
|
1176
|
+
: []
|
|
1177
|
+
if (remapped.length > 0) sourceEventSeqs = remapped
|
|
1178
|
+
else if (typeof lastAssistantSeq === 'number') sourceEventSeqs = [lastAssistantSeq]
|
|
1179
|
+
}
|
|
1180
|
+
const originalMessage = message || {}
|
|
1181
|
+
const rest = { ...originalMessage }
|
|
1182
|
+
delete rest.id
|
|
1183
|
+
delete rest.content
|
|
1184
|
+
delete rest.source
|
|
1185
|
+
// The interrupted-repair carrier must keep its contract-shaped id
|
|
1186
|
+
// (`interrupted-tool-result-<callId>-<n>`): the validator refuses a repair
|
|
1187
|
+
// whose id does not match, and a random id breaks it. The suffix is the
|
|
1188
|
+
// copy's own predicted seq, so the identity stays self-describing.
|
|
1189
|
+
const messageId = repairIds.has(callId) ? `interrupted-tool-result-${callId}-${predicted}` : randomUUID()
|
|
1190
|
+
writes.push({
|
|
1191
|
+
type: 'tool/result',
|
|
1192
|
+
surfaceOp: 'append',
|
|
1193
|
+
...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }),
|
|
1194
|
+
data: {
|
|
1195
|
+
turn: turnMap.get(data.turn),
|
|
1196
|
+
step: data.step,
|
|
1197
|
+
message: {
|
|
1198
|
+
...rest,
|
|
1199
|
+
id: messageId,
|
|
1200
|
+
content: originalMessage.content ?? [],
|
|
1201
|
+
source,
|
|
1202
|
+
},
|
|
1203
|
+
...(data.error === undefined ? {} : { error: data.error }),
|
|
1204
|
+
...(data.meta === undefined ? {} : { meta: data.meta }),
|
|
1205
|
+
},
|
|
1206
|
+
})
|
|
1207
|
+
seqOf()
|
|
1208
|
+
continue
|
|
1209
|
+
}
|
|
1210
|
+
// system/message, developer/message and every log-only record are skipped.
|
|
1211
|
+
}
|
|
1212
|
+
return writes
|
|
1213
|
+
}
|
|
1214
|
+
|
|
784
1215
|
/**
|
|
785
1216
|
* Every human prompt that can currently be edited.
|
|
786
1217
|
*
|
|
@@ -826,34 +1257,48 @@ export function editableTurns(events, surfaceNodes, options = {}) {
|
|
|
826
1257
|
/**
|
|
827
1258
|
* Every model reply that can currently be rewritten.
|
|
828
1259
|
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
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.
|
|
833
1264
|
*
|
|
834
1265
|
* @param events - complete contiguous raw event log.
|
|
835
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.
|
|
836
1269
|
* @returns one entry per editable reply, in conversation order.
|
|
837
1270
|
*/
|
|
838
|
-
export function editableReplies(events, surfaceNodes) {
|
|
1271
|
+
export function editableReplies(events, surfaceNodes, options = {}) {
|
|
839
1272
|
const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
|
|
840
1273
|
const turnOf = turnIndex(events)
|
|
841
1274
|
const head = surfaceNodes[0]
|
|
1275
|
+
const patches = options.patches instanceof Map ? options.patches : new Map()
|
|
1276
|
+
const patch = options.patch === true
|
|
842
1277
|
const out = []
|
|
843
1278
|
for (const event of events) {
|
|
844
1279
|
if (!isAssistantReply(event)) continue
|
|
845
1280
|
if (!nodeIndex.has(event.seq)) continue
|
|
846
1281
|
if (event.seq === head) continue
|
|
847
|
-
const
|
|
848
|
-
|
|
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
|
|
849
1289
|
const turn = turnOf.get(event.seq)
|
|
850
1290
|
out.push({
|
|
851
1291
|
seq: event.seq,
|
|
852
1292
|
turn: typeof turn === 'number' ? turn : null,
|
|
853
1293
|
messageId: messageIdOf(event) ?? null,
|
|
854
1294
|
text,
|
|
855
|
-
// For a reply these are the
|
|
856
|
-
|
|
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 } : {}),
|
|
857
1302
|
})
|
|
858
1303
|
}
|
|
859
1304
|
return out
|
|
@@ -1199,13 +1644,14 @@ async function stateOf(ctx, sessionId, config) {
|
|
|
1199
1644
|
}
|
|
1200
1645
|
const folded = foldSurface(events)
|
|
1201
1646
|
const ledger = rollbackLedger(events)
|
|
1647
|
+
const patches = patchesOf(events)
|
|
1202
1648
|
const live = findLiveSession(ctx, sessionId)
|
|
1203
1649
|
// Probed per request: the editor offers what THIS deployment can do right
|
|
1204
1650
|
// now, so a store that is unmounted (or not yet mounted) cannot leave the
|
|
1205
1651
|
// editor promising to keep blocks the host would have to drop.
|
|
1206
1652
|
const store = resolveAttachmentStore(ctx)
|
|
1207
1653
|
const turns = editableTurns(events, folded.nodes, { preview: canPreviewAttachments(store) })
|
|
1208
|
-
const replies = editableReplies(events, folded.nodes)
|
|
1654
|
+
const replies = editableReplies(events, folded.nodes, { patches, patch: config.patch === true })
|
|
1209
1655
|
noteRequest({
|
|
1210
1656
|
kind: 'state',
|
|
1211
1657
|
sessionId,
|
|
@@ -1227,6 +1673,11 @@ async function stateOf(ctx, sessionId, config) {
|
|
|
1227
1673
|
surface: folded.nodes,
|
|
1228
1674
|
turns,
|
|
1229
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',
|
|
1230
1681
|
live: Boolean(live),
|
|
1231
1682
|
busy: isBusy(events),
|
|
1232
1683
|
lastSeq: events.length > 0 ? events[events.length - 1].seq : -1,
|
|
@@ -1235,7 +1686,7 @@ async function stateOf(ctx, sessionId, config) {
|
|
|
1235
1686
|
// path (a chip that may draw its own thumbnail); both are answered from a
|
|
1236
1687
|
// live service probe, so the editor never promises what the host cannot do.
|
|
1237
1688
|
capabilities: { attachments: store !== null, preview: canPreviewAttachments(store) },
|
|
1238
|
-
config: { confirm: config.confirm },
|
|
1689
|
+
config: { confirm: config.confirm, replyMode: config.replyMode, patch: config.patch === true },
|
|
1239
1690
|
version: PLUGIN_VERSION,
|
|
1240
1691
|
}
|
|
1241
1692
|
}
|
|
@@ -1352,13 +1803,17 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1352
1803
|
if (isBusy(events)) throw new HttpError(409, 'busy', 'the session is still working')
|
|
1353
1804
|
|
|
1354
1805
|
const surfaceNodes = surfaceOf(ctx, sessionId, events)
|
|
1806
|
+
const patches = patchesOf(events)
|
|
1807
|
+
const patchMode = config.patch === true
|
|
1355
1808
|
let plan
|
|
1356
1809
|
try {
|
|
1357
1810
|
plan = planRollback(events, surfaceNodes, {
|
|
1358
1811
|
seq: typeof body.seq === 'number' ? body.seq : undefined,
|
|
1359
1812
|
messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
|
|
1360
1813
|
turn: typeof body.turn === 'number' ? body.turn : undefined,
|
|
1361
|
-
|
|
1814
|
+
blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
|
|
1815
|
+
kind: typeof body.kind === 'string' ? body.kind : undefined,
|
|
1816
|
+
}, { patches, patch: patchMode })
|
|
1362
1817
|
} catch (error) {
|
|
1363
1818
|
if (error instanceof EditPlanError) {
|
|
1364
1819
|
const status = error.code === 'not-editable' ? 400 : 409
|
|
@@ -1367,6 +1822,56 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1367
1822
|
throw error
|
|
1368
1823
|
}
|
|
1369
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
|
+
|
|
1370
1875
|
// The live surface is the append authority; a node that vanished between the
|
|
1371
1876
|
// read and this check means another writer landed first.
|
|
1372
1877
|
for (const seq of plan.shadowed) {
|
|
@@ -1465,6 +1970,13 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1465
1970
|
// (it projects `lastTurn` from every turn/start it observes). A prompt edit
|
|
1466
1971
|
// does not: `user/message` carries the revision and needs no turn.
|
|
1467
1972
|
const editTurn = plan.mode === 'reply' ? nextTurnOf(events) : lastTurnOf(events)
|
|
1973
|
+
// One identity for the whole reply splice: every copy the replay appends
|
|
1974
|
+
// carries it, so a later reader (or a sibling plugin) can tell a replayed turn
|
|
1975
|
+
// from the one it retired without re-deriving anything.
|
|
1976
|
+
const replayId = plan.mode === 'reply' ? randomUUID() : null
|
|
1977
|
+
// Resolved BEFORE the appends so the loop counter can be re-pointed in the
|
|
1978
|
+
// same tick as the writes, not after an await that lets the loop start.
|
|
1979
|
+
const loopAgent = plan.mode === 'reply' ? await resolveLoopAgent(ctx, sessionId) : undefined
|
|
1468
1980
|
const carrier = buildCarrier(plan, editTurn, plan.mode === 'prompt' ? { ...config, carrier: 'user/message', markerText: text } : config, content)
|
|
1469
1981
|
if (plan.mode === 'reply') {
|
|
1470
1982
|
try {
|
|
@@ -1495,82 +2007,122 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1495
2007
|
} catch (error) {
|
|
1496
2008
|
throw new HttpError(409, 'stale', `the surface refused the rollback: ${String((error && error.message) || error)}`)
|
|
1497
2009
|
}
|
|
1498
|
-
const flush = await flushSession(ctx, session)
|
|
1499
2010
|
|
|
1500
|
-
//
|
|
1501
|
-
//
|
|
1502
|
-
//
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
2011
|
+
// A prompt edit saved here: the carrier that replaced the window IS the
|
|
2012
|
+
// revised prompt, so the model reads it as the user's wording from here on.
|
|
2013
|
+
// Nothing is prompted and nothing else needs writing.
|
|
2014
|
+
if (plan.mode === 'prompt') {
|
|
2015
|
+
const flush = await flushSession(ctx, session)
|
|
2016
|
+
return {
|
|
2017
|
+
kind: 'prompt',
|
|
2018
|
+
replacementSeq: replacement.seq,
|
|
2019
|
+
applied: true,
|
|
2020
|
+
shadowed: plan.shadowed,
|
|
2021
|
+
dropped,
|
|
2022
|
+
// What the carrier now carries, described exactly the way /state describes
|
|
2023
|
+
// it. The browser half adopts this for the row it re-draws before its own
|
|
2024
|
+
// refresh lands - without it a second save could not know which blocks are
|
|
2025
|
+
// still there and would rewrite the message text-only.
|
|
2026
|
+
blocks: blockSummaries(content, canPreviewAttachments(store)),
|
|
2027
|
+
...flush,
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// Step two of a reply edit: the correction itself, inside the turn just
|
|
2032
|
+
// opened. An `assistant/message` may never carry `sourceEventSeqs`, so it can
|
|
2033
|
+
// never be a replacement carrier (checked against the real validator, not
|
|
2034
|
+
// assumed); it is appended in the same shape the agent loop records a reply,
|
|
2035
|
+
// and the model goes on seeing the edited text as its own answer.
|
|
2036
|
+
const correction = buildCorrection(plan, text, editTurn, 1)
|
|
2037
|
+
let appended
|
|
2038
|
+
try {
|
|
2039
|
+
appended = session.append(correction.type, correction.data, { surfaceOp: 'append' })
|
|
2040
|
+
// Close the step and the turn behind the corrected reply: the host anchors
|
|
2041
|
+
// a turn's tail (duration, action strip) at its last `turn/end`, and the
|
|
2042
|
+
// format only reads a step message inside an open turn and step.
|
|
2043
|
+
session.append('step/end', { turn: editTurn, step: 1 })
|
|
2044
|
+
session.append('turn/end', { turn: editTurn, reason: { kind: 'completed' } })
|
|
2045
|
+
} catch (error) {
|
|
2046
|
+
const failedFlush = await flushSession(ctx, session)
|
|
2047
|
+
return {
|
|
2048
|
+
kind: 'reply',
|
|
2049
|
+
replacementSeq: replacement.seq,
|
|
2050
|
+
applied: false,
|
|
2051
|
+
applyError: `the corrected reply was refused: ${String((error && error.message) || error)}`,
|
|
2052
|
+
shadowed: plan.shadowed,
|
|
2053
|
+
dropped,
|
|
2054
|
+
...failedFlush,
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// Step three: put every later turn back behind the correction, as fresh
|
|
2059
|
+
// copies. This is the same infix splice dsh-rerun-turn performs after a
|
|
2060
|
+
// regenerated answer - shadow the tail, write the new reply, replay what
|
|
2061
|
+
// followed - with the user's text taking the place of the model call.
|
|
2062
|
+
let replay = null
|
|
2063
|
+
if (typeof plan.replayFrom === 'number' && typeof plan.logTo === 'number') {
|
|
2064
|
+
const writes = buildReplayWrites(events, plan, editTurn + 1, replayId, session.seq)
|
|
2065
|
+
let replayed = 0
|
|
2066
|
+
let replayError = null
|
|
1512
2067
|
try {
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
} catch (error) {
|
|
1520
|
-
return {
|
|
1521
|
-
kind: 'reply',
|
|
1522
|
-
replacementSeq: replacement.seq,
|
|
1523
|
-
applied: false,
|
|
1524
|
-
applyError: `the corrected reply was refused: ${String((error && error.message) || error)}`,
|
|
1525
|
-
shadowed: plan.shadowed,
|
|
1526
|
-
dropped,
|
|
1527
|
-
...flush,
|
|
2068
|
+
for (const write of writes) {
|
|
2069
|
+
const options = {}
|
|
2070
|
+
if (write.surfaceOp !== undefined) options.surfaceOp = write.surfaceOp
|
|
2071
|
+
if (write.sourceEventSeqs !== undefined) options.sourceEventSeqs = write.sourceEventSeqs
|
|
2072
|
+
session.append(write.type, write.data, options)
|
|
2073
|
+
replayed += 1
|
|
1528
2074
|
}
|
|
2075
|
+
} catch (error) {
|
|
2076
|
+
replayError = String((error && error.message) || error)
|
|
2077
|
+
}
|
|
2078
|
+
const playedMax = writes.reduce((max, write) => {
|
|
2079
|
+
const turn = write.data && typeof write.data.turn === 'number' ? write.data.turn : 0
|
|
2080
|
+
return turn > max ? turn : max
|
|
2081
|
+
}, 0)
|
|
2082
|
+
replay = {
|
|
2083
|
+
id: replayId,
|
|
2084
|
+
replayed,
|
|
2085
|
+
total: writes.length,
|
|
2086
|
+
turns: writes.reduce((count, write) => count + (write.type === 'turn/start' ? 1 : 0), 0),
|
|
2087
|
+
...(replayError === null ? {} : { error: replayError }),
|
|
2088
|
+
}
|
|
2089
|
+
// The replayed turns are closed in the log but were never opened by the
|
|
2090
|
+
// live loop: re-point its idle counter at the highest one, synchronously.
|
|
2091
|
+
const loopTurn = syncLoopTurn(loopAgent, playedMax > editTurn ? playedMax : editTurn)
|
|
2092
|
+
if (loopTurn === 'unavailable') {
|
|
2093
|
+
noteRequest({ kind: 'reply', sessionId, ok: replayError === null, loopTurn, turn: editTurn, replayId, replayed })
|
|
1529
2094
|
}
|
|
1530
2095
|
const corrected = await flushSession(ctx, session)
|
|
1531
|
-
// The synthetic turn this edit opened is invisible to the live loop's own
|
|
1532
|
-
// counter; without the sync its next run reuses that number and the log
|
|
1533
|
-
// fails its next load. Only a reply edit opens a turn - a prompt edit does
|
|
1534
|
-
// not, and needs nothing.
|
|
1535
|
-
const loopAgent = await resolveLoopAgent(ctx, sessionId)
|
|
1536
|
-
const loopTurn = syncLoopTurn(loopAgent, editTurn)
|
|
1537
|
-
// `loopTurn` is the diagnostic the request ring records; the field is DELIBERATELY
|
|
1538
|
-
// not called `code` - the static checks read `code:` as host error codes and
|
|
1539
|
-
// must not start demanding a user-visible message for an internal sync state.
|
|
1540
|
-
const syncNote = loopTurn
|
|
1541
|
-
if (syncNote === 'unavailable') {
|
|
1542
|
-
noteRequest({ kind: 'reply', sessionId, ok: true, loopTurn: syncNote, turn: editTurn })
|
|
1543
|
-
}
|
|
1544
2096
|
return {
|
|
1545
2097
|
kind: 'reply',
|
|
1546
2098
|
replacementSeq: replacement.seq,
|
|
1547
2099
|
appendedSeq: appended.seq,
|
|
1548
2100
|
applied: true,
|
|
1549
2101
|
loopTurn,
|
|
2102
|
+
replay,
|
|
1550
2103
|
shadowed: plan.shadowed,
|
|
1551
2104
|
dropped,
|
|
1552
2105
|
...corrected,
|
|
1553
2106
|
}
|
|
1554
2107
|
}
|
|
1555
2108
|
|
|
1556
|
-
//
|
|
1557
|
-
//
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
2109
|
+
// No later turn to replay: the correction is the last answer in the log.
|
|
2110
|
+
// The synthetic turn still moved the loop's counter; sync it the same way.
|
|
2111
|
+
const loopTurn = syncLoopTurn(loopAgent, editTurn)
|
|
2112
|
+
if (loopTurn === 'unavailable') {
|
|
2113
|
+
noteRequest({ kind: 'reply', sessionId, ok: true, loopTurn, turn: editTurn, replayId })
|
|
2114
|
+
}
|
|
2115
|
+
const corrected = await flushSession(ctx, session)
|
|
1562
2116
|
return {
|
|
1563
|
-
kind: '
|
|
2117
|
+
kind: 'reply',
|
|
1564
2118
|
replacementSeq: replacement.seq,
|
|
2119
|
+
appendedSeq: appended.seq,
|
|
1565
2120
|
applied: true,
|
|
2121
|
+
loopTurn,
|
|
2122
|
+
replay: null,
|
|
1566
2123
|
shadowed: plan.shadowed,
|
|
1567
2124
|
dropped,
|
|
1568
|
-
|
|
1569
|
-
// it. The browser half adopts this for the row it re-draws before its own
|
|
1570
|
-
// refresh lands - without it a second save could not know which blocks are
|
|
1571
|
-
// still there and would rewrite the message text-only.
|
|
1572
|
-
blocks: blockSummaries(content, canPreviewAttachments(store)),
|
|
1573
|
-
...flush,
|
|
2125
|
+
...corrected,
|
|
1574
2126
|
}
|
|
1575
2127
|
}
|
|
1576
2128
|
|
|
@@ -1590,14 +2142,14 @@ function sessionIdFromExec(exec) {
|
|
|
1590
2142
|
return undefined
|
|
1591
2143
|
}
|
|
1592
2144
|
|
|
1593
|
-
function registerTool(ctx) {
|
|
2145
|
+
function registerTool(ctx, config) {
|
|
1594
2146
|
const tools = ctx.get('tools')
|
|
1595
2147
|
if (!tools || typeof tools.register !== 'function') return
|
|
1596
2148
|
tools.register(
|
|
1597
2149
|
defineTool({
|
|
1598
2150
|
name: 'edit_turn_targets',
|
|
1599
2151
|
description:
|
|
1600
|
-
'List the user turns of a DeepSeek Harness session that the edit-turn plugin can rewrite
|
|
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.',
|
|
1601
2153
|
parameters: {
|
|
1602
2154
|
sessionId: {
|
|
1603
2155
|
type: 'string',
|
|
@@ -1617,8 +2169,9 @@ function registerTool(ctx) {
|
|
|
1617
2169
|
const events = await readEvents(ctx, sessionId)
|
|
1618
2170
|
if (!events) throw new Error(`no session log for ${sessionId}`)
|
|
1619
2171
|
const nodes = surfaceOf(ctx, sessionId, events)
|
|
2172
|
+
const patches = patchesOf(events)
|
|
1620
2173
|
const turns = editableTurns(events, nodes)
|
|
1621
|
-
const replies = editableReplies(events, nodes)
|
|
2174
|
+
const replies = editableReplies(events, nodes, { patches, patch: config && config.patch === true })
|
|
1622
2175
|
const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.floor(args.limit) : 20
|
|
1623
2176
|
const shown = turns.slice(-limit)
|
|
1624
2177
|
const shownReplies = replies.slice(-limit)
|
|
@@ -1632,10 +2185,13 @@ function registerTool(ctx) {
|
|
|
1632
2185
|
if (turns.length > shown.length) lines.push(`(${turns.length - shown.length} earlier turn(s) omitted)`)
|
|
1633
2186
|
for (const reply of shownReplies) {
|
|
1634
2187
|
const preview = reply.text.length > 160 ? `${reply.text.slice(0, 160)}...` : reply.text
|
|
1635
|
-
|
|
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}`)
|
|
1636
2192
|
}
|
|
1637
2193
|
if (replies.length > shownReplies.length) lines.push(`(${replies.length - shownReplies.length} earlier reply/replies omitted)`)
|
|
1638
|
-
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.')
|
|
1639
2195
|
return lines.join('\n')
|
|
1640
2196
|
},
|
|
1641
2197
|
}),
|
|
@@ -1764,7 +2320,38 @@ export function apply(ctx, config) {
|
|
|
1764
2320
|
: DEFAULT_MARKER,
|
|
1765
2321
|
carrier: config && config.carrier === 'user/message' ? 'user/message' : 'system/message',
|
|
1766
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
|
+
}
|
|
1767
2353
|
}
|
|
2354
|
+
resolved.patch = patch
|
|
1768
2355
|
|
|
1769
2356
|
const registerRoutes = (webServer, fiber) => {
|
|
1770
2357
|
fiber.effect(() =>
|
|
@@ -1843,7 +2430,19 @@ export function apply(ctx, config) {
|
|
|
1843
2430
|
sendJson(res, 405, { ok: false, code: 'method', error: 'GET only' })
|
|
1844
2431
|
return
|
|
1845
2432
|
}
|
|
1846
|
-
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
|
+
})
|
|
1847
2446
|
},
|
|
1848
2447
|
}),
|
|
1849
2448
|
)
|
|
@@ -1924,9 +2523,9 @@ export function apply(ctx, config) {
|
|
|
1924
2523
|
|
|
1925
2524
|
const tools = ctx.get('tools')
|
|
1926
2525
|
if (tools) {
|
|
1927
|
-
registerTool(ctx)
|
|
2526
|
+
registerTool(ctx, resolved)
|
|
1928
2527
|
} else {
|
|
1929
|
-
ctx.inject(['tools'], (sub) => registerTool(sub))
|
|
2528
|
+
ctx.inject(['tools'], (sub) => registerTool(sub, resolved))
|
|
1930
2529
|
}
|
|
1931
2530
|
}
|
|
1932
2531
|
|