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,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
+ }