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.
- package/HUMANS.txt +18 -0
- package/LICENSE +21 -0
- package/README.md +317 -0
- package/README.zh.md +284 -0
- package/cordis.patch.yml +17 -0
- package/lib/artifact-store.js +239 -0
- package/lib/client.bundle.js +637 -0
- package/lib/client.js +752 -0
- package/lib/dynamic-client.js +647 -0
- package/lib/dynamic-host.js +399 -0
- package/lib/host-core.js +379 -0
- package/lib/http.js +186 -0
- package/lib/index.js +75 -0
- package/lib/projection/versions.js +69 -0
- package/lib/types/client.d.ts +14 -0
- package/lib/types/index.d.ts +65 -0
- package/lib/version-index.js +310 -0
- package/lib/versioning.js +193 -0
- package/package.json +100 -0
|
@@ -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
|
+
}
|