@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.
- package/INSTALL-ZH.md +225 -0
- package/INSTALL.md +220 -0
- package/README.md +261 -0
- package/index.mjs +84 -0
- package/lib/code-tools.mjs +134 -0
- package/lib/memadd-local.mjs +118 -0
- package/lib/memory-recall.mjs +214 -0
- package/lib/memory-session.mjs +651 -0
- package/lib/memory-tools.mjs +410 -0
- package/lib/repo-context.mjs +68 -0
- package/lib/runtime.mjs +33 -0
- package/lib/utils.mjs +341 -0
- package/lib/viking-uri-guard.mjs +52 -0
- package/package.json +42 -0
|
@@ -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
|
+
}
|