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
package/lib/host-core.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-retrace — Host core.
|
|
3
|
+
*
|
|
4
|
+
* Shared business logic for message recall (撤回), edit-and-resend (编辑重发)
|
|
5
|
+
* and regenerate (重新生成) over one DSH Session.
|
|
6
|
+
*
|
|
7
|
+
* The DSH conversation log is append-only, but the model-visible *surface*
|
|
8
|
+
* supports positional replacement (the same primitive compaction uses): a new
|
|
9
|
+
* surface-eligible event carrying `surfaceOp: { op: 'replace', start, end }`
|
|
10
|
+
* shadows every node in [start..end] from the derived model history. This
|
|
11
|
+
* module appends an *invisible* replacement marker (an empty assistant message
|
|
12
|
+
* derives to no model message) so the conversation rewinds to before the
|
|
13
|
+
* target while the durable transcript keeps an audit trail.
|
|
14
|
+
*
|
|
15
|
+
* Pure ESM with zero imports: safe to run inside the dynamic-package sandbox
|
|
16
|
+
* and inside a published package alike. Every op resolves to a transport-
|
|
17
|
+
* neutral result object `{ ok: true, value }` or `{ ok: false, error }` and
|
|
18
|
+
* never rejects (transport failures are the caller's concern).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const EDITOR_PLUGIN = 'retrace'
|
|
22
|
+
|
|
23
|
+
/** Message-id prefix every event this plugin appends carries (client discriminator). */
|
|
24
|
+
export const MARKER_ID_PREFIX = 'retrace'
|
|
25
|
+
|
|
26
|
+
export function createEditorApi(ctx, sessions, agents, log = () => {}) {
|
|
27
|
+
/** One in-flight op per session; later ops wait for the earlier one. */
|
|
28
|
+
const locks = new Map()
|
|
29
|
+
|
|
30
|
+
function locked(sessionId, fn) {
|
|
31
|
+
const previous = locks.get(sessionId) ?? Promise.resolve()
|
|
32
|
+
const next = previous.catch(() => {}).then(fn)
|
|
33
|
+
locks.set(sessionId, next)
|
|
34
|
+
void next.finally(() => {
|
|
35
|
+
if (locks.get(sessionId) === next) locks.delete(sessionId)
|
|
36
|
+
}).catch(() => {})
|
|
37
|
+
return next
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function editorId(op) {
|
|
41
|
+
return `${MARKER_ID_PREFIX}-${op}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function editorError(code, message) {
|
|
45
|
+
const error = new Error(message)
|
|
46
|
+
error.code = code
|
|
47
|
+
return error
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requireSession(sessionId) {
|
|
51
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
52
|
+
throw editorError('bad-request', 'sessionId must be a non-empty string')
|
|
53
|
+
}
|
|
54
|
+
const session = sessions.get(sessionId)
|
|
55
|
+
if (!session) throw editorError('session-not-found', `session "${sessionId}" not found`)
|
|
56
|
+
return session
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requireIdle(agent) {
|
|
60
|
+
if (agent && typeof agent.status === 'string' && agent.status === 'running') {
|
|
61
|
+
throw editorError(
|
|
62
|
+
'agent-busy',
|
|
63
|
+
'The agent is still responding. Stop the current reply before recalling or editing.',
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Latest known provider/model: from the last request header, else last assistant message. */
|
|
69
|
+
function lastModelSource(session) {
|
|
70
|
+
const events = session.events
|
|
71
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
72
|
+
const event = events[i]
|
|
73
|
+
if (event.type === 'request/header') {
|
|
74
|
+
const config = event.data?.header?.config
|
|
75
|
+
if (
|
|
76
|
+
config &&
|
|
77
|
+
typeof config.provider === 'string' && config.provider.length > 0 &&
|
|
78
|
+
typeof config.model === 'string' && config.model.length > 0
|
|
79
|
+
) {
|
|
80
|
+
return { provider: config.provider, model: config.model }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (event.type === 'assistant/message') {
|
|
84
|
+
const source = event.data?.message?.source
|
|
85
|
+
if (
|
|
86
|
+
source && source.kind === 'model' &&
|
|
87
|
+
typeof source.provider === 'string' && source.provider.length > 0 &&
|
|
88
|
+
typeof source.model === 'string' && source.model.length > 0
|
|
89
|
+
) {
|
|
90
|
+
return { provider: source.provider, model: source.model }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Locate the durable seq of a user/assistant message by its stable message id. */
|
|
98
|
+
function findMessageSeq(session, messageId) {
|
|
99
|
+
if (typeof messageId !== 'string' || messageId.length === 0) {
|
|
100
|
+
throw editorError('bad-request', 'messageId must be a non-empty string')
|
|
101
|
+
}
|
|
102
|
+
const events = session.events
|
|
103
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
104
|
+
const event = events[i]
|
|
105
|
+
const id = event.type === 'user/message'
|
|
106
|
+
? event.data?.id
|
|
107
|
+
: event.type === 'assistant/message'
|
|
108
|
+
? event.data?.message?.id
|
|
109
|
+
: undefined
|
|
110
|
+
if (typeof id === 'string' && id === messageId) return event.seq
|
|
111
|
+
}
|
|
112
|
+
return -1
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The current model-surface span starting at `startSeq` through the tail. */
|
|
116
|
+
function shadowSpanFrom(session, startSeq) {
|
|
117
|
+
const nodes = session.surface.nodes
|
|
118
|
+
const index = nodes.indexOf(startSeq)
|
|
119
|
+
if (index === -1) return null
|
|
120
|
+
const span = nodes.slice(index)
|
|
121
|
+
return {
|
|
122
|
+
start: span[0],
|
|
123
|
+
end: span[span.length - 1],
|
|
124
|
+
shadowedSeqs: span.slice(),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A round boundary is a real user-sent message. The runtime also appends
|
|
130
|
+
* `user/message` events for injected context/steering (source.kind !== 'user',
|
|
131
|
+
* e.g. the runtime-context snapshot); those must NOT split an exchange round.
|
|
132
|
+
*/
|
|
133
|
+
function isRoundBoundary(event) {
|
|
134
|
+
return event?.type === 'user/message' && event.data?.source?.kind === 'user'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The exchange-round span containing `seq`: the user input plus everything
|
|
139
|
+
* the agent produced for it (all assistant/tool nodes up to the next user
|
|
140
|
+
* input). Recalling one message therefore removes the whole round — input
|
|
141
|
+
* AND output — from the model surface.
|
|
142
|
+
*/
|
|
143
|
+
function roundSpanFrom(session, seq) {
|
|
144
|
+
const nodes = session.surface.nodes
|
|
145
|
+
const index = nodes.indexOf(seq)
|
|
146
|
+
if (index === -1) return null
|
|
147
|
+
const targetEvent = session.events[seq]
|
|
148
|
+
let startIdx = index
|
|
149
|
+
if (!isRoundBoundary(targetEvent)) {
|
|
150
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
151
|
+
if (isRoundBoundary(session.events[nodes[i]])) {
|
|
152
|
+
startIdx = i
|
|
153
|
+
break
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
let endIdx = nodes.length - 1
|
|
158
|
+
for (let i = startIdx + 1; i < nodes.length; i++) {
|
|
159
|
+
if (isRoundBoundary(session.events[nodes[i]])) {
|
|
160
|
+
endIdx = i - 1
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const span = nodes.slice(startIdx, endIdx + 1)
|
|
165
|
+
return {
|
|
166
|
+
start: span[0],
|
|
167
|
+
end: span[span.length - 1],
|
|
168
|
+
shadowedSeqs: span,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Append the invisible replacement marker that shadows [span.start..span.end].
|
|
174
|
+
* An empty assistant message is a valid surface node yet derives to *no*
|
|
175
|
+
* model message, so the LLM context simply rewinds. `turn: null` declares a
|
|
176
|
+
* session-level event (the client location index maps it to SESSION_LOCATION,
|
|
177
|
+
* keeping it out of every turn's node list). `editor` carries the reference
|
|
178
|
+
* facts the UI uses for the optional "original input" comparison row.
|
|
179
|
+
*/
|
|
180
|
+
function appendEditorMarker(session, span, op, targetSeq, originalText) {
|
|
181
|
+
const model = lastModelSource(session)
|
|
182
|
+
if (!model) {
|
|
183
|
+
throw editorError(
|
|
184
|
+
'no-model-header',
|
|
185
|
+
'This session has no model header yet; send at least one message before recalling or editing.',
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
const marker = {
|
|
189
|
+
id: editorId(op),
|
|
190
|
+
role: 'assistant',
|
|
191
|
+
content: [],
|
|
192
|
+
source: { kind: 'model', provider: model.provider, model: model.model },
|
|
193
|
+
}
|
|
194
|
+
return session.append('assistant/message', {
|
|
195
|
+
turn: null,
|
|
196
|
+
step: null,
|
|
197
|
+
message: marker,
|
|
198
|
+
editor: {
|
|
199
|
+
targetSeq,
|
|
200
|
+
text: typeof originalText === 'string' ? originalText.slice(0, 2000) : '',
|
|
201
|
+
},
|
|
202
|
+
}, {
|
|
203
|
+
surfaceOp: { op: 'replace', start: span.start, end: span.end },
|
|
204
|
+
sourceEventSeqs: span.shadowedSeqs.slice(),
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function extractUserText(content) {
|
|
209
|
+
if (!Array.isArray(content)) return ''
|
|
210
|
+
return content
|
|
211
|
+
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
|
|
212
|
+
.map((block) => block.text)
|
|
213
|
+
.join('')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function resendMessage(text, op) {
|
|
217
|
+
return {
|
|
218
|
+
id: editorId(op),
|
|
219
|
+
role: 'user',
|
|
220
|
+
content: [{ type: 'text', text }],
|
|
221
|
+
source: { kind: 'user', rpcId: editorId('retrace') },
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function flushSafely(session) {
|
|
226
|
+
try {
|
|
227
|
+
if (typeof sessions.flush === 'function') await sessions.flush(session)
|
|
228
|
+
} catch (error) {
|
|
229
|
+
log(`retrace: flush failed: ${String(error)}`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Wrap one op body into the transport-neutral result convention. */
|
|
234
|
+
function op(fn) {
|
|
235
|
+
return (args) =>
|
|
236
|
+
locked(String(args?.sessionId ?? ''), () =>
|
|
237
|
+
Promise.resolve()
|
|
238
|
+
.then(() => fn(args))
|
|
239
|
+
.then(
|
|
240
|
+
(value) => ({ ok: true, value }),
|
|
241
|
+
(error) => ({
|
|
242
|
+
ok: false,
|
|
243
|
+
error: {
|
|
244
|
+
code: error && typeof error.code === 'string' ? error.code : 'internal',
|
|
245
|
+
message: error instanceof Error ? error.message : String(error),
|
|
246
|
+
},
|
|
247
|
+
}),
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Extract the durable text of a user or assistant message by seq. */
|
|
253
|
+
function messageTextOf(session, seq) {
|
|
254
|
+
const event = session.events[seq]
|
|
255
|
+
if (!event) return ''
|
|
256
|
+
const data = event.type === 'user/message' ? event.data : event.data?.message
|
|
257
|
+
return extractUserText(data?.content)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 撤回: remove the whole exchange round (input + output) around one message. */
|
|
261
|
+
const recall = op(async (args) => {
|
|
262
|
+
const sessionId = String(args?.sessionId ?? '')
|
|
263
|
+
const messageId = String(args?.messageId ?? '')
|
|
264
|
+
const session = requireSession(sessionId)
|
|
265
|
+
requireIdle(agents.get(sessionId))
|
|
266
|
+
const seq = findMessageSeq(session, messageId)
|
|
267
|
+
if (seq === -1) throw editorError('message-not-found', 'Message not found in this session.')
|
|
268
|
+
const span = roundSpanFrom(session, seq)
|
|
269
|
+
if (!span) throw editorError('target-shadowed', 'This message is no longer part of the active conversation.')
|
|
270
|
+
const markerEvent = appendEditorMarker(session, span, 'recall', seq, messageTextOf(session, seq))
|
|
271
|
+
await flushSafely(session)
|
|
272
|
+
return {
|
|
273
|
+
op: 'recall',
|
|
274
|
+
messageId,
|
|
275
|
+
seq,
|
|
276
|
+
markerSeq: markerEvent.seq,
|
|
277
|
+
shadowed: span.shadowedSeqs.length,
|
|
278
|
+
text: messageTextOf(session, seq),
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 编辑重发: rewind before a user message, replace it with `text`, then re-trigger
|
|
284
|
+
* the agent. With `fromScratch` the whole surface is rewound first, so the
|
|
285
|
+
* conversation continues from a clean slate (new-conversation semantics).
|
|
286
|
+
*/
|
|
287
|
+
const editAndResend = op(async (args) => {
|
|
288
|
+
const sessionId = String(args?.sessionId ?? '')
|
|
289
|
+
const messageId = String(args?.messageId ?? '')
|
|
290
|
+
const text = args?.text
|
|
291
|
+
const fromScratch = args?.fromScratch === true
|
|
292
|
+
const session = requireSession(sessionId)
|
|
293
|
+
const agent = agents.get(sessionId)
|
|
294
|
+
requireIdle(agent)
|
|
295
|
+
const seq = findMessageSeq(session, messageId)
|
|
296
|
+
if (seq === -1) throw editorError('message-not-found', 'Message not found in this session.')
|
|
297
|
+
const event = session.events[seq]
|
|
298
|
+
if (!isRoundBoundary(event)) {
|
|
299
|
+
throw editorError('not-user-message', 'Only user messages can be edited and re-sent.')
|
|
300
|
+
}
|
|
301
|
+
if (typeof text !== 'string' || text.trim().length === 0) {
|
|
302
|
+
throw editorError('blank-text', 'The edited message must not be empty.')
|
|
303
|
+
}
|
|
304
|
+
if (!agent || typeof agent.followup !== 'function') {
|
|
305
|
+
throw editorError('agent-unavailable', 'No live agent for this session; cannot re-send.')
|
|
306
|
+
}
|
|
307
|
+
const originalText = messageTextOf(session, seq)
|
|
308
|
+
const startSeq = fromScratch ? session.surface.nodes[0] : seq
|
|
309
|
+
if (startSeq === undefined) throw editorError('empty-surface', 'This session has no conversation to edit.')
|
|
310
|
+
const span = shadowSpanFrom(session, startSeq)
|
|
311
|
+
if (!span) throw editorError('target-shadowed', 'This message is no longer part of the active conversation.')
|
|
312
|
+
appendEditorMarker(session, span, 'edit', seq, originalText)
|
|
313
|
+
await flushSafely(session)
|
|
314
|
+
const message = resendMessage(text.trim(), 'resend')
|
|
315
|
+
agent.followup(message)
|
|
316
|
+
return {
|
|
317
|
+
op: 'edit',
|
|
318
|
+
messageId,
|
|
319
|
+
seq,
|
|
320
|
+
resendMessageId: message.id,
|
|
321
|
+
shadowed: span.shadowedSeqs.length,
|
|
322
|
+
text: text.trim(),
|
|
323
|
+
originalText,
|
|
324
|
+
fromScratch,
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
/** 重新生成: rewind to the user prompt that produced one assistant reply, then re-send it. */
|
|
329
|
+
const regenerate = op(async (args) => {
|
|
330
|
+
const sessionId = String(args?.sessionId ?? '')
|
|
331
|
+
const messageId = String(args?.messageId ?? '')
|
|
332
|
+
const session = requireSession(sessionId)
|
|
333
|
+
const agent = agents.get(sessionId)
|
|
334
|
+
requireIdle(agent)
|
|
335
|
+
const seq = findMessageSeq(session, messageId)
|
|
336
|
+
if (seq === -1) throw editorError('message-not-found', 'Message not found in this session.')
|
|
337
|
+
const event = session.events[seq]
|
|
338
|
+
if (event?.type !== 'assistant/message') {
|
|
339
|
+
throw editorError('not-assistant-message', 'Regenerate targets an assistant reply.')
|
|
340
|
+
}
|
|
341
|
+
const nodes = session.surface.nodes
|
|
342
|
+
const index = nodes.indexOf(seq)
|
|
343
|
+
if (index === -1) throw editorError('target-shadowed', 'This message is no longer part of the active conversation.')
|
|
344
|
+
let userSeq = -1
|
|
345
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
346
|
+
if (isRoundBoundary(session.events[nodes[i]])) {
|
|
347
|
+
userSeq = nodes[i]
|
|
348
|
+
break
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (userSeq === -1) throw editorError('no-prompt', 'No user message precedes this reply; cannot regenerate.')
|
|
352
|
+
const text = extractUserText(session.events[userSeq]?.data?.content)
|
|
353
|
+
if (!text.trim()) {
|
|
354
|
+
throw editorError('no-text', 'The original message carries no text to regenerate from.')
|
|
355
|
+
}
|
|
356
|
+
if (!agent || typeof agent.followup !== 'function') {
|
|
357
|
+
throw editorError('agent-unavailable', 'No live agent for this session; cannot re-send.')
|
|
358
|
+
}
|
|
359
|
+
const span = shadowSpanFrom(session, userSeq)
|
|
360
|
+
if (!span) throw editorError('target-shadowed', 'This message is no longer part of the active conversation.')
|
|
361
|
+
appendEditorMarker(session, span, 'regenerate', userSeq, text)
|
|
362
|
+
await flushSafely(session)
|
|
363
|
+
const message = resendMessage(text.trim(), 'resend')
|
|
364
|
+
agent.followup(message)
|
|
365
|
+
return {
|
|
366
|
+
op: 'regenerate',
|
|
367
|
+
messageId,
|
|
368
|
+
seq,
|
|
369
|
+
resendMessageId: message.id,
|
|
370
|
+
shadowed: span.shadowedSeqs.length,
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
recall: (args) => recall(args),
|
|
376
|
+
editAndResend: (args) => editAndResend(args),
|
|
377
|
+
regenerate: (args) => regenerate(args),
|
|
378
|
+
}
|
|
379
|
+
}
|
package/lib/http.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-retrace — HTTP route aggregation for `/api/plugins/retrace/*`.
|
|
3
|
+
*
|
|
4
|
+
* Serves both the published-client transports and the versioning surface:
|
|
5
|
+
*
|
|
6
|
+
* POST /api/plugins/retrace/{recall|editAndResend|regenerate}
|
|
7
|
+
* — the L1 editor ops (unchanged wire shape from 0.2.x).
|
|
8
|
+
* GET /api/plugins/retrace/versions?sessionId=
|
|
9
|
+
* — live projection snapshot (HTTP fallback channel; the push channel is
|
|
10
|
+
* `session/projection` frames via dsh-host-apiproxy).
|
|
11
|
+
* GET /api/plugins/retrace/event?sessionId=&seq=&before=&after=
|
|
12
|
+
* — one event + context window (sessionQuery.readEvent, lazy reads).
|
|
13
|
+
* GET /api/plugins/retrace/surface?sessionId=
|
|
14
|
+
* — current model surface (sessionQuery.readSurface).
|
|
15
|
+
*
|
|
16
|
+
* Per PLAN.md §4.6 the client carries its localStorage config on every
|
|
17
|
+
* request as `x-retrace-config: {"versioning":bool,"git":bool,
|
|
18
|
+
* "retentionLimit":n}`; the host honors it per request and does not persist
|
|
19
|
+
* it. A missing/malformed header falls back to the plugin defaults.
|
|
20
|
+
*/
|
|
21
|
+
import { createEditorApi } from './host-core.js'
|
|
22
|
+
|
|
23
|
+
export const ROUTE_PREFIX = '/api/plugins/retrace'
|
|
24
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
25
|
+
|
|
26
|
+
/** Default per-request config (client overrides via the header). */
|
|
27
|
+
export const DEFAULT_CONFIG = { versioning: true, git: true, retentionLimit: 50 }
|
|
28
|
+
|
|
29
|
+
/** Parse the `x-retrace-config` request header (tolerant of garbage). */
|
|
30
|
+
export function parseRetraceConfig(raw) {
|
|
31
|
+
const config = { ...DEFAULT_CONFIG }
|
|
32
|
+
if (typeof raw !== 'string' || raw.length === 0) return config
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(raw)
|
|
35
|
+
if (typeof parsed.versioning === 'boolean') config.versioning = parsed.versioning
|
|
36
|
+
if (typeof parsed.git === 'boolean') config.git = parsed.git
|
|
37
|
+
if (Number.isInteger(parsed.retentionLimit) && parsed.retentionLimit > 0) {
|
|
38
|
+
config.retentionLimit = parsed.retentionLimit
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// malformed header → defaults
|
|
42
|
+
}
|
|
43
|
+
return config
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sendJson(res, status, value) {
|
|
47
|
+
const body = JSON.stringify(value)
|
|
48
|
+
res.writeHead(status, {
|
|
49
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
50
|
+
'Content-Length': Buffer.byteLength(body),
|
|
51
|
+
'Cache-Control': 'no-store',
|
|
52
|
+
})
|
|
53
|
+
res.end(body)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sendError(res, error) {
|
|
57
|
+
sendJson(res, 200, {
|
|
58
|
+
ok: false,
|
|
59
|
+
error: {
|
|
60
|
+
code: error && typeof error.code === 'string' ? error.code : 'internal',
|
|
61
|
+
message: error instanceof Error ? error.message : String(error),
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Route one request. `seam` is the versioning seam (lib/versioning.js). */
|
|
67
|
+
export function createRetraceHttpHandler(ctx, { sessions, agents, seam, log = () => {} }) {
|
|
68
|
+
const api = createEditorApi(ctx, sessions, agents, log)
|
|
69
|
+
|
|
70
|
+
function handleVersions(req, res, sessionId, config) {
|
|
71
|
+
seam.setConfig(sessionId, config)
|
|
72
|
+
try {
|
|
73
|
+
sendJson(res, 200, { ok: true, value: seam.snapshot(sessionId) })
|
|
74
|
+
} catch (error) {
|
|
75
|
+
sendError(res, error)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function handleEvent(req, res, searchParams) {
|
|
80
|
+
const sessionId = searchParams.get('sessionId') ?? ''
|
|
81
|
+
const seq = Number(searchParams.get('seq'))
|
|
82
|
+
const before = searchParams.has('before') ? Number(searchParams.get('before')) : undefined
|
|
83
|
+
const after = searchParams.has('after') ? Number(searchParams.get('after')) : undefined
|
|
84
|
+
try {
|
|
85
|
+
const value = await seam.readEvent({ sessionId, seq, before, after })
|
|
86
|
+
sendJson(res, 200, { ok: true, value })
|
|
87
|
+
} catch (error) {
|
|
88
|
+
sendError(res, error)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function handleSurface(req, res, sessionId) {
|
|
93
|
+
try {
|
|
94
|
+
const value = await seam.readSurface(sessionId)
|
|
95
|
+
sendJson(res, 200, { ok: true, value })
|
|
96
|
+
} catch (error) {
|
|
97
|
+
sendError(res, error)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function handlePost(req, res, op) {
|
|
102
|
+
let body = ''
|
|
103
|
+
req.setEncoding('utf8')
|
|
104
|
+
req.on('data', (chunk) => {
|
|
105
|
+
body += chunk
|
|
106
|
+
if (body.length > MAX_BODY_BYTES) {
|
|
107
|
+
sendJson(res, 413, {
|
|
108
|
+
ok: false,
|
|
109
|
+
error: { code: 'payload-too-large', message: 'payload exceeds 64 KiB' },
|
|
110
|
+
})
|
|
111
|
+
req.destroy()
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
req.on('error', () => { /* socket errors are terminal; nothing to send */ })
|
|
115
|
+
req.on('end', async () => {
|
|
116
|
+
let args = {}
|
|
117
|
+
if (body.length > 0) {
|
|
118
|
+
try {
|
|
119
|
+
args = JSON.parse(body)
|
|
120
|
+
} catch {
|
|
121
|
+
sendJson(res, 400, {
|
|
122
|
+
ok: false,
|
|
123
|
+
error: { code: 'bad-json', message: 'request body is not valid JSON' },
|
|
124
|
+
})
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const sessionId = String(args?.sessionId ?? '')
|
|
129
|
+
if (sessionId) seam.setConfig(sessionId, parseRetraceConfig(req.headers['x-retrace-config']))
|
|
130
|
+
const opFn = api[op]
|
|
131
|
+
if (typeof opFn !== 'function') {
|
|
132
|
+
sendJson(res, 404, {
|
|
133
|
+
ok: false,
|
|
134
|
+
error: { code: 'unknown-op', message: `unknown operation "${op}"` },
|
|
135
|
+
})
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const result = await opFn(args)
|
|
140
|
+
sendJson(res, 200, result)
|
|
141
|
+
} catch (error) {
|
|
142
|
+
sendError(res, error)
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return (req, res) => {
|
|
148
|
+
const url = new URL(req.url ?? '/', 'http://retrace.local')
|
|
149
|
+
const op = url.pathname.split('/').filter(Boolean).at(-1) ?? ''
|
|
150
|
+
const searchParams = url.searchParams
|
|
151
|
+
const sessionId = searchParams.get('sessionId') ?? ''
|
|
152
|
+
const config = parseRetraceConfig(req.headers['x-retrace-config'])
|
|
153
|
+
|
|
154
|
+
if (req.method === 'OPTIONS') {
|
|
155
|
+
res.writeHead(204, {
|
|
156
|
+
'Access-Control-Allow-Origin': '*',
|
|
157
|
+
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
|
158
|
+
'Access-Control-Allow-Headers': 'Content-Type, x-retrace-config',
|
|
159
|
+
})
|
|
160
|
+
res.end()
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
if (req.method === 'GET') {
|
|
164
|
+
switch (op) {
|
|
165
|
+
case 'versions':
|
|
166
|
+
return handleVersions(req, res, sessionId, config)
|
|
167
|
+
case 'event':
|
|
168
|
+
return void handleEvent(req, res, searchParams)
|
|
169
|
+
case 'surface':
|
|
170
|
+
return void handleSurface(req, res, sessionId)
|
|
171
|
+
default:
|
|
172
|
+
return sendJson(res, 404, {
|
|
173
|
+
ok: false,
|
|
174
|
+
error: { code: 'unknown-op', message: `unknown operation "${op}"` },
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (req.method !== 'POST') {
|
|
179
|
+
return sendJson(res, 405, {
|
|
180
|
+
ok: false,
|
|
181
|
+
error: { code: 'method-not-allowed', message: 'POST or GET only' },
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
return handlePost(req, res, op)
|
|
185
|
+
}
|
|
186
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-retrace — Host plugin entry (published form).
|
|
3
|
+
*
|
|
4
|
+
* Registers the retrace operations behind two transports so the same
|
|
5
|
+
* package serves both the desktop/web GUI and headless deployments:
|
|
6
|
+
*
|
|
7
|
+
* - `harness.handle` when present (the dynamic-package RPC bridge), and
|
|
8
|
+
* - a same-origin HTTP route under `/api/plugins/retrace/*` for
|
|
9
|
+
* bundled (published) client modules.
|
|
10
|
+
*
|
|
11
|
+
* The HTTP surface additionally serves the P0 versioning channels:
|
|
12
|
+
* `GET /versions` (projection snapshot fallback), `GET /event` and
|
|
13
|
+
* `GET /surface` (lazy sessionQuery reads). The versioning seam itself
|
|
14
|
+
* (`lib/versioning.js`) lives behind `ctx.inject(...)` — headless
|
|
15
|
+
* compositions without the projection/storage services simply degrade to
|
|
16
|
+
* plain L1 (recall / edit / regenerate), exactly like 0.2.x.
|
|
17
|
+
*
|
|
18
|
+
* Every op resolves to a result object `{ ok: true, value }` /
|
|
19
|
+
* `{ ok: false, error }` produced by the host core, so both transports carry
|
|
20
|
+
* the identical wire shape.
|
|
21
|
+
*/
|
|
22
|
+
import { createEditorApi } from './host-core.js'
|
|
23
|
+
import { createRetraceHttpHandler, ROUTE_PREFIX } from './http.js'
|
|
24
|
+
import { createVersioningSeam } from './versioning.js'
|
|
25
|
+
|
|
26
|
+
export const name = 'dsh-retrace'
|
|
27
|
+
export const inject = ['sessions', 'agents', 'webServer']
|
|
28
|
+
|
|
29
|
+
export function apply(ctx) {
|
|
30
|
+
const log = (line) => ctx.logger?.info(line)
|
|
31
|
+
|
|
32
|
+
// P0 versioning seam: projection unit + artifact snapshots + config view.
|
|
33
|
+
const seam = createVersioningSeam(ctx, log)
|
|
34
|
+
seam.register()
|
|
35
|
+
|
|
36
|
+
const api = createEditorApi(ctx, ctx.sessions, ctx.agents, log)
|
|
37
|
+
const handler = createRetraceHttpHandler(ctx, {
|
|
38
|
+
sessions: ctx.sessions,
|
|
39
|
+
agents: ctx.agents,
|
|
40
|
+
seam,
|
|
41
|
+
log,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const disposeRoute = (() => {
|
|
45
|
+
const webServer = ctx.get('webServer')
|
|
46
|
+
if (webServer && typeof webServer.register === 'function') {
|
|
47
|
+
try {
|
|
48
|
+
return webServer.register({
|
|
49
|
+
kind: 'prefix',
|
|
50
|
+
path: ROUTE_PREFIX,
|
|
51
|
+
handler,
|
|
52
|
+
})
|
|
53
|
+
} catch (error) {
|
|
54
|
+
ctx.logger?.warn(`dsh-retrace: route registration failed: ${String(error)}`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return () => {}
|
|
58
|
+
})()
|
|
59
|
+
|
|
60
|
+
// Dynamic-package bridge: no-op when this file runs as a plain published plugin.
|
|
61
|
+
const disposeHarness = (() => {
|
|
62
|
+
if (typeof harness === 'undefined' || !harness || typeof harness.handle !== 'function') return () => {}
|
|
63
|
+
const disposers = [
|
|
64
|
+
harness.handle('retrace.recall', (args) => api.recall(args)),
|
|
65
|
+
harness.handle('retrace.editAndResend', (args) => api.editAndResend(args)),
|
|
66
|
+
harness.handle('retrace.regenerate', (args) => api.regenerate(args)),
|
|
67
|
+
]
|
|
68
|
+
return () => disposers.forEach((dispose) => dispose())
|
|
69
|
+
})()
|
|
70
|
+
|
|
71
|
+
ctx.effect(() => () => {
|
|
72
|
+
disposeRoute()
|
|
73
|
+
disposeHarness()
|
|
74
|
+
}, 'dsh-retrace: transports')
|
|
75
|
+
}
|