dsh-retrace 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.
@@ -0,0 +1,399 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ * Source of truth: lib/host-core.js + the wrapper below (scripts/generate-dynamic.mjs).
4
+ */
5
+ return {
6
+ inject: ['sessions', 'agents'],
7
+ apply(ctx) {
8
+ const { sessions, agents } = ctx
9
+ const log = (line) => console.error(`retrace: ${line}`)
10
+ /**
11
+ * dsh-retrace — Host core.
12
+ *
13
+ * Shared business logic for message recall (撤回), edit-and-resend (编辑重发)
14
+ * and regenerate (重新生成) over one DSH Session.
15
+ *
16
+ * The DSH conversation log is append-only, but the model-visible *surface*
17
+ * supports positional replacement (the same primitive compaction uses): a new
18
+ * surface-eligible event carrying `surfaceOp: { op: 'replace', start, end }`
19
+ * shadows every node in [start..end] from the derived model history. This
20
+ * module appends an *invisible* replacement marker (an empty assistant message
21
+ * derives to no model message) so the conversation rewinds to before the
22
+ * target while the durable transcript keeps an audit trail.
23
+ *
24
+ * Pure ESM with zero imports: safe to run inside the dynamic-package sandbox
25
+ * and inside a published package alike. Every op resolves to a transport-
26
+ * neutral result object `{ ok: true, value }` or `{ ok: false, error }` and
27
+ * never rejects (transport failures are the caller's concern).
28
+ */
29
+
30
+ const EDITOR_PLUGIN = 'retrace'
31
+
32
+ /** Message-id prefix every event this plugin appends carries (client discriminator). */
33
+ const MARKER_ID_PREFIX = 'retrace'
34
+
35
+ function createEditorApi(ctx, sessions, agents, log = () => {}) {
36
+ /** One in-flight op per session; later ops wait for the earlier one. */
37
+ const locks = new Map()
38
+
39
+ function locked(sessionId, fn) {
40
+ const previous = locks.get(sessionId) ?? Promise.resolve()
41
+ const next = previous.catch(() => {}).then(fn)
42
+ locks.set(sessionId, next)
43
+ void next.finally(() => {
44
+ if (locks.get(sessionId) === next) locks.delete(sessionId)
45
+ }).catch(() => {})
46
+ return next
47
+ }
48
+
49
+ function editorId(op) {
50
+ return `${MARKER_ID_PREFIX}-${op}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
51
+ }
52
+
53
+ function editorError(code, message) {
54
+ const error = new Error(message)
55
+ error.code = code
56
+ return error
57
+ }
58
+
59
+ function requireSession(sessionId) {
60
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
61
+ throw editorError('bad-request', 'sessionId must be a non-empty string')
62
+ }
63
+ const session = sessions.get(sessionId)
64
+ if (!session) throw editorError('session-not-found', `session "${sessionId}" not found`)
65
+ return session
66
+ }
67
+
68
+ function requireIdle(agent) {
69
+ if (agent && typeof agent.status === 'string' && agent.status === 'running') {
70
+ throw editorError(
71
+ 'agent-busy',
72
+ 'The agent is still responding. Stop the current reply before recalling or editing.',
73
+ )
74
+ }
75
+ }
76
+
77
+ /** Latest known provider/model: from the last request header, else last assistant message. */
78
+ function lastModelSource(session) {
79
+ const events = session.events
80
+ for (let i = events.length - 1; i >= 0; i--) {
81
+ const event = events[i]
82
+ if (event.type === 'request/header') {
83
+ const config = event.data?.header?.config
84
+ if (
85
+ config &&
86
+ typeof config.provider === 'string' && config.provider.length > 0 &&
87
+ typeof config.model === 'string' && config.model.length > 0
88
+ ) {
89
+ return { provider: config.provider, model: config.model }
90
+ }
91
+ }
92
+ if (event.type === 'assistant/message') {
93
+ const source = event.data?.message?.source
94
+ if (
95
+ source && source.kind === 'model' &&
96
+ typeof source.provider === 'string' && source.provider.length > 0 &&
97
+ typeof source.model === 'string' && source.model.length > 0
98
+ ) {
99
+ return { provider: source.provider, model: source.model }
100
+ }
101
+ }
102
+ }
103
+ return null
104
+ }
105
+
106
+ /** Locate the durable seq of a user/assistant message by its stable message id. */
107
+ function findMessageSeq(session, messageId) {
108
+ if (typeof messageId !== 'string' || messageId.length === 0) {
109
+ throw editorError('bad-request', 'messageId must be a non-empty string')
110
+ }
111
+ const events = session.events
112
+ for (let i = events.length - 1; i >= 0; i--) {
113
+ const event = events[i]
114
+ const id = event.type === 'user/message'
115
+ ? event.data?.id
116
+ : event.type === 'assistant/message'
117
+ ? event.data?.message?.id
118
+ : undefined
119
+ if (typeof id === 'string' && id === messageId) return event.seq
120
+ }
121
+ return -1
122
+ }
123
+
124
+ /** The current model-surface span starting at `startSeq` through the tail. */
125
+ function shadowSpanFrom(session, startSeq) {
126
+ const nodes = session.surface.nodes
127
+ const index = nodes.indexOf(startSeq)
128
+ if (index === -1) return null
129
+ const span = nodes.slice(index)
130
+ return {
131
+ start: span[0],
132
+ end: span[span.length - 1],
133
+ shadowedSeqs: span.slice(),
134
+ }
135
+ }
136
+
137
+ /**
138
+ * A round boundary is a real user-sent message. The runtime also appends
139
+ * `user/message` events for injected context/steering (source.kind !== 'user',
140
+ * e.g. the runtime-context snapshot); those must NOT split an exchange round.
141
+ */
142
+ function isRoundBoundary(event) {
143
+ return event?.type === 'user/message' && event.data?.source?.kind === 'user'
144
+ }
145
+
146
+ /**
147
+ * The exchange-round span containing `seq`: the user input plus everything
148
+ * the agent produced for it (all assistant/tool nodes up to the next user
149
+ * input). Recalling one message therefore removes the whole round — input
150
+ * AND output — from the model surface.
151
+ */
152
+ function roundSpanFrom(session, seq) {
153
+ const nodes = session.surface.nodes
154
+ const index = nodes.indexOf(seq)
155
+ if (index === -1) return null
156
+ const targetEvent = session.events[seq]
157
+ let startIdx = index
158
+ if (!isRoundBoundary(targetEvent)) {
159
+ for (let i = index - 1; i >= 0; i--) {
160
+ if (isRoundBoundary(session.events[nodes[i]])) {
161
+ startIdx = i
162
+ break
163
+ }
164
+ }
165
+ }
166
+ let endIdx = nodes.length - 1
167
+ for (let i = startIdx + 1; i < nodes.length; i++) {
168
+ if (isRoundBoundary(session.events[nodes[i]])) {
169
+ endIdx = i - 1
170
+ break
171
+ }
172
+ }
173
+ const span = nodes.slice(startIdx, endIdx + 1)
174
+ return {
175
+ start: span[0],
176
+ end: span[span.length - 1],
177
+ shadowedSeqs: span,
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Append the invisible replacement marker that shadows [span.start..span.end].
183
+ * An empty assistant message is a valid surface node yet derives to *no*
184
+ * model message, so the LLM context simply rewinds. `turn: null` declares a
185
+ * session-level event (the client location index maps it to SESSION_LOCATION,
186
+ * keeping it out of every turn's node list). `editor` carries the reference
187
+ * facts the UI uses for the optional "original input" comparison row.
188
+ */
189
+ function appendEditorMarker(session, span, op, targetSeq, originalText) {
190
+ const model = lastModelSource(session)
191
+ if (!model) {
192
+ throw editorError(
193
+ 'no-model-header',
194
+ 'This session has no model header yet; send at least one message before recalling or editing.',
195
+ )
196
+ }
197
+ const marker = {
198
+ id: editorId(op),
199
+ role: 'assistant',
200
+ content: [],
201
+ source: { kind: 'model', provider: model.provider, model: model.model },
202
+ }
203
+ return session.append('assistant/message', {
204
+ turn: null,
205
+ step: null,
206
+ message: marker,
207
+ editor: {
208
+ targetSeq,
209
+ text: typeof originalText === 'string' ? originalText.slice(0, 2000) : '',
210
+ },
211
+ }, {
212
+ surfaceOp: { op: 'replace', start: span.start, end: span.end },
213
+ sourceEventSeqs: span.shadowedSeqs.slice(),
214
+ })
215
+ }
216
+
217
+ function extractUserText(content) {
218
+ if (!Array.isArray(content)) return ''
219
+ return content
220
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
221
+ .map((block) => block.text)
222
+ .join('')
223
+ }
224
+
225
+ function resendMessage(text, op) {
226
+ return {
227
+ id: editorId(op),
228
+ role: 'user',
229
+ content: [{ type: 'text', text }],
230
+ source: { kind: 'user', rpcId: editorId('retrace') },
231
+ }
232
+ }
233
+
234
+ async function flushSafely(session) {
235
+ try {
236
+ if (typeof sessions.flush === 'function') await sessions.flush(session)
237
+ } catch (error) {
238
+ log(`retrace: flush failed: ${String(error)}`)
239
+ }
240
+ }
241
+
242
+ /** Wrap one op body into the transport-neutral result convention. */
243
+ function op(fn) {
244
+ return (args) =>
245
+ locked(String(args?.sessionId ?? ''), () =>
246
+ Promise.resolve()
247
+ .then(() => fn(args))
248
+ .then(
249
+ (value) => ({ ok: true, value }),
250
+ (error) => ({
251
+ ok: false,
252
+ error: {
253
+ code: error && typeof error.code === 'string' ? error.code : 'internal',
254
+ message: error instanceof Error ? error.message : String(error),
255
+ },
256
+ }),
257
+ ),
258
+ )
259
+ }
260
+
261
+ /** Extract the durable text of a user or assistant message by seq. */
262
+ function messageTextOf(session, seq) {
263
+ const event = session.events[seq]
264
+ if (!event) return ''
265
+ const data = event.type === 'user/message' ? event.data : event.data?.message
266
+ return extractUserText(data?.content)
267
+ }
268
+
269
+ /** 撤回: remove the whole exchange round (input + output) around one message. */
270
+ const recall = op(async (args) => {
271
+ const sessionId = String(args?.sessionId ?? '')
272
+ const messageId = String(args?.messageId ?? '')
273
+ const session = requireSession(sessionId)
274
+ requireIdle(agents.get(sessionId))
275
+ const seq = findMessageSeq(session, messageId)
276
+ if (seq === -1) throw editorError('message-not-found', 'Message not found in this session.')
277
+ const span = roundSpanFrom(session, seq)
278
+ if (!span) throw editorError('target-shadowed', 'This message is no longer part of the active conversation.')
279
+ const markerEvent = appendEditorMarker(session, span, 'recall', seq, messageTextOf(session, seq))
280
+ await flushSafely(session)
281
+ return {
282
+ op: 'recall',
283
+ messageId,
284
+ seq,
285
+ markerSeq: markerEvent.seq,
286
+ shadowed: span.shadowedSeqs.length,
287
+ text: messageTextOf(session, seq),
288
+ }
289
+ })
290
+
291
+ /**
292
+ * 编辑重发: rewind before a user message, replace it with `text`, then re-trigger
293
+ * the agent. With `fromScratch` the whole surface is rewound first, so the
294
+ * conversation continues from a clean slate (new-conversation semantics).
295
+ */
296
+ const editAndResend = op(async (args) => {
297
+ const sessionId = String(args?.sessionId ?? '')
298
+ const messageId = String(args?.messageId ?? '')
299
+ const text = args?.text
300
+ const fromScratch = args?.fromScratch === true
301
+ const session = requireSession(sessionId)
302
+ const agent = agents.get(sessionId)
303
+ requireIdle(agent)
304
+ const seq = findMessageSeq(session, messageId)
305
+ if (seq === -1) throw editorError('message-not-found', 'Message not found in this session.')
306
+ const event = session.events[seq]
307
+ if (!isRoundBoundary(event)) {
308
+ throw editorError('not-user-message', 'Only user messages can be edited and re-sent.')
309
+ }
310
+ if (typeof text !== 'string' || text.trim().length === 0) {
311
+ throw editorError('blank-text', 'The edited message must not be empty.')
312
+ }
313
+ if (!agent || typeof agent.followup !== 'function') {
314
+ throw editorError('agent-unavailable', 'No live agent for this session; cannot re-send.')
315
+ }
316
+ const originalText = messageTextOf(session, seq)
317
+ const startSeq = fromScratch ? session.surface.nodes[0] : seq
318
+ if (startSeq === undefined) throw editorError('empty-surface', 'This session has no conversation to edit.')
319
+ const span = shadowSpanFrom(session, startSeq)
320
+ if (!span) throw editorError('target-shadowed', 'This message is no longer part of the active conversation.')
321
+ appendEditorMarker(session, span, 'edit', seq, originalText)
322
+ await flushSafely(session)
323
+ const message = resendMessage(text.trim(), 'resend')
324
+ agent.followup(message)
325
+ return {
326
+ op: 'edit',
327
+ messageId,
328
+ seq,
329
+ resendMessageId: message.id,
330
+ shadowed: span.shadowedSeqs.length,
331
+ text: text.trim(),
332
+ originalText,
333
+ fromScratch,
334
+ }
335
+ })
336
+
337
+ /** 重新生成: rewind to the user prompt that produced one assistant reply, then re-send it. */
338
+ const regenerate = op(async (args) => {
339
+ const sessionId = String(args?.sessionId ?? '')
340
+ const messageId = String(args?.messageId ?? '')
341
+ const session = requireSession(sessionId)
342
+ const agent = agents.get(sessionId)
343
+ requireIdle(agent)
344
+ const seq = findMessageSeq(session, messageId)
345
+ if (seq === -1) throw editorError('message-not-found', 'Message not found in this session.')
346
+ const event = session.events[seq]
347
+ if (event?.type !== 'assistant/message') {
348
+ throw editorError('not-assistant-message', 'Regenerate targets an assistant reply.')
349
+ }
350
+ const nodes = session.surface.nodes
351
+ const index = nodes.indexOf(seq)
352
+ if (index === -1) throw editorError('target-shadowed', 'This message is no longer part of the active conversation.')
353
+ let userSeq = -1
354
+ for (let i = index - 1; i >= 0; i--) {
355
+ if (isRoundBoundary(session.events[nodes[i]])) {
356
+ userSeq = nodes[i]
357
+ break
358
+ }
359
+ }
360
+ if (userSeq === -1) throw editorError('no-prompt', 'No user message precedes this reply; cannot regenerate.')
361
+ const text = extractUserText(session.events[userSeq]?.data?.content)
362
+ if (!text.trim()) {
363
+ throw editorError('no-text', 'The original message carries no text to regenerate from.')
364
+ }
365
+ if (!agent || typeof agent.followup !== 'function') {
366
+ throw editorError('agent-unavailable', 'No live agent for this session; cannot re-send.')
367
+ }
368
+ const span = shadowSpanFrom(session, userSeq)
369
+ if (!span) throw editorError('target-shadowed', 'This message is no longer part of the active conversation.')
370
+ appendEditorMarker(session, span, 'regenerate', userSeq, text)
371
+ await flushSafely(session)
372
+ const message = resendMessage(text.trim(), 'resend')
373
+ agent.followup(message)
374
+ return {
375
+ op: 'regenerate',
376
+ messageId,
377
+ seq,
378
+ resendMessageId: message.id,
379
+ shadowed: span.shadowedSeqs.length,
380
+ }
381
+ })
382
+
383
+ return {
384
+ recall: (args) => recall(args),
385
+ editAndResend: (args) => editAndResend(args),
386
+ regenerate: (args) => regenerate(args),
387
+ }
388
+ }
389
+ const api = createEditorApi(ctx, sessions, agents, log)
390
+ const disposers = [
391
+ harness.handle('retrace.recall', (args) => api.recall(args)),
392
+ harness.handle('retrace.editAndResend', (args) => api.editAndResend(args)),
393
+ harness.handle('retrace.regenerate', (args) => api.regenerate(args)),
394
+ ]
395
+ ctx.effect(() => () => {
396
+ for (const dispose of disposers) dispose()
397
+ }, 'retrace: handlers')
398
+ },
399
+ }