dsh-edit-turn 0.1.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/LICENSE +21 -0
- package/README.md +342 -0
- package/cordis.patch.yml +14 -0
- package/lib/client.js +773 -0
- package/lib/index.js +903 -0
- package/package.json +73 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
// dsh-edit-turn - host half.
|
|
2
|
+
//
|
|
3
|
+
// Two loopback-only JSON routes:
|
|
4
|
+
//
|
|
5
|
+
// GET /dsh-edit-turn/state?sessionId=<id>
|
|
6
|
+
// POST /dsh-edit-turn/apply { sessionId, seq|messageId|turn, text }
|
|
7
|
+
//
|
|
8
|
+
// An edit is two official operations, in order:
|
|
9
|
+
//
|
|
10
|
+
// 1. ROLLBACK - append ONE surface event whose `surfaceOp` is
|
|
11
|
+
// `{ op: 'replace', startSeq, endSeq }` over the inclusive surface window
|
|
12
|
+
// "the addressed user message .. the last surface node". The official fold
|
|
13
|
+
// swaps that whole window for the replacing event, so every event recorded
|
|
14
|
+
// after the edit point leaves the derived model context while the
|
|
15
|
+
// append-only log keeps every original byte ("Committed events are never
|
|
16
|
+
// rewritten"). The window always ends at the last surface node, so an
|
|
17
|
+
// assistant message (which carries its own tool_use blocks) and the
|
|
18
|
+
// tool/result it produced are always shadowed together: no call/result
|
|
19
|
+
// pair can be left dangling.
|
|
20
|
+
//
|
|
21
|
+
// 2. RE-RUN - `ctx.sessionController.prompt(...)`, the one official prompt
|
|
22
|
+
// admission path. It resumes the Session when cold and starts exactly one
|
|
23
|
+
// new turn from the revised text.
|
|
24
|
+
//
|
|
25
|
+
// The replacement carrier defaults to an EMPTY `system/message`. The official
|
|
26
|
+
// format documents an empty later system node as dormant - "empty later nodes
|
|
27
|
+
// are dormant and project to no message" - so the rollback adds nothing the
|
|
28
|
+
// model can see and the derived context after an edit is exactly what it would
|
|
29
|
+
// be had the conversation really stopped at the edit point. `carrier:
|
|
30
|
+
// 'user/message'` switches to a short marker text instead; that shape is what
|
|
31
|
+
// an equivalent plugin already runs in production, so it is the fallback.
|
|
32
|
+
// Either way `sourceEventSeqs` must list the complete shadowed node set, which
|
|
33
|
+
// is why an `assistant/message` carrier is impossible (it forbids
|
|
34
|
+
// `sourceEventSeqs` outright).
|
|
35
|
+
//
|
|
36
|
+
// The module imports only `@deepseek-ai/schemastery` and
|
|
37
|
+
// `@deepseek-ai/dsh-tools` (both declared). Every DSH service is resolved
|
|
38
|
+
// through the cordis context at call time, so the plugin still loads on a
|
|
39
|
+
// profile that lacks one and degrades to a clear HTTP failure instead.
|
|
40
|
+
import { randomUUID } from 'node:crypto'
|
|
41
|
+
import z from '@deepseek-ai/schemastery'
|
|
42
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
43
|
+
|
|
44
|
+
/** Plugin id shared by the host and browser halves. */
|
|
45
|
+
export const PLUGIN_ID = 'dsh-edit-turn'
|
|
46
|
+
|
|
47
|
+
/** Cordis plugin name. */
|
|
48
|
+
export const name = PLUGIN_ID
|
|
49
|
+
|
|
50
|
+
/** Keep in sync with package.json and lib/client.js. */
|
|
51
|
+
export const PLUGIN_VERSION = '0.1.0'
|
|
52
|
+
|
|
53
|
+
const ROUTE_PREFIX = '/dsh-edit-turn'
|
|
54
|
+
const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
55
|
+
const MAX_TEXT = 200_000
|
|
56
|
+
const PROMPT_DEADLINE_MS = 30_000
|
|
57
|
+
const FLUSH_DEADLINE_MS = 5_000
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Model-visible text of the replacement carrier.
|
|
61
|
+
*
|
|
62
|
+
* A `user/message` carrier always projects into the model context, so the
|
|
63
|
+
* default states the one fact the revised context cannot express on its own:
|
|
64
|
+
* earlier turns were deliberately dropped. Keep it short and factual.
|
|
65
|
+
*/
|
|
66
|
+
const DEFAULT_MARKER = '[dsh-edit-turn] The user revised an earlier message. Every turn after that message was rolled back; the revised message follows. Send your reply to the revised message.'
|
|
67
|
+
|
|
68
|
+
/** Deployment policy for the rollback. */
|
|
69
|
+
export const Config = z.object({
|
|
70
|
+
markerText: z.string().default(DEFAULT_MARKER),
|
|
71
|
+
confirm: z.boolean().default(true),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// pure session-log logic (no DSH SDK imports; unit-testable under node --test)
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/** The four event types that may carry `surfaceOp` (official surface contract). */
|
|
79
|
+
const SURFACE_TYPES = new Set([
|
|
80
|
+
'system/message',
|
|
81
|
+
'user/message',
|
|
82
|
+
'assistant/message',
|
|
83
|
+
'tool/result',
|
|
84
|
+
])
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether one event participates in the model-visible surface.
|
|
88
|
+
* @param event - raw session event.
|
|
89
|
+
* @returns true for a message-producing event type.
|
|
90
|
+
*/
|
|
91
|
+
export function isSurfaceEvent(event) {
|
|
92
|
+
const type = event && event.type
|
|
93
|
+
return typeof type === 'string' && SURFACE_TYPES.has(type)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Replay the surface operations of a complete log.
|
|
98
|
+
*
|
|
99
|
+
* Mirrors the official fold: `append` pushes the event onto the tail; a
|
|
100
|
+
* `replace` swaps the inclusive window between its two surface nodes for the
|
|
101
|
+
* replacing event. Replacements whose anchors are no longer present are skipped
|
|
102
|
+
* defensively - a corrupt log must not throw inside an HTTP handler.
|
|
103
|
+
*
|
|
104
|
+
* @param events - complete contiguous raw event log in seq order.
|
|
105
|
+
* @returns current surface seqs in model order plus every landed replacement.
|
|
106
|
+
*/
|
|
107
|
+
export function foldSurface(events) {
|
|
108
|
+
const nodes = []
|
|
109
|
+
const replacements = []
|
|
110
|
+
for (const event of events) {
|
|
111
|
+
const op = event.surfaceOp
|
|
112
|
+
if (op === undefined) continue
|
|
113
|
+
if (op === 'append') {
|
|
114
|
+
nodes.push(event.seq)
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
if (op === null || typeof op !== 'object' || op.op !== 'replace') continue
|
|
118
|
+
const startIdx = nodes.indexOf(op.startSeq)
|
|
119
|
+
const endIdx = nodes.indexOf(op.endSeq)
|
|
120
|
+
if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) continue
|
|
121
|
+
const shadowed = nodes.slice(startIdx, endIdx + 1)
|
|
122
|
+
nodes.splice(startIdx, endIdx - startIdx + 1, event.seq)
|
|
123
|
+
replacements.push({ seq: event.seq, startSeq: op.startSeq, endSeq: op.endSeq, shadowed })
|
|
124
|
+
}
|
|
125
|
+
return { nodes, replacements }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Map every event seq to the turn that encloses it.
|
|
130
|
+
*
|
|
131
|
+
* Turn brackets are the durable source for user messages (their payload has no
|
|
132
|
+
* turn field); assistant and tool events carry their own turn and override the
|
|
133
|
+
* bracket reading.
|
|
134
|
+
*
|
|
135
|
+
* @param events - complete contiguous raw event log.
|
|
136
|
+
* @returns seq -> turn number (undefined outside any turn).
|
|
137
|
+
*/
|
|
138
|
+
export function turnIndex(events) {
|
|
139
|
+
const turnOf = new Map()
|
|
140
|
+
let current
|
|
141
|
+
for (const event of events) {
|
|
142
|
+
if (event.type === 'turn/start') {
|
|
143
|
+
current = event.data && event.data.turn
|
|
144
|
+
turnOf.set(event.seq, current)
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
if (event.type === 'turn/end') {
|
|
148
|
+
turnOf.set(event.seq, current)
|
|
149
|
+
current = undefined
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
const data = event.data
|
|
153
|
+
const explicit =
|
|
154
|
+
(event.type === 'assistant/message' || event.type === 'tool/result' || event.type === 'tool/call') &&
|
|
155
|
+
data &&
|
|
156
|
+
typeof data.turn === 'number'
|
|
157
|
+
? data.turn
|
|
158
|
+
: undefined
|
|
159
|
+
turnOf.set(event.seq, explicit !== undefined ? explicit : current)
|
|
160
|
+
}
|
|
161
|
+
return turnOf
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The turn still awaiting its `turn/end`, or null when every turn closed. */
|
|
165
|
+
export function openTurn(events) {
|
|
166
|
+
let open = null
|
|
167
|
+
for (const event of events) {
|
|
168
|
+
if (event.type === 'turn/start') open = event.data && event.data.turn
|
|
169
|
+
else if (event.type === 'turn/end' && (open === null || event.data.turn === open)) open = null
|
|
170
|
+
}
|
|
171
|
+
return open
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Whether an operation is in flight that will still write the surface. */
|
|
175
|
+
export function isBusy(events) {
|
|
176
|
+
if (openTurn(events) !== null) return true
|
|
177
|
+
let compaction = false
|
|
178
|
+
for (const event of events) {
|
|
179
|
+
if (event.type === 'compaction/start') compaction = true
|
|
180
|
+
else if (event.type === 'compaction/end') compaction = false
|
|
181
|
+
}
|
|
182
|
+
return compaction
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Durable message identity of one surface event.
|
|
187
|
+
* @param event - raw session event.
|
|
188
|
+
* @returns the message id, or undefined for an event without one.
|
|
189
|
+
*/
|
|
190
|
+
export function messageIdOf(event) {
|
|
191
|
+
const data = event.data
|
|
192
|
+
if (!data || typeof data !== 'object') return undefined
|
|
193
|
+
if (event.type === 'user/message') return typeof data.id === 'string' ? data.id : undefined
|
|
194
|
+
if (event.type === 'assistant/message' || event.type === 'tool/result' || event.type === 'system/message') {
|
|
195
|
+
const message = data.message
|
|
196
|
+
return message && typeof message.id === 'string' ? message.id : undefined
|
|
197
|
+
}
|
|
198
|
+
return undefined
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Whether one event is a human-authored prompt.
|
|
203
|
+
*
|
|
204
|
+
* Injected context, this plugin's own placeholder and compaction checkpoints
|
|
205
|
+
* are all `user/message` events too; only `source.kind === 'user'` is a prompt
|
|
206
|
+
* the user actually typed, so only those are editable.
|
|
207
|
+
*
|
|
208
|
+
* @param event - raw session event.
|
|
209
|
+
* @returns true for a human prompt.
|
|
210
|
+
*/
|
|
211
|
+
export function isHumanPrompt(event) {
|
|
212
|
+
if (!event || event.type !== 'user/message') return false
|
|
213
|
+
const data = event.data
|
|
214
|
+
return Boolean(data && data.source && data.source.kind === 'user')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Read the plain text of a message payload, plus how many non-text parts it
|
|
219
|
+
* carries. Editing is text-only, so a caller must be told when a rewrite would
|
|
220
|
+
* drop attachments.
|
|
221
|
+
* @param event - raw session event.
|
|
222
|
+
* @returns `{ text, attachments }`.
|
|
223
|
+
*/
|
|
224
|
+
export function readMessageText(event) {
|
|
225
|
+
const data = (event && event.data) || {}
|
|
226
|
+
const blocks = Array.isArray(data.content) ? data.content : []
|
|
227
|
+
let text = ''
|
|
228
|
+
let attachments = 0
|
|
229
|
+
for (const block of blocks) {
|
|
230
|
+
if (!block || typeof block !== 'object') continue
|
|
231
|
+
if (block.type === 'text' && typeof block.text === 'string') text += (text === '' ? '' : '\n') + block.text
|
|
232
|
+
else attachments += 1
|
|
233
|
+
}
|
|
234
|
+
return { text, attachments }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Rebuild this plugin's rollback ledger from the log alone.
|
|
239
|
+
*
|
|
240
|
+
* Every rollback is one replacement event whose message source is
|
|
241
|
+
* `{ kind: 'plugin', plugin: 'dsh-edit-turn' }`. A replacement landed by any
|
|
242
|
+
* other producer - compaction, for instance - is ignored.
|
|
243
|
+
*
|
|
244
|
+
* @param events - complete contiguous raw event log.
|
|
245
|
+
* @returns `{ hidden, edits }`: one hidden entry per shadowed seq, and one
|
|
246
|
+
* record per landed rollback.
|
|
247
|
+
*/
|
|
248
|
+
export function rollbackLedger(events) {
|
|
249
|
+
const folded = foldSurface(events)
|
|
250
|
+
const bySeq = new Map(events.map((event) => [event.seq, event]))
|
|
251
|
+
const turnOf = turnIndex(events)
|
|
252
|
+
const hidden = []
|
|
253
|
+
const edits = []
|
|
254
|
+
for (const replacement of folded.replacements) {
|
|
255
|
+
const event = bySeq.get(replacement.seq)
|
|
256
|
+
const data = event && event.data
|
|
257
|
+
const source = data && (data.source || (data.message && data.message.source))
|
|
258
|
+
if (!source || source.kind !== 'plugin' || source.plugin !== PLUGIN_ID) continue
|
|
259
|
+
for (const seq of replacement.shadowed) {
|
|
260
|
+
const turn = turnOf.get(seq)
|
|
261
|
+
hidden.push({ seq, turn: typeof turn === 'number' ? turn : null, replacement: replacement.seq })
|
|
262
|
+
}
|
|
263
|
+
edits.push({
|
|
264
|
+
replacementSeq: replacement.seq,
|
|
265
|
+
startSeq: replacement.startSeq,
|
|
266
|
+
endSeq: replacement.endSeq,
|
|
267
|
+
shadowed: replacement.shadowed,
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
return { hidden, edits }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Highest turn number recorded in the log, or undefined for an empty log. */
|
|
274
|
+
export function lastTurnOf(events) {
|
|
275
|
+
let last
|
|
276
|
+
for (const event of events) {
|
|
277
|
+
const turn = event.data && event.data.turn
|
|
278
|
+
if (typeof turn === 'number' && (last === undefined || turn > last)) last = turn
|
|
279
|
+
}
|
|
280
|
+
return last
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Planner rejection with a machine code the HTTP layer forwards verbatim. */
|
|
284
|
+
export class EditPlanError extends Error {
|
|
285
|
+
constructor(code, message) {
|
|
286
|
+
super(message)
|
|
287
|
+
this.name = 'EditPlanError'
|
|
288
|
+
this.code = code
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Resolve one editing target inside a log.
|
|
294
|
+
* @param events - complete contiguous raw event log.
|
|
295
|
+
* @param request - `{ seq?, messageId?, turn? }`.
|
|
296
|
+
* @returns the target event.
|
|
297
|
+
* @throws {EditPlanError} with `not-editable` when no unique target exists.
|
|
298
|
+
*/
|
|
299
|
+
function resolveTarget(events, request) {
|
|
300
|
+
let targetSeq = typeof request.seq === 'number' ? request.seq : undefined
|
|
301
|
+
if (targetSeq === undefined && typeof request.messageId === 'string' && request.messageId !== '') {
|
|
302
|
+
for (const event of events) {
|
|
303
|
+
if (messageIdOf(event) === request.messageId) {
|
|
304
|
+
targetSeq = event.seq
|
|
305
|
+
break
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (targetSeq === undefined && typeof request.turn === 'number') {
|
|
310
|
+
for (const event of events) {
|
|
311
|
+
if (!isHumanPrompt(event)) continue
|
|
312
|
+
if (turnIndex(events).get(event.seq) !== request.turn) continue
|
|
313
|
+
targetSeq = event.seq
|
|
314
|
+
break
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (targetSeq === undefined) throw new EditPlanError('not-editable', 'no editing target was found')
|
|
318
|
+
const target = events.find((event) => event.seq === targetSeq)
|
|
319
|
+
if (!target) throw new EditPlanError('not-editable', 'the target event does not exist')
|
|
320
|
+
return target
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Turn one UI target into the canonical rollback window.
|
|
325
|
+
*
|
|
326
|
+
* The window is `[the addressed prompt .. the last surface node]`: the complete
|
|
327
|
+
* remainder of the conversation in surface order. It is contiguous by
|
|
328
|
+
* construction, it always ends on a completed turn's last node, and its left
|
|
329
|
+
* edge is exactly the prompt being rewritten, so the surviving prefix ends on
|
|
330
|
+
* the previous turn's boundary.
|
|
331
|
+
*
|
|
332
|
+
* @param events - complete contiguous raw event log.
|
|
333
|
+
* @param surfaceNodes - current surface seqs in model order.
|
|
334
|
+
* @param request - `{ seq?, messageId?, turn? }`.
|
|
335
|
+
* @returns `{ targetSeq, startSeq, endSeq, shadowed, turn, original, attachments }`.
|
|
336
|
+
* @throws {EditPlanError} with a stable code when the target cannot be planned.
|
|
337
|
+
*/
|
|
338
|
+
export function planRollback(events, surfaceNodes, request) {
|
|
339
|
+
const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
|
|
340
|
+
const turnOf = turnIndex(events)
|
|
341
|
+
const target = resolveTarget(events, request, turnOf)
|
|
342
|
+
if (!nodeIndex.has(target.seq)) {
|
|
343
|
+
throw new EditPlanError('already-rolled-back', 'this message is no longer on the current surface')
|
|
344
|
+
}
|
|
345
|
+
// Surface node 0 is the system prompt head: the official append contract only
|
|
346
|
+
// lets a system/message rewrite exactly that node, and no UI row targets it.
|
|
347
|
+
if (target.seq === surfaceNodes[0]) {
|
|
348
|
+
throw new EditPlanError('not-editable', 'the system prompt head cannot be edited')
|
|
349
|
+
}
|
|
350
|
+
if (!isHumanPrompt(target)) {
|
|
351
|
+
throw new EditPlanError('not-editable', 'only messages you typed can be edited and re-run')
|
|
352
|
+
}
|
|
353
|
+
const startIdx = nodeIndex.get(target.seq)
|
|
354
|
+
const shadowed = surfaceNodes.slice(startIdx)
|
|
355
|
+
if (shadowed.length === 0 || shadowed[0] !== target.seq) {
|
|
356
|
+
throw new EditPlanError('not-editable', 'the target does not open a rollback window')
|
|
357
|
+
}
|
|
358
|
+
const { text, attachments } = readMessageText(target)
|
|
359
|
+
const turn = turnOf.get(target.seq)
|
|
360
|
+
return {
|
|
361
|
+
targetSeq: target.seq,
|
|
362
|
+
messageId: messageIdOf(target),
|
|
363
|
+
startSeq: shadowed[0],
|
|
364
|
+
endSeq: shadowed[shadowed.length - 1],
|
|
365
|
+
shadowed,
|
|
366
|
+
turn: typeof turn === 'number' ? turn : null,
|
|
367
|
+
original: text,
|
|
368
|
+
attachments,
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Every human prompt that can currently be edited.
|
|
374
|
+
*
|
|
375
|
+
* A prompt that already left the surface (an earlier rollback shadowed it, or a
|
|
376
|
+
* compaction consumed it) keeps its transcript row on purpose, so it must not
|
|
377
|
+
* be offered again.
|
|
378
|
+
*
|
|
379
|
+
* @param events - complete contiguous raw event log.
|
|
380
|
+
* @param surfaceNodes - current surface seqs in model order.
|
|
381
|
+
* @returns one entry per editable prompt, in conversation order.
|
|
382
|
+
*/
|
|
383
|
+
export function editableTurns(events, surfaceNodes) {
|
|
384
|
+
const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
|
|
385
|
+
const turnOf = turnIndex(events)
|
|
386
|
+
const head = surfaceNodes[0]
|
|
387
|
+
const out = []
|
|
388
|
+
for (const event of events) {
|
|
389
|
+
if (!isHumanPrompt(event)) continue
|
|
390
|
+
if (!nodeIndex.has(event.seq)) continue
|
|
391
|
+
if (event.seq === head) continue
|
|
392
|
+
const { text, attachments } = readMessageText(event)
|
|
393
|
+
const turn = turnOf.get(event.seq)
|
|
394
|
+
out.push({
|
|
395
|
+
seq: event.seq,
|
|
396
|
+
turn: typeof turn === 'number' ? turn : null,
|
|
397
|
+
messageId: messageIdOf(event) ?? null,
|
|
398
|
+
text,
|
|
399
|
+
attachments,
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
return out
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// host service plumbing
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
class HttpError extends Error {
|
|
410
|
+
constructor(status, code, message) {
|
|
411
|
+
super(message)
|
|
412
|
+
this.name = 'HttpError'
|
|
413
|
+
this.status = status
|
|
414
|
+
this.code = code
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// A session id travels in two spellings: the raw uuid and `session-<uuid>`.
|
|
419
|
+
// The store, the persistence directories and the workspace rows disagree about
|
|
420
|
+
// which one they hold, so every lookup tries both.
|
|
421
|
+
function idVariants(sessionId) {
|
|
422
|
+
const out = new Set([sessionId])
|
|
423
|
+
if (sessionId.startsWith('session-')) out.add(sessionId.slice('session-'.length))
|
|
424
|
+
else out.add(`session-${sessionId}`)
|
|
425
|
+
return [...out]
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function findLiveSession(ctx, sessionId) {
|
|
429
|
+
const sessions = ctx.get('sessions')
|
|
430
|
+
if (!sessions || typeof sessions.get !== 'function') return undefined
|
|
431
|
+
for (const variant of idVariants(sessionId)) {
|
|
432
|
+
const found = sessions.get(variant)
|
|
433
|
+
if (found) return found
|
|
434
|
+
}
|
|
435
|
+
return undefined
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Resolve the live Session that owns the append. An already-open session is
|
|
439
|
+
// used directly; a cold one is resumed through the official controller, which
|
|
440
|
+
// is exactly what the web UI does when the user opens it.
|
|
441
|
+
async function resolveSession(ctx, sessionId) {
|
|
442
|
+
const live = findLiveSession(ctx, sessionId)
|
|
443
|
+
if (live) return live
|
|
444
|
+
const controller = ctx.get('sessionController')
|
|
445
|
+
if (controller && typeof controller.resolveAgent === 'function') {
|
|
446
|
+
try {
|
|
447
|
+
const result = await controller.resolveAgent(sessionId)
|
|
448
|
+
if (result && result.agent && result.agent.session) return result.agent.session
|
|
449
|
+
} catch {
|
|
450
|
+
// fall through to the explicit failure below
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return undefined
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function eventsFromLive(session) {
|
|
457
|
+
if (session && typeof session.snapshotEvents === 'function') {
|
|
458
|
+
try {
|
|
459
|
+
const events = session.snapshotEvents()
|
|
460
|
+
if (Array.isArray(events)) return events
|
|
461
|
+
} catch {
|
|
462
|
+
// fall through to the query service
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return undefined
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Live-preferred read through the public query service; the live session's own
|
|
469
|
+
// snapshot is the fallback when the service is absent.
|
|
470
|
+
async function readEvents(ctx, sessionId) {
|
|
471
|
+
const query = ctx.get('sessionQuery')
|
|
472
|
+
if (query && typeof query.readSession === 'function') {
|
|
473
|
+
try {
|
|
474
|
+
const snapshot = await query.readSession(sessionId)
|
|
475
|
+
if (snapshot && Array.isArray(snapshot.events)) return snapshot.events
|
|
476
|
+
} catch {
|
|
477
|
+
// fall through to the live snapshot
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const events = eventsFromLive(findLiveSession(ctx, sessionId))
|
|
481
|
+
return events ?? null
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function surfaceOf(ctx, sessionId, events) {
|
|
485
|
+
const live = findLiveSession(ctx, sessionId)
|
|
486
|
+
const nodes = live && live.surface && Array.isArray(live.surface.nodes) ? live.surface.nodes : undefined
|
|
487
|
+
return nodes ?? foldSurface(events).nodes
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// The append is committed in memory the moment `session.append` returns; the
|
|
491
|
+
// persistence writer buffers asynchronously. Await the official durability
|
|
492
|
+
// checkpoint so a reload or a DSH restart still sees the rollback.
|
|
493
|
+
async function flushSession(ctx, session) {
|
|
494
|
+
const errors = []
|
|
495
|
+
const sessions = ctx.get('sessions')
|
|
496
|
+
if (sessions && typeof sessions.flush === 'function') {
|
|
497
|
+
try {
|
|
498
|
+
await Promise.race([sessions.flush(session), new Promise((resolve) => setTimeout(resolve, FLUSH_DEADLINE_MS))])
|
|
499
|
+
return { flushed: true }
|
|
500
|
+
} catch (error) {
|
|
501
|
+
errors.push(String((error && error.message) || error))
|
|
502
|
+
}
|
|
503
|
+
} else {
|
|
504
|
+
errors.push('sessions.flush unavailable')
|
|
505
|
+
}
|
|
506
|
+
const persistence = ctx.get('sessionPersistence')
|
|
507
|
+
if (persistence && typeof persistence.flush === 'function') {
|
|
508
|
+
try {
|
|
509
|
+
await Promise.race([persistence.flush(), new Promise((resolve) => setTimeout(resolve, FLUSH_DEADLINE_MS))])
|
|
510
|
+
return { flushed: true }
|
|
511
|
+
} catch (error) {
|
|
512
|
+
errors.push(String((error && error.message) || error))
|
|
513
|
+
}
|
|
514
|
+
} else {
|
|
515
|
+
errors.push('sessionPersistence.flush unavailable')
|
|
516
|
+
}
|
|
517
|
+
return { flushed: false, flushError: errors.join(' | ') }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// --- operations --------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
async function stateOf(ctx, sessionId, config) {
|
|
523
|
+
const events = await readEvents(ctx, sessionId)
|
|
524
|
+
if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
|
|
525
|
+
const folded = foldSurface(events)
|
|
526
|
+
const ledger = rollbackLedger(events)
|
|
527
|
+
const live = findLiveSession(ctx, sessionId)
|
|
528
|
+
return {
|
|
529
|
+
hidden: ledger.hidden,
|
|
530
|
+
edits: ledger.edits.length,
|
|
531
|
+
surface: folded.nodes,
|
|
532
|
+
turns: editableTurns(events, folded.nodes),
|
|
533
|
+
live: Boolean(live),
|
|
534
|
+
busy: isBusy(events),
|
|
535
|
+
lastSeq: events.length > 0 ? events[events.length - 1].seq : -1,
|
|
536
|
+
config: { confirm: config.confirm },
|
|
537
|
+
version: PLUGIN_VERSION,
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Build the event that carries one rollback replacement.
|
|
543
|
+
*
|
|
544
|
+
* @param plan - the planned window.
|
|
545
|
+
* @param lastTurn - highest turn number in the log, for a coherent bracket.
|
|
546
|
+
* @param config - resolved deployment policy.
|
|
547
|
+
* @returns `{ type, data }` for `session.append`.
|
|
548
|
+
*/
|
|
549
|
+
export function buildCarrier(plan, lastTurn, config) {
|
|
550
|
+
const silent = config.carrier !== 'user/message'
|
|
551
|
+
if (silent) {
|
|
552
|
+
// An empty later system node is dormant and projects to no model message.
|
|
553
|
+
return {
|
|
554
|
+
type: 'system/message',
|
|
555
|
+
data: {
|
|
556
|
+
turn: typeof lastTurn === 'number' ? lastTurn : 0,
|
|
557
|
+
step: 1,
|
|
558
|
+
message: {
|
|
559
|
+
id: randomUUID(),
|
|
560
|
+
role: 'system',
|
|
561
|
+
content: [],
|
|
562
|
+
source: { kind: 'plugin', plugin: PLUGIN_ID },
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
type: 'user/message',
|
|
569
|
+
data: {
|
|
570
|
+
id: randomUUID(),
|
|
571
|
+
role: 'user',
|
|
572
|
+
content: [{ type: 'text', text: config.markerText }],
|
|
573
|
+
source: { kind: 'plugin', plugin: PLUGIN_ID },
|
|
574
|
+
},
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function applyEdit(ctx, sessionId, body, config) {
|
|
579
|
+
const text = typeof body.text === 'string' ? body.text : ''
|
|
580
|
+
if (text.trim() === '') throw new HttpError(400, 'invalid', 'the revised message cannot be empty')
|
|
581
|
+
if (text.length > MAX_TEXT) throw new HttpError(400, 'invalid', 'the revised message is too long')
|
|
582
|
+
|
|
583
|
+
const session = await resolveSession(ctx, sessionId)
|
|
584
|
+
if (!session || typeof session.append !== 'function') {
|
|
585
|
+
throw new HttpError(409, 'session-not-active', 'the session is not open in DSH')
|
|
586
|
+
}
|
|
587
|
+
const events = await readEvents(ctx, sessionId)
|
|
588
|
+
if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
|
|
589
|
+
if (isBusy(events)) throw new HttpError(409, 'busy', 'the session is still working')
|
|
590
|
+
|
|
591
|
+
const surfaceNodes = surfaceOf(ctx, sessionId, events)
|
|
592
|
+
let plan
|
|
593
|
+
try {
|
|
594
|
+
plan = planRollback(events, surfaceNodes, {
|
|
595
|
+
seq: typeof body.seq === 'number' ? body.seq : undefined,
|
|
596
|
+
messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
|
|
597
|
+
turn: typeof body.turn === 'number' ? body.turn : undefined,
|
|
598
|
+
})
|
|
599
|
+
} catch (error) {
|
|
600
|
+
if (error instanceof EditPlanError) {
|
|
601
|
+
const status = error.code === 'not-editable' ? 400 : 409
|
|
602
|
+
throw new HttpError(status, error.code, error.message)
|
|
603
|
+
}
|
|
604
|
+
throw error
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// The live surface is the append authority; a node that vanished between the
|
|
608
|
+
// read and this check means another writer landed first.
|
|
609
|
+
for (const seq of plan.shadowed) {
|
|
610
|
+
if (!surfaceNodes.includes(seq)) throw new HttpError(409, 'stale', 'the session changed, retry')
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const carrier = buildCarrier(plan, lastTurnOf(events), config)
|
|
614
|
+
let replacement
|
|
615
|
+
try {
|
|
616
|
+
// A replacement carrier has to be a surface event with non-empty
|
|
617
|
+
// `sourceEventSeqs` coverage of the whole shadowed window. An assistant
|
|
618
|
+
// message forbids `sourceEventSeqs` outright, so the carrier is either an
|
|
619
|
+
// empty `system/message` (default, dormant, invisible) or a short
|
|
620
|
+
// `user/message` marker.
|
|
621
|
+
replacement = session.append(carrier.type, carrier.data, {
|
|
622
|
+
surfaceOp: { op: 'replace', startSeq: plan.startSeq, endSeq: plan.endSeq },
|
|
623
|
+
sourceEventSeqs: plan.shadowed,
|
|
624
|
+
})
|
|
625
|
+
} catch (error) {
|
|
626
|
+
throw new HttpError(409, 'stale', `the surface refused the rollback: ${String((error && error.message) || error)}`)
|
|
627
|
+
}
|
|
628
|
+
const flush = await flushSession(ctx, session)
|
|
629
|
+
|
|
630
|
+
// Step two: admit the revised prompt. This is the only official prompt path,
|
|
631
|
+
// so it also resumes a cold Session and starts exactly one new turn.
|
|
632
|
+
const controller = ctx.get('sessionController')
|
|
633
|
+
if (!controller || typeof controller.prompt !== 'function') {
|
|
634
|
+
return {
|
|
635
|
+
replacementSeq: replacement.seq,
|
|
636
|
+
promptAccepted: false,
|
|
637
|
+
promptError: 'sessionController.prompt unavailable',
|
|
638
|
+
shadowed: plan.shadowed,
|
|
639
|
+
...flush,
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
try {
|
|
643
|
+
await controller.prompt(
|
|
644
|
+
{
|
|
645
|
+
requestId: randomUUID(),
|
|
646
|
+
sessionId,
|
|
647
|
+
mode: 'queue',
|
|
648
|
+
content: [{ type: 'text', text }],
|
|
649
|
+
},
|
|
650
|
+
AbortSignal.timeout(PROMPT_DEADLINE_MS),
|
|
651
|
+
)
|
|
652
|
+
} catch (error) {
|
|
653
|
+
return {
|
|
654
|
+
replacementSeq: replacement.seq,
|
|
655
|
+
promptAccepted: false,
|
|
656
|
+
promptError: String((error && error.message) || error),
|
|
657
|
+
shadowed: plan.shadowed,
|
|
658
|
+
...flush,
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return {
|
|
663
|
+
replacementSeq: replacement.seq,
|
|
664
|
+
promptAccepted: true,
|
|
665
|
+
shadowed: plan.shadowed,
|
|
666
|
+
...flush,
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// --- tool --------------------------------------------------------------------
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Session id the calling agent runs in, when the tool context exposes one.
|
|
674
|
+
* @param exec - tool execution context.
|
|
675
|
+
* @returns the session id, or undefined when the caller has no agent.
|
|
676
|
+
*/
|
|
677
|
+
function sessionIdFromExec(exec) {
|
|
678
|
+
const agent = exec && exec.agent
|
|
679
|
+
const session = agent && agent.session
|
|
680
|
+
if (session && typeof session.id === 'string' && session.id !== '') return session.id
|
|
681
|
+
if (agent && typeof agent.sessionId === 'string' && agent.sessionId !== '') return agent.sessionId
|
|
682
|
+
if (exec && typeof exec.sessionId === 'string' && exec.sessionId !== '') return exec.sessionId
|
|
683
|
+
return undefined
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function registerTool(ctx) {
|
|
687
|
+
const tools = ctx.get('tools')
|
|
688
|
+
if (!tools || typeof tools.register !== 'function') return
|
|
689
|
+
tools.register(
|
|
690
|
+
defineTool({
|
|
691
|
+
name: 'edit_turn_targets',
|
|
692
|
+
description:
|
|
693
|
+
'List the user turns of a DeepSeek Harness session that the edit-turn plugin can rewrite and re-run, with each prompt\'s original text. Read-only: it changes nothing.',
|
|
694
|
+
parameters: {
|
|
695
|
+
sessionId: {
|
|
696
|
+
type: 'string',
|
|
697
|
+
description: 'Session id to inspect. Defaults to the session this agent is running in.',
|
|
698
|
+
},
|
|
699
|
+
limit: { type: 'number', description: 'Maximum number of turns to return (default 20).' },
|
|
700
|
+
},
|
|
701
|
+
output: {
|
|
702
|
+
schema: { type: 'string' },
|
|
703
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
704
|
+
},
|
|
705
|
+
async execute(args, exec) {
|
|
706
|
+
const sessionId = typeof args.sessionId === 'string' && args.sessionId !== '' ? args.sessionId : sessionIdFromExec(exec)
|
|
707
|
+
if (sessionId === undefined) {
|
|
708
|
+
throw new Error('no sessionId was supplied and the calling agent has no session')
|
|
709
|
+
}
|
|
710
|
+
const events = await readEvents(ctx, sessionId)
|
|
711
|
+
if (!events) throw new Error(`no session log for ${sessionId}`)
|
|
712
|
+
const nodes = surfaceOf(ctx, sessionId, events)
|
|
713
|
+
const turns = editableTurns(events, nodes)
|
|
714
|
+
const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.floor(args.limit) : 20
|
|
715
|
+
const shown = turns.slice(-limit)
|
|
716
|
+
const lines = [
|
|
717
|
+
`session ${sessionId}: ${turns.length} editable turn(s), ${isBusy(events) ? 'BUSY' : 'idle'}`,
|
|
718
|
+
]
|
|
719
|
+
for (const turn of shown) {
|
|
720
|
+
const preview = turn.text.length > 160 ? `${turn.text.slice(0, 160)}...` : turn.text
|
|
721
|
+
lines.push(`- turn ${turn.turn} (seq ${turn.seq}, ${turn.attachments} attachment(s)): ${preview}`)
|
|
722
|
+
}
|
|
723
|
+
if (turns.length > shown.length) lines.push(`(${turns.length - shown.length} earlier turn(s) omitted)`)
|
|
724
|
+
return lines.join('\n')
|
|
725
|
+
},
|
|
726
|
+
}),
|
|
727
|
+
)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// --- http --------------------------------------------------------------------
|
|
731
|
+
|
|
732
|
+
function isLoopbackAddress(address) {
|
|
733
|
+
if (typeof address !== 'string' || address.length === 0) return false
|
|
734
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address.startsWith('127.')
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function isLocalHostHeader(host) {
|
|
738
|
+
if (typeof host !== 'string' || host.length === 0) return false
|
|
739
|
+
const trimmed = host.trim().toLowerCase()
|
|
740
|
+
// A bracketed IPv6 literal keeps its colons, so the port can only be split
|
|
741
|
+
// off after the closing bracket, and nothing but an optional port may follow.
|
|
742
|
+
if (trimmed.startsWith('[')) {
|
|
743
|
+
const end = trimmed.indexOf(']')
|
|
744
|
+
if (end === -1) return false
|
|
745
|
+
const rest = trimmed.slice(end + 1)
|
|
746
|
+
if (rest !== '' && !/^:[0-9]+$/.test(rest)) return false
|
|
747
|
+
return trimmed.slice(1, end) === '::1'
|
|
748
|
+
}
|
|
749
|
+
const match = /^([^:]*)(?::([0-9]+))?$/.exec(trimmed)
|
|
750
|
+
if (match === null) return false
|
|
751
|
+
// Exact matches only: a name like `127.0.0.1.evil.test` must not pass.
|
|
752
|
+
return match[1] === 'localhost' || match[1] === '127.0.0.1' || match[1] === '::1'
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function sendJson(res, status, body) {
|
|
756
|
+
const payload = JSON.stringify(body)
|
|
757
|
+
res.writeHead(status, {
|
|
758
|
+
'content-type': 'application/json; charset=utf-8',
|
|
759
|
+
'content-length': Buffer.byteLength(payload),
|
|
760
|
+
})
|
|
761
|
+
res.end(payload)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function readBody(req) {
|
|
765
|
+
return new Promise((resolve, reject) => {
|
|
766
|
+
let data = ''
|
|
767
|
+
req.on('data', (chunk) => {
|
|
768
|
+
data += chunk
|
|
769
|
+
if (data.length > 1e6) req.destroy()
|
|
770
|
+
})
|
|
771
|
+
req.on('end', () => resolve(data))
|
|
772
|
+
req.on('error', reject)
|
|
773
|
+
req.on('aborted', () => reject(new Error('aborted')))
|
|
774
|
+
})
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Rewriting the derived context is destructive for the model, so every route
|
|
778
|
+
// demands a loopback socket, a loopback Host header, and a same-origin check
|
|
779
|
+
// when the browser sends Origin.
|
|
780
|
+
function guard(req, res) {
|
|
781
|
+
if (!isLoopbackAddress(req.socket && req.socket.remoteAddress)) {
|
|
782
|
+
sendJson(res, 403, { ok: false, code: 'forbidden', error: 'loopback only' })
|
|
783
|
+
return false
|
|
784
|
+
}
|
|
785
|
+
const host = req.headers.host
|
|
786
|
+
if (!isLocalHostHeader(host)) {
|
|
787
|
+
sendJson(res, 403, { ok: false, code: 'forbidden', error: 'unexpected host' })
|
|
788
|
+
return false
|
|
789
|
+
}
|
|
790
|
+
const origin = req.headers.origin
|
|
791
|
+
if (typeof origin === 'string' && origin.length > 0) {
|
|
792
|
+
let originHost = null
|
|
793
|
+
try {
|
|
794
|
+
originHost = new URL(origin).host
|
|
795
|
+
} catch {
|
|
796
|
+
originHost = null
|
|
797
|
+
}
|
|
798
|
+
if (originHost !== host) {
|
|
799
|
+
sendJson(res, 403, { ok: false, code: 'forbidden', error: 'cross-origin request' })
|
|
800
|
+
return false
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return true
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function sessionIdFromQuery(url) {
|
|
807
|
+
try {
|
|
808
|
+
return (new URL(url, 'http://localhost').searchParams.get('sessionId') || '').trim()
|
|
809
|
+
} catch {
|
|
810
|
+
return ''
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function requireSessionId(value) {
|
|
815
|
+
if (!value) throw new HttpError(400, 'invalid', 'sessionId required')
|
|
816
|
+
if (!SESSION_ID_RE.test(value)) throw new HttpError(400, 'invalid', 'invalid session id')
|
|
817
|
+
return value
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function failure(res, error) {
|
|
821
|
+
const status = error instanceof HttpError ? error.status : 500
|
|
822
|
+
const code = error instanceof HttpError ? error.code : 'internal'
|
|
823
|
+
sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// --- plugin ------------------------------------------------------------------
|
|
827
|
+
|
|
828
|
+
export function apply(ctx, config) {
|
|
829
|
+
const resolved = {
|
|
830
|
+
markerText:
|
|
831
|
+
config && typeof config.markerText === 'string' && config.markerText.trim() !== ''
|
|
832
|
+
? config.markerText
|
|
833
|
+
: DEFAULT_MARKER,
|
|
834
|
+
carrier: config && config.carrier === 'user/message' ? 'user/message' : 'system/message',
|
|
835
|
+
confirm: config && typeof config.confirm === 'boolean' ? config.confirm : true,
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const registerRoutes = (webServer, fiber) => {
|
|
839
|
+
fiber.effect(() =>
|
|
840
|
+
webServer.register({
|
|
841
|
+
kind: 'exact',
|
|
842
|
+
path: `${ROUTE_PREFIX}/state`,
|
|
843
|
+
handler: async (req, res) => {
|
|
844
|
+
if (!guard(req, res)) return
|
|
845
|
+
if (req.method !== 'GET') {
|
|
846
|
+
sendJson(res, 405, { ok: false, code: 'method', error: 'GET only' })
|
|
847
|
+
return
|
|
848
|
+
}
|
|
849
|
+
try {
|
|
850
|
+
const sessionId = requireSessionId(sessionIdFromQuery(req.url))
|
|
851
|
+
sendJson(res, 200, { ok: true, ...(await stateOf(ctx, sessionId, resolved)) })
|
|
852
|
+
} catch (error) {
|
|
853
|
+
failure(res, error)
|
|
854
|
+
}
|
|
855
|
+
},
|
|
856
|
+
}),
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
fiber.effect(() =>
|
|
860
|
+
webServer.register({
|
|
861
|
+
kind: 'exact',
|
|
862
|
+
path: `${ROUTE_PREFIX}/apply`,
|
|
863
|
+
handler: async (req, res) => {
|
|
864
|
+
if (!guard(req, res)) return
|
|
865
|
+
if (req.method !== 'POST') {
|
|
866
|
+
sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
|
|
867
|
+
return
|
|
868
|
+
}
|
|
869
|
+
let body = {}
|
|
870
|
+
try {
|
|
871
|
+
const raw = await readBody(req)
|
|
872
|
+
if (raw) body = JSON.parse(raw)
|
|
873
|
+
} catch {
|
|
874
|
+
sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
|
|
875
|
+
return
|
|
876
|
+
}
|
|
877
|
+
try {
|
|
878
|
+
const sessionId = requireSessionId(typeof body.sessionId === 'string' ? body.sessionId.trim() : '')
|
|
879
|
+
sendJson(res, 200, { ok: true, ...(await applyEdit(ctx, sessionId, body, resolved)) })
|
|
880
|
+
} catch (error) {
|
|
881
|
+
failure(res, error)
|
|
882
|
+
}
|
|
883
|
+
},
|
|
884
|
+
}),
|
|
885
|
+
)
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const webServer = ctx.get('webServer')
|
|
889
|
+
if (webServer) {
|
|
890
|
+
registerRoutes(webServer, ctx)
|
|
891
|
+
} else {
|
|
892
|
+
ctx.inject(['webServer'], (sub) => registerRoutes(sub.webServer, sub))
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const tools = ctx.get('tools')
|
|
896
|
+
if (tools) {
|
|
897
|
+
registerTool(ctx)
|
|
898
|
+
} else {
|
|
899
|
+
ctx.inject(['tools'], (sub) => registerTool(sub))
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
export default apply
|