@openviking/opencode-plugin 0.1.5

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,651 @@
1
+ import fs from "fs"
2
+ import path from "path"
3
+ import {
4
+ log,
5
+ effectivePeerId,
6
+ makeRequest,
7
+ safeStringify,
8
+ unwrapResponse,
9
+ } from "./utils.mjs"
10
+
11
+ const MAX_BUFFERED_MESSAGES_PER_SESSION = 100
12
+ const BUFFERED_MESSAGE_TTL_MS = 15 * 60 * 1000
13
+ const BUFFER_CLEANUP_INTERVAL_MS = 30 * 1000
14
+ const COMMIT_WAIT_TIMEOUT_MS = 180000
15
+
16
+ export function createMemorySessionManager({ config, pluginRoot }) {
17
+ const sessionMap = new Map()
18
+ const sessionMessageBuffer = new Map()
19
+ const commitWatchers = new Map()
20
+ let sessionMapPath = path.join(pluginRoot, "openviking-session-map.json")
21
+ let saveTimer = null
22
+ let lastBufferCleanupAt = 0
23
+
24
+ async function init() {
25
+ await loadSessionMap()
26
+ resumeBackgroundCommits()
27
+ }
28
+
29
+ async function loadSessionMap() {
30
+ try {
31
+ if (!fs.existsSync(sessionMapPath)) {
32
+ log("INFO", "persistence", "No session map file found, starting fresh")
33
+ return
34
+ }
35
+ const data = JSON.parse(await fs.promises.readFile(sessionMapPath, "utf8"))
36
+ if (data.version !== 1) {
37
+ log("ERROR", "persistence", "Unsupported session map version", { version: data.version })
38
+ return
39
+ }
40
+ for (const [opencodeSessionId, persisted] of Object.entries(data.sessions ?? {})) {
41
+ sessionMap.set(opencodeSessionId, deserializeSessionMapping(persisted))
42
+ }
43
+ log("INFO", "persistence", "Session map loaded", { count: sessionMap.size })
44
+ } catch (error) {
45
+ log("ERROR", "persistence", "Failed to load session map", { error: error?.message })
46
+ if (fs.existsSync(sessionMapPath)) {
47
+ await fs.promises.rename(sessionMapPath, `${sessionMapPath}.corrupted.${Date.now()}`)
48
+ }
49
+ }
50
+ }
51
+
52
+ async function saveSessionMap() {
53
+ try {
54
+ const sessions = {}
55
+ for (const [opencodeSessionId, mapping] of sessionMap.entries()) {
56
+ sessions[opencodeSessionId] = serializeSessionMapping(mapping)
57
+ }
58
+ const tempPath = `${sessionMapPath}.tmp`
59
+ await fs.promises.writeFile(tempPath, JSON.stringify({ version: 1, sessions, lastSaved: Date.now() }, null, 2), "utf8")
60
+ await fs.promises.rename(tempPath, sessionMapPath)
61
+ log("DEBUG", "persistence", "Session map saved", { count: sessionMap.size })
62
+ } catch (error) {
63
+ log("ERROR", "persistence", "Failed to save session map", { error: error?.message })
64
+ }
65
+ }
66
+
67
+ function debouncedSaveSessionMap() {
68
+ if (saveTimer) clearTimeout(saveTimer)
69
+ saveTimer = setTimeout(() => {
70
+ saveSessionMap().catch((error) => {
71
+ log("ERROR", "persistence", "Debounced save failed", { error: error?.message })
72
+ })
73
+ }, 300)
74
+ }
75
+
76
+ function serializeSessionMapping(mapping) {
77
+ return {
78
+ ovSessionId: mapping.ovSessionId,
79
+ createdAt: mapping.createdAt,
80
+ capturedMessages: Array.from(mapping.capturedMessages),
81
+ messageRoles: Array.from(mapping.messageRoles.entries()),
82
+ pendingMessages: Array.from(mapping.pendingMessages.entries()),
83
+ lastCommitTime: mapping.lastCommitTime,
84
+ commitInFlight: mapping.commitInFlight,
85
+ commitTaskId: mapping.commitTaskId,
86
+ commitStartedAt: mapping.commitStartedAt,
87
+ pendingCleanup: mapping.pendingCleanup,
88
+ }
89
+ }
90
+
91
+ function deserializeSessionMapping(persisted) {
92
+ return {
93
+ ovSessionId: persisted.ovSessionId,
94
+ createdAt: persisted.createdAt,
95
+ capturedMessages: new Set(persisted.capturedMessages ?? []),
96
+ messageRoles: new Map(persisted.messageRoles ?? []),
97
+ pendingMessages: new Map(persisted.pendingMessages ?? []),
98
+ sendingMessages: new Set(),
99
+ lastCommitTime: persisted.lastCommitTime,
100
+ commitInFlight: persisted.commitInFlight,
101
+ commitTaskId: persisted.commitTaskId,
102
+ commitStartedAt: persisted.commitStartedAt,
103
+ pendingCleanup: persisted.pendingCleanup,
104
+ }
105
+ }
106
+
107
+ function getMappedSessionId(opencodeSessionId) {
108
+ return sessionMap.get(opencodeSessionId)?.ovSessionId
109
+ }
110
+
111
+ async function handleEvent(event) {
112
+ if (!event?.type || event.type === "session.diff") return
113
+
114
+ if (event.type === "session.created") {
115
+ await handleSessionCreated(event)
116
+ } else if (event.type === "session.deleted") {
117
+ await handleSessionDeleted(event)
118
+ } else if (event.type === "session.error") {
119
+ await handleSessionError(event)
120
+ } else if (event.type === "session.compacted") {
121
+ await handleSessionCompacted(event)
122
+ } else if (event.type === "message.updated") {
123
+ await handleMessageUpdated(event)
124
+ } else if (event.type === "message.part.updated") {
125
+ await handleMessagePartUpdated(event)
126
+ }
127
+ }
128
+
129
+ async function handleSessionCreated(event) {
130
+ const sessionId = resolveEventSessionId(event)
131
+ if (!sessionId) {
132
+ log("ERROR", "event", "session.created event missing sessionId", { event: safeStringify(event) })
133
+ return
134
+ }
135
+
136
+ const ovSessionId = await ensureOpenVikingSession(sessionId)
137
+ if (!ovSessionId) return
138
+
139
+ const existing = sessionMap.get(sessionId)
140
+ const mapping = existing ?? createSessionMapping(ovSessionId)
141
+ mapping.ovSessionId = ovSessionId
142
+ sessionMap.set(sessionId, mapping)
143
+
144
+ const bufferedMessages = sessionMessageBuffer.get(sessionId)
145
+ if (bufferedMessages?.length) {
146
+ for (const buffered of bufferedMessages) {
147
+ if (buffered.role) mapping.messageRoles.set(buffered.messageId, buffered.role)
148
+ if (buffered.content) {
149
+ mapping.pendingMessages.set(
150
+ buffered.messageId,
151
+ mergeMessageContent(mapping.pendingMessages.get(buffered.messageId), buffered.content),
152
+ )
153
+ }
154
+ }
155
+ sessionMessageBuffer.delete(sessionId)
156
+ await flushPendingMessages(sessionId, mapping)
157
+ }
158
+
159
+ debouncedSaveSessionMap()
160
+ log("INFO", "event", "Session mapping established", {
161
+ opencode_session: sessionId,
162
+ openviking_session: ovSessionId,
163
+ })
164
+ }
165
+
166
+ async function handleSessionDeleted(event) {
167
+ const sessionId = resolveEventSessionId(event)
168
+ if (!sessionId) return
169
+
170
+ const mapping = sessionMap.get(sessionId)
171
+ if (!mapping) {
172
+ sessionMessageBuffer.delete(sessionId)
173
+ return
174
+ }
175
+
176
+ await flushPendingMessages(sessionId, mapping)
177
+ if (mapping.capturedMessages.size > 0 || mapping.commitInFlight) {
178
+ mapping.pendingCleanup = true
179
+ if (!mapping.commitInFlight) await startBackgroundCommit(mapping, sessionId)
180
+ } else {
181
+ sessionMap.delete(sessionId)
182
+ sessionMessageBuffer.delete(sessionId)
183
+ await saveSessionMap()
184
+ }
185
+ }
186
+
187
+ async function handleSessionError(event) {
188
+ const sessionId = resolveEventSessionId(event)
189
+ if (!sessionId) return
190
+ log("ERROR", "event", "OpenCode session error", { session_id: sessionId, error: safeStringify(event.error) })
191
+ await handleSessionDeleted(event)
192
+ }
193
+
194
+ async function handleSessionCompacted(event) {
195
+ await commitSessionBoundary(event, "session.compacted")
196
+ }
197
+
198
+ async function commitSessionBoundary(event, reason) {
199
+ const sessionId = resolveEventSessionId(event)
200
+ if (!sessionId) return
201
+
202
+ const mapping = sessionMap.get(sessionId)
203
+ if (!mapping) return
204
+
205
+ await flushPendingMessages(sessionId, mapping)
206
+ if (mapping.commitInFlight) {
207
+ monitorBackgroundCommit(mapping, sessionId)
208
+ return
209
+ }
210
+ if (mapping.capturedMessages.size > 0) {
211
+ log("INFO", "session", "Committing OpenViking session at lifecycle boundary", {
212
+ opencode_session: sessionId,
213
+ openviking_session: mapping.ovSessionId,
214
+ reason,
215
+ })
216
+ await startBackgroundCommit(mapping, sessionId)
217
+ }
218
+ }
219
+
220
+ async function handleMessageUpdated(event) {
221
+ const message = event.properties?.info
222
+ if (!message) return
223
+
224
+ const sessionId = message.sessionID
225
+ const messageId = message.id
226
+ const role = message.role
227
+ const finish = message.finish
228
+ if (!sessionId || !messageId) return
229
+
230
+ const mapping = sessionMap.get(sessionId)
231
+ if (!mapping) {
232
+ upsertBufferedMessage(sessionId, messageId, role ? { role } : {})
233
+ return
234
+ }
235
+
236
+ if (role === "user") {
237
+ mapping.messageRoles.set(messageId, role)
238
+ } else if (role === "assistant" && finish === "stop") {
239
+ mapping.messageRoles.set(messageId, role)
240
+ }
241
+
242
+ await flushPendingMessages(sessionId, mapping)
243
+ }
244
+
245
+ async function handleMessagePartUpdated(event) {
246
+ const part = event.properties?.part
247
+ if (!part) return
248
+
249
+ const sessionId = part.sessionID
250
+ const messageId = part.messageID
251
+ if (!sessionId || !messageId || part.type !== "text" || !part.text?.trim()) return
252
+
253
+ const mapping = sessionMap.get(sessionId)
254
+ if (!mapping) {
255
+ upsertBufferedMessage(sessionId, messageId, { content: part.text })
256
+ return
257
+ }
258
+
259
+ if (mapping.capturedMessages.has(messageId)) return
260
+ mapping.pendingMessages.set(messageId, mergeMessageContent(mapping.pendingMessages.get(messageId), part.text))
261
+ }
262
+
263
+ async function ensureOpenVikingSession(opencodeSessionId) {
264
+ const knownSessionId = sessionMap.get(opencodeSessionId)?.ovSessionId
265
+ if (knownSessionId) {
266
+ try {
267
+ const response = await makeRequest(config, {
268
+ method: "GET",
269
+ endpoint: `/api/v1/sessions/${encodeURIComponent(knownSessionId)}`,
270
+ timeoutMs: 5000,
271
+ })
272
+ if (unwrapResponse(response)) return knownSessionId
273
+ } catch (error) {
274
+ log("INFO", "session", "Persisted OpenViking session unavailable, creating a new one", {
275
+ opencode_session: opencodeSessionId,
276
+ openviking_session: knownSessionId,
277
+ error: error?.message,
278
+ })
279
+ }
280
+ }
281
+
282
+ try {
283
+ const response = await makeRequest(config, {
284
+ method: "POST",
285
+ endpoint: "/api/v1/sessions",
286
+ body: {},
287
+ timeoutMs: 5000,
288
+ })
289
+ const sessionId = unwrapResponse(response)?.session_id
290
+ if (!sessionId) throw new Error("OpenViking did not return a session_id")
291
+ return sessionId
292
+ } catch (error) {
293
+ log("ERROR", "session", "Failed to create OpenViking session", {
294
+ opencode_session: opencodeSessionId,
295
+ error: error?.message,
296
+ })
297
+ return null
298
+ }
299
+ }
300
+
301
+ async function flushPendingMessages(opencodeSessionId, mapping) {
302
+ if (mapping.commitInFlight) return
303
+
304
+ for (const messageId of Array.from(mapping.pendingMessages.keys())) {
305
+ if (mapping.capturedMessages.has(messageId) || mapping.sendingMessages.has(messageId)) continue
306
+ const role = mapping.messageRoles.get(messageId)
307
+ const content = mapping.pendingMessages.get(messageId)
308
+ if (!role || !content?.trim()) continue
309
+
310
+ mapping.sendingMessages.add(messageId)
311
+ try {
312
+ const success = await addMessageToSession(mapping.ovSessionId, role, content)
313
+ if (success) {
314
+ const latest = mapping.pendingMessages.get(messageId)
315
+ if (latest && latest !== content) {
316
+ continue
317
+ }
318
+ mapping.pendingMessages.delete(messageId)
319
+ mapping.capturedMessages.add(messageId)
320
+ debouncedSaveSessionMap()
321
+ }
322
+ } finally {
323
+ mapping.sendingMessages.delete(messageId)
324
+ }
325
+ }
326
+ }
327
+
328
+ async function addMessageToSession(ovSessionId, role, content) {
329
+ try {
330
+ const body = { role, content }
331
+ const peerId = effectivePeerId(config)
332
+ if (peerId) body.peer_id = peerId
333
+ const response = await makeRequest(config, {
334
+ method: "POST",
335
+ endpoint: `/api/v1/sessions/${encodeURIComponent(ovSessionId)}/messages`,
336
+ body,
337
+ timeoutMs: 5000,
338
+ })
339
+ unwrapResponse(response)
340
+ return true
341
+ } catch (error) {
342
+ log("ERROR", "message", "Failed to add message to OpenViking session", {
343
+ openviking_session: ovSessionId,
344
+ role,
345
+ error: error?.message,
346
+ })
347
+ return false
348
+ }
349
+ }
350
+
351
+ async function startBackgroundCommit(mapping, opencodeSessionId, abortSignal) {
352
+ if (mapping.commitInFlight && mapping.commitTaskId) {
353
+ if (!abortSignal) monitorBackgroundCommit(mapping, opencodeSessionId)
354
+ return { mode: "background", taskId: mapping.commitTaskId }
355
+ }
356
+
357
+ try {
358
+ const response = await makeRequest(config, {
359
+ method: "POST",
360
+ endpoint: `/api/v1/sessions/${encodeURIComponent(mapping.ovSessionId)}/commit`,
361
+ timeoutMs: 10000,
362
+ abortSignal,
363
+ })
364
+ const result = unwrapResponse(response)
365
+ const taskId = result?.task_id
366
+
367
+ if (!taskId) {
368
+ await finalizeCommitSuccess(mapping, opencodeSessionId)
369
+ return { mode: "completed", result }
370
+ }
371
+
372
+ mapping.commitInFlight = true
373
+ mapping.commitTaskId = taskId
374
+ mapping.commitStartedAt = Date.now()
375
+ debouncedSaveSessionMap()
376
+ if (!abortSignal) monitorBackgroundCommit(mapping, opencodeSessionId)
377
+ return { mode: "background", taskId }
378
+ } catch (error) {
379
+ if (error?.message?.includes("already has a commit in progress")) {
380
+ const taskId = await findRunningCommitTaskId(mapping.ovSessionId)
381
+ if (taskId) {
382
+ mapping.commitInFlight = true
383
+ mapping.commitTaskId = taskId
384
+ mapping.commitStartedAt = mapping.commitStartedAt ?? Date.now()
385
+ debouncedSaveSessionMap()
386
+ if (!abortSignal) monitorBackgroundCommit(mapping, opencodeSessionId)
387
+ return { mode: "background", taskId }
388
+ }
389
+ }
390
+ log("ERROR", "session", "Failed to start OpenViking commit", {
391
+ openviking_session: mapping.ovSessionId,
392
+ opencode_session: opencodeSessionId,
393
+ error: error?.message,
394
+ })
395
+ return null
396
+ }
397
+ }
398
+
399
+ async function waitForCommitCompletion(mapping, opencodeSessionId, abortSignal, timeoutMs = COMMIT_WAIT_TIMEOUT_MS) {
400
+ const startedAt = Date.now()
401
+ while (Date.now() - startedAt < timeoutMs) {
402
+ if (abortSignal?.aborted) throw new Error("Operation aborted")
403
+ if (!mapping.commitInFlight) return null
404
+ if (!mapping.commitTaskId) {
405
+ mapping.commitTaskId = await findRunningCommitTaskId(mapping.ovSessionId)
406
+ if (!mapping.commitTaskId) {
407
+ clearCommitState(mapping)
408
+ debouncedSaveSessionMap()
409
+ return null
410
+ }
411
+ }
412
+
413
+ const task = await getTask(mapping.commitTaskId, abortSignal)
414
+ if (task.status === "completed") {
415
+ await finalizeCommitSuccess(mapping, opencodeSessionId)
416
+ return task
417
+ }
418
+ if (task.status === "failed") {
419
+ clearCommitState(mapping)
420
+ debouncedSaveSessionMap()
421
+ throw new Error(task.error || "Background commit failed")
422
+ }
423
+ await sleep(2000, abortSignal)
424
+ }
425
+ return null
426
+ }
427
+
428
+ async function getTask(taskId, abortSignal) {
429
+ const response = await makeRequest(config, {
430
+ method: "GET",
431
+ endpoint: `/api/v1/tasks/${encodeURIComponent(taskId)}`,
432
+ timeoutMs: 5000,
433
+ abortSignal,
434
+ })
435
+ return unwrapResponse(response)
436
+ }
437
+
438
+ async function findRunningCommitTaskId(ovSessionId) {
439
+ try {
440
+ const response = await makeRequest(config, {
441
+ method: "GET",
442
+ endpoint: `/api/v1/tasks?task_type=session_commit&resource_id=${encodeURIComponent(ovSessionId)}&limit=10`,
443
+ timeoutMs: 5000,
444
+ })
445
+ const tasks = unwrapResponse(response) ?? []
446
+ return tasks.find((task) => task.status === "pending" || task.status === "running")?.task_id
447
+ } catch (error) {
448
+ log("WARN", "session", "Failed to query running commit tasks", { error: error?.message })
449
+ return undefined
450
+ }
451
+ }
452
+
453
+ async function finalizeCommitSuccess(mapping, opencodeSessionId) {
454
+ mapping.lastCommitTime = Date.now()
455
+ mapping.capturedMessages.clear()
456
+ clearCommitState(mapping)
457
+ debouncedSaveSessionMap()
458
+
459
+ await flushPendingMessages(opencodeSessionId, mapping)
460
+
461
+ if (mapping.pendingCleanup) {
462
+ sessionMap.delete(opencodeSessionId)
463
+ sessionMessageBuffer.delete(opencodeSessionId)
464
+ await saveSessionMap()
465
+ }
466
+ }
467
+
468
+ function resumeBackgroundCommits() {
469
+ for (const [opencodeSessionId, mapping] of sessionMap.entries()) {
470
+ if (mapping.commitInFlight) monitorBackgroundCommit(mapping, opencodeSessionId)
471
+ }
472
+ }
473
+
474
+ function monitorBackgroundCommit(mapping, opencodeSessionId) {
475
+ if (!mapping.commitTaskId) return
476
+ if (commitWatchers.has(mapping.commitTaskId)) return
477
+
478
+ const taskId = mapping.commitTaskId
479
+ const watcher = waitForCommitCompletion(mapping, opencodeSessionId)
480
+ .then((task) => {
481
+ if (!task) {
482
+ log("WARN", "session", "Background commit is still pending after the wait timeout", {
483
+ task_id: taskId,
484
+ openviking_session: mapping.ovSessionId,
485
+ opencode_session: opencodeSessionId,
486
+ })
487
+ }
488
+ })
489
+ .catch((error) => {
490
+ log("ERROR", "session", "Background commit watcher failed", {
491
+ task_id: taskId,
492
+ openviking_session: mapping.ovSessionId,
493
+ opencode_session: opencodeSessionId,
494
+ error: error?.message,
495
+ })
496
+ })
497
+ .finally(() => {
498
+ commitWatchers.delete(taskId)
499
+ })
500
+ commitWatchers.set(taskId, watcher)
501
+ }
502
+
503
+ async function flushAll({ commit = false } = {}) {
504
+ if (saveTimer) {
505
+ clearTimeout(saveTimer)
506
+ saveTimer = null
507
+ }
508
+ for (const [sessionId, mapping] of sessionMap.entries()) {
509
+ await flushPendingMessages(sessionId, mapping)
510
+ if (commit) {
511
+ if (mapping.commitInFlight) {
512
+ monitorBackgroundCommit(mapping, sessionId)
513
+ } else if (mapping.capturedMessages.size > 0) {
514
+ await startBackgroundCommit(mapping, sessionId)
515
+ }
516
+ }
517
+ }
518
+ await saveSessionMap()
519
+ }
520
+
521
+ async function flushSession(opencodeSessionId, { commit = false, reason = "manual" } = {}) {
522
+ const mapping = sessionMap.get(opencodeSessionId)
523
+ if (!mapping) return false
524
+
525
+ await flushPendingMessages(opencodeSessionId, mapping)
526
+ if (commit) {
527
+ if (mapping.commitInFlight) {
528
+ monitorBackgroundCommit(mapping, opencodeSessionId)
529
+ } else if (mapping.capturedMessages.size > 0) {
530
+ log("INFO", "session", "Committing OpenViking session at lifecycle boundary", {
531
+ opencode_session: opencodeSessionId,
532
+ openviking_session: mapping.ovSessionId,
533
+ reason,
534
+ })
535
+ await startBackgroundCommit(mapping, opencodeSessionId)
536
+ }
537
+ }
538
+ await saveSessionMap()
539
+ return true
540
+ }
541
+
542
+ async function commitSession(sessionId, opencodeSessionId, abortSignal) {
543
+ let mapping = opencodeSessionId ? sessionMap.get(opencodeSessionId) : undefined
544
+ if (!mapping || mapping.ovSessionId !== sessionId) {
545
+ mapping = createSessionMapping(sessionId)
546
+ } else {
547
+ await flushPendingMessages(opencodeSessionId, mapping)
548
+ }
549
+
550
+ if (mapping.commitInFlight) {
551
+ const task = await waitForCommitCompletion(mapping, opencodeSessionId ?? sessionId, abortSignal)
552
+ if (task?.status === "completed") return { status: "completed", task }
553
+ }
554
+
555
+ const start = await startBackgroundCommit(mapping, opencodeSessionId ?? sessionId, abortSignal)
556
+ if (!start) throw new Error("Failed to start OpenViking session commit")
557
+ if (start.mode === "completed") return { status: "completed", result: start.result }
558
+
559
+ const task = await waitForCommitCompletion(mapping, opencodeSessionId ?? sessionId, abortSignal)
560
+ if (!task) return { status: "accepted", task_id: start.taskId }
561
+ return { status: task.status, task }
562
+ }
563
+
564
+ return {
565
+ init,
566
+ handleEvent,
567
+ getMappedSessionId,
568
+ commitSession,
569
+ flushAll,
570
+ flushSession,
571
+ }
572
+
573
+ function createSessionMapping(ovSessionId) {
574
+ return {
575
+ ovSessionId,
576
+ createdAt: Date.now(),
577
+ capturedMessages: new Set(),
578
+ messageRoles: new Map(),
579
+ pendingMessages: new Map(),
580
+ sendingMessages: new Set(),
581
+ lastCommitTime: undefined,
582
+ commitInFlight: false,
583
+ }
584
+ }
585
+
586
+ function resolveEventSessionId(event) {
587
+ return event?.properties?.info?.id ?? event?.properties?.sessionID ?? event?.properties?.sessionId
588
+ }
589
+
590
+ function mergeMessageContent(existing, incoming) {
591
+ const next = incoming?.trim()
592
+ if (!next) return existing ?? ""
593
+ if (!existing) return next
594
+ if (next === existing) return existing
595
+ if (next.startsWith(existing)) return next
596
+ if (existing.startsWith(next)) return existing
597
+ if (next.includes(existing)) return next
598
+ if (existing.includes(next)) return existing
599
+ return `${existing}\n${next}`.trim()
600
+ }
601
+
602
+ function upsertBufferedMessage(sessionId, messageId, updates) {
603
+ const now = Date.now()
604
+ if (now - lastBufferCleanupAt >= BUFFER_CLEANUP_INTERVAL_MS) {
605
+ cleanupOrphanedMessageBuffers(now)
606
+ lastBufferCleanupAt = now
607
+ }
608
+
609
+ const freshBuffer = (sessionMessageBuffer.get(sessionId) ?? [])
610
+ .filter((message) => now - message.timestamp <= BUFFERED_MESSAGE_TTL_MS)
611
+ let buffered = freshBuffer.find((message) => message.messageId === messageId)
612
+ if (!buffered) {
613
+ while (freshBuffer.length >= MAX_BUFFERED_MESSAGES_PER_SESSION) freshBuffer.shift()
614
+ buffered = { messageId, timestamp: now }
615
+ freshBuffer.push(buffered)
616
+ } else {
617
+ buffered.timestamp = now
618
+ }
619
+ if (updates.role) buffered.role = updates.role
620
+ if (updates.content) buffered.content = mergeMessageContent(buffered.content, updates.content)
621
+ sessionMessageBuffer.set(sessionId, freshBuffer)
622
+ }
623
+
624
+ function cleanupOrphanedMessageBuffers(now) {
625
+ for (const [sessionId, buffer] of sessionMessageBuffer.entries()) {
626
+ if (sessionMap.has(sessionId)) continue
627
+ const oldest = buffer[0]
628
+ if (!oldest || now - oldest.timestamp > BUFFERED_MESSAGE_TTL_MS * 2) {
629
+ sessionMessageBuffer.delete(sessionId)
630
+ }
631
+ }
632
+ }
633
+
634
+ function clearCommitState(mapping) {
635
+ mapping.commitInFlight = false
636
+ mapping.commitTaskId = undefined
637
+ mapping.commitStartedAt = undefined
638
+ }
639
+
640
+ async function sleep(ms, abortSignal) {
641
+ await new Promise((resolve, reject) => {
642
+ const timer = setTimeout(resolve, ms)
643
+ if (!abortSignal) return
644
+ const onAbort = () => {
645
+ clearTimeout(timer)
646
+ reject(new Error("Operation aborted"))
647
+ }
648
+ abortSignal.addEventListener("abort", onAbort, { once: true })
649
+ })
650
+ }
651
+ }