@openviking/opencode-plugin 0.1.6 → 0.2.1

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.
@@ -1,111 +1,127 @@
1
1
  import fs from "fs"
2
2
  import path from "path"
3
+ import {
4
+ extractPartsFromPayload,
5
+ extractTextFromPayload,
6
+ shouldCaptureText,
7
+ } from "./shared/capture-utils.mjs"
8
+ import {
9
+ deriveHarnessSessionId,
10
+ } from "./shared/session-model.mjs"
11
+ import {
12
+ enqueue,
13
+ replayPending,
14
+ } from "./shared/pending-queue.mjs"
3
15
  import {
4
16
  log,
5
17
  effectivePeerId,
6
- makeRequest,
18
+ fetchJSON,
7
19
  safeStringify,
8
- unwrapResponse,
9
20
  } from "./utils.mjs"
10
21
 
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
22
  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")
23
+ const sessions = new Map()
24
+ const statePath = path.join(pluginRoot, "openviking-session-state.json")
25
+ const oldSessionMapPath = path.join(pluginRoot, "openviking-session-map.json")
21
26
  let saveTimer = null
22
- let lastBufferCleanupAt = 0
23
27
 
24
28
  async function init() {
25
- await loadSessionMap()
26
- resumeBackgroundCommits()
29
+ await migrateLegacySessionMap()
30
+ await loadState()
31
+ const health = await fetchJSON(config, "/health", {}, { timeoutMs: 5000 })
32
+ if (health.ok) {
33
+ await replayPending(
34
+ (endpoint, init = {}, options = {}) => fetchJSON(config, endpoint, init, options),
35
+ (stage, data) => log("DEBUG", "pending", stage, data),
36
+ )
37
+ }
27
38
  }
28
39
 
29
- async function loadSessionMap() {
40
+ async function loadState() {
30
41
  try {
31
- if (!fs.existsSync(sessionMapPath)) {
32
- log("INFO", "persistence", "No session map file found, starting fresh")
42
+ if (!fs.existsSync(statePath)) {
43
+ log("INFO", "persistence", "No session state file found, starting fresh")
33
44
  return
34
45
  }
35
- const data = JSON.parse(await fs.promises.readFile(sessionMapPath, "utf8"))
36
- if (data.version !== 1) {
46
+ const data = JSON.parse(await fs.promises.readFile(statePath, "utf8"))
47
+ if (data.version !== 2) {
37
48
  log("ERROR", "persistence", "Unsupported session map version", { version: data.version })
38
49
  return
39
50
  }
40
51
  for (const [opencodeSessionId, persisted] of Object.entries(data.sessions ?? {})) {
41
- sessionMap.set(opencodeSessionId, deserializeSessionMapping(persisted))
52
+ sessions.set(opencodeSessionId, deserializeSessionState(persisted))
42
53
  }
43
- log("INFO", "persistence", "Session map loaded", { count: sessionMap.size })
54
+ log("INFO", "persistence", "Session state loaded", { count: sessions.size })
44
55
  } 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()}`)
56
+ log("ERROR", "persistence", "Failed to load session state", { error: error?.message })
57
+ if (fs.existsSync(statePath)) {
58
+ await fs.promises.rename(statePath, `${statePath}.corrupted.${Date.now()}`)
48
59
  }
49
60
  }
50
61
  }
51
62
 
52
- async function saveSessionMap() {
63
+ async function saveState() {
53
64
  try {
54
- const sessions = {}
55
- for (const [opencodeSessionId, mapping] of sessionMap.entries()) {
56
- sessions[opencodeSessionId] = serializeSessionMapping(mapping)
65
+ const persisted = {}
66
+ for (const [opencodeSessionId, state] of sessions.entries()) {
67
+ persisted[opencodeSessionId] = serializeSessionState(state)
57
68
  }
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 })
69
+ const tempPath = `${statePath}.tmp`
70
+ await fs.promises.writeFile(tempPath, JSON.stringify({ version: 2, sessions: persisted, lastSaved: Date.now() }, null, 2), "utf8")
71
+ await fs.promises.rename(tempPath, statePath)
72
+ log("DEBUG", "persistence", "Session state saved", { count: sessions.size })
62
73
  } catch (error) {
63
- log("ERROR", "persistence", "Failed to save session map", { error: error?.message })
74
+ log("ERROR", "persistence", "Failed to save session state", { error: error?.message })
64
75
  }
65
76
  }
66
77
 
67
- function debouncedSaveSessionMap() {
78
+ function debouncedSaveState() {
68
79
  if (saveTimer) clearTimeout(saveTimer)
69
80
  saveTimer = setTimeout(() => {
70
- saveSessionMap().catch((error) => {
81
+ saveState().catch((error) => {
71
82
  log("ERROR", "persistence", "Debounced save failed", { error: error?.message })
72
83
  })
73
84
  }, 300)
74
85
  }
75
86
 
76
- function serializeSessionMapping(mapping) {
87
+ function serializeSessionState(state) {
77
88
  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) {
89
+ ovSessionId: state.ovSessionId,
90
+ createdAt: state.createdAt,
91
+ lastActivityAt: state.lastActivityAt,
92
+ lastCommitTime: state.lastCommitTime,
93
+ compactedAt: state.compactedAt,
94
+ messages: Array.from(state.messages.entries()).map(([messageId, message]) => ([
95
+ messageId,
96
+ {
97
+ role: message.role,
98
+ captured: message.captured,
99
+ parts: Array.from(message.parts.entries()),
100
+ },
101
+ ])),
102
+ }
103
+ }
104
+
105
+ function deserializeSessionState(persisted) {
92
106
  return {
93
107
  ovSessionId: persisted.ovSessionId,
94
108
  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(),
109
+ lastActivityAt: persisted.lastActivityAt,
99
110
  lastCommitTime: persisted.lastCommitTime,
100
- commitInFlight: persisted.commitInFlight,
101
- commitTaskId: persisted.commitTaskId,
102
- commitStartedAt: persisted.commitStartedAt,
103
- pendingCleanup: persisted.pendingCleanup,
111
+ compactedAt: persisted.compactedAt,
112
+ messages: new Map((persisted.messages ?? []).map(([messageId, message]) => ([
113
+ messageId,
114
+ {
115
+ role: message.role,
116
+ captured: Boolean(message.captured),
117
+ parts: new Map(message.parts ?? []),
118
+ },
119
+ ]))),
104
120
  }
105
121
  }
106
122
 
107
123
  function getMappedSessionId(opencodeSessionId) {
108
- return sessionMap.get(opencodeSessionId)?.ovSessionId
124
+ return getOrCreateSession(opencodeSessionId).ovSessionId
109
125
  }
110
126
 
111
127
  async function handleEvent(event) {
@@ -119,6 +135,8 @@ export function createMemorySessionManager({ config, pluginRoot }) {
119
135
  await handleSessionError(event)
120
136
  } else if (event.type === "session.compacted") {
121
137
  await handleSessionCompacted(event)
138
+ } else if (event.type === "session.idle") {
139
+ await handleSessionIdle(event)
122
140
  } else if (event.type === "message.updated") {
123
141
  await handleMessageUpdated(event)
124
142
  } else if (event.type === "message.part.updated") {
@@ -132,56 +150,27 @@ export function createMemorySessionManager({ config, pluginRoot }) {
132
150
  log("ERROR", "event", "session.created event missing sessionId", { event: safeStringify(event) })
133
151
  return
134
152
  }
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)
153
+ const state = getOrCreateSession(sessionId, event)
154
+ debouncedSaveState()
155
+ const health = await fetchJSON(config, "/health", {}, { timeoutMs: 5000 })
156
+ if (health.ok) {
157
+ await replayPending(
158
+ (endpoint, init = {}, options = {}) => fetchJSON(config, endpoint, init, options),
159
+ (stage, data) => log("DEBUG", "pending", stage, data),
160
+ )
157
161
  }
158
-
159
- debouncedSaveSessionMap()
160
- log("INFO", "event", "Session mapping established", {
162
+ log("INFO", "event", "OpenViking session derived", {
161
163
  opencode_session: sessionId,
162
- openviking_session: ovSessionId,
164
+ openviking_session: state.ovSessionId,
163
165
  })
164
166
  }
165
167
 
166
168
  async function handleSessionDeleted(event) {
167
169
  const sessionId = resolveEventSessionId(event)
168
170
  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
- }
171
+ await flushSession(sessionId, { commit: true, reason: event.type })
172
+ sessions.delete(sessionId)
173
+ await saveState()
185
174
  }
186
175
 
187
176
  async function handleSessionError(event) {
@@ -195,26 +184,18 @@ export function createMemorySessionManager({ config, pluginRoot }) {
195
184
  await commitSessionBoundary(event, "session.compacted")
196
185
  }
197
186
 
198
- async function commitSessionBoundary(event, reason) {
187
+ async function handleSessionIdle(event) {
199
188
  const sessionId = resolveEventSessionId(event)
200
189
  if (!sessionId) return
190
+ await flushSession(sessionId, { commit: false, reason: "session.idle" })
191
+ }
201
192
 
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
- }
193
+ async function commitSessionBoundary(event, reason) {
194
+ const sessionId = resolveEventSessionId(event)
195
+ if (!sessionId) return
196
+ const state = getOrCreateSession(sessionId, event)
197
+ state.compactedAt = Date.now()
198
+ await flushSession(sessionId, { commit: true, reason })
218
199
  }
219
200
 
220
201
  async function handleMessageUpdated(event) {
@@ -227,19 +208,17 @@ export function createMemorySessionManager({ config, pluginRoot }) {
227
208
  const finish = message.finish
228
209
  if (!sessionId || !messageId) return
229
210
 
230
- const mapping = sessionMap.get(sessionId)
231
- if (!mapping) {
232
- upsertBufferedMessage(sessionId, messageId, role ? { role } : {})
233
- return
234
- }
235
-
211
+ const state = getOrCreateSession(sessionId, event)
212
+ const captured = state.messages.get(messageId)
213
+ const next = captured ?? createMessageState()
236
214
  if (role === "user") {
237
- mapping.messageRoles.set(messageId, role)
238
- } else if (role === "assistant" && finish === "stop") {
239
- mapping.messageRoles.set(messageId, role)
215
+ next.role = role
216
+ } else if (role === "assistant") {
217
+ next.role = role
240
218
  }
241
-
242
- await flushPendingMessages(sessionId, mapping)
219
+ state.messages.set(messageId, next)
220
+ state.lastActivityAt = Date.now()
221
+ debouncedSaveState()
243
222
  }
244
223
 
245
224
  async function handleMessagePartUpdated(event) {
@@ -248,256 +227,16 @@ export function createMemorySessionManager({ config, pluginRoot }) {
248
227
 
249
228
  const sessionId = part.sessionID
250
229
  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
230
+ if (!sessionId || !messageId) return
477
231
 
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)
232
+ const state = getOrCreateSession(sessionId, event)
233
+ const message = state.messages.get(messageId) ?? createMessageState()
234
+ if (message.captured) return
235
+ const partId = part.id ?? `${messageId}:${message.parts.size}`
236
+ message.parts.set(partId, part)
237
+ state.messages.set(messageId, message)
238
+ state.lastActivityAt = Date.now()
239
+ debouncedSaveState()
501
240
  }
502
241
 
503
242
  async function flushAll({ commit = false } = {}) {
@@ -505,60 +244,33 @@ export function createMemorySessionManager({ config, pluginRoot }) {
505
244
  clearTimeout(saveTimer)
506
245
  saveTimer = null
507
246
  }
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
- }
247
+ for (const sessionId of sessions.keys()) {
248
+ await flushSession(sessionId, { commit, reason: "flushAll" })
517
249
  }
518
- await saveSessionMap()
250
+ await saveState()
519
251
  }
520
252
 
521
253
  async function flushSession(opencodeSessionId, { commit = false, reason = "manual" } = {}) {
522
- const mapping = sessionMap.get(opencodeSessionId)
523
- if (!mapping) return false
254
+ if (!opencodeSessionId) return false
255
+ const state = sessions.get(opencodeSessionId)
256
+ if (!state) return false
524
257
 
525
- await flushPendingMessages(opencodeSessionId, mapping)
258
+ const added = await flushPendingMessages(opencodeSessionId, state)
526
259
  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
- }
260
+ await commitOvSession(state.ovSessionId, { force: true, reason })
261
+ } else if (added > 0) {
262
+ await maybeCommitByThreshold(state)
537
263
  }
538
- await saveSessionMap()
264
+ await saveState()
539
265
  return true
540
266
  }
541
267
 
542
268
  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 }
269
+ if (opencodeSessionId) {
270
+ const state = sessions.get(opencodeSessionId)
271
+ if (state) await flushPendingMessages(opencodeSessionId, state)
553
272
  }
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 }
273
+ return commitOvSession(sessionId, { force: true, abortSignal, reason: "tool" })
562
274
  }
563
275
 
564
276
  return {
@@ -570,82 +282,193 @@ export function createMemorySessionManager({ config, pluginRoot }) {
570
282
  flushSession,
571
283
  }
572
284
 
573
- function createSessionMapping(ovSessionId) {
285
+ function createSessionState(opencodeSessionId, event = {}) {
286
+ const parentId = event?.properties?.info?.parentID ?? event?.properties?.parentID ?? event?.parentID ?? ""
287
+ const ovSessionId = parentId
288
+ ? deriveHarnessSessionId("oc-", parentId, `subagent-${opencodeSessionId}`)
289
+ : deriveHarnessSessionId("oc-", opencodeSessionId)
574
290
  return {
575
291
  ovSessionId,
576
292
  createdAt: Date.now(),
577
- capturedMessages: new Set(),
578
- messageRoles: new Map(),
579
- pendingMessages: new Map(),
580
- sendingMessages: new Set(),
293
+ lastActivityAt: Date.now(),
581
294
  lastCommitTime: undefined,
582
- commitInFlight: false,
295
+ compactedAt: undefined,
296
+ messages: new Map(),
583
297
  }
584
298
  }
585
299
 
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
300
+ function createMessageState() {
301
+ return {
302
+ role: "",
303
+ parts: new Map(),
304
+ captured: false,
607
305
  }
306
+ }
608
307
 
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
308
+ function getOrCreateSession(opencodeSessionId, event = {}) {
309
+ let state = sessions.get(opencodeSessionId)
310
+ if (!state) {
311
+ state = createSessionState(opencodeSessionId, event)
312
+ sessions.set(opencodeSessionId, state)
618
313
  }
619
- if (updates.role) buffered.role = updates.role
620
- if (updates.content) buffered.content = mergeMessageContent(buffered.content, updates.content)
621
- sessionMessageBuffer.set(sessionId, freshBuffer)
314
+ return state
622
315
  }
623
316
 
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)
317
+ function resolveEventSessionId(event) {
318
+ return event?.properties?.info?.id ??
319
+ event?.properties?.info?.sessionID ??
320
+ event?.properties?.info?.sessionId ??
321
+ event?.properties?.sessionID ??
322
+ event?.properties?.sessionId ??
323
+ event?.sessionID ??
324
+ event?.sessionId ??
325
+ event?.id
326
+ }
327
+
328
+ function resolvePartRole(part, fallbackRole) {
329
+ if (fallbackRole) return fallbackRole
330
+ const type = String(part?.type || part?.kind || "").toLowerCase()
331
+ if (type.includes("tool") && type.includes("call")) return "assistant"
332
+ if (type.includes("tool")) return "user"
333
+ return ""
334
+ }
335
+
336
+ function buildCapturePayload(message) {
337
+ const partsRaw = Array.from(message.parts.values())
338
+ if (partsRaw.length === 0) return null
339
+ const role = resolvePartRole(partsRaw[0], message.role)
340
+ if (!role) return null
341
+ if (role === "assistant" && !config.captureAssistantTurns) return null
342
+
343
+ const rawText = partsRaw
344
+ .map((part) => extractTextFromPayload(part, { toolMaxChars: config.captureToolMaxChars }))
345
+ .filter(Boolean)
346
+ .join("\n\n")
347
+ const captureParts = partsRaw.flatMap((part) => extractPartsFromPayload(part, {
348
+ toolMaxChars: config.captureToolMaxChars,
349
+ }))
350
+ const decision = shouldCaptureText(rawText, role, config)
351
+ if (!decision.shouldCapture && captureParts.length === 0) return null
352
+ const body = captureParts.length > 0
353
+ ? { role, parts: captureParts }
354
+ : { role, content: decision.text }
355
+ const peerId = effectivePeerId(config)
356
+ if (peerId) body.peer_id = peerId
357
+ return body
358
+ }
359
+
360
+ async function flushPendingMessages(opencodeSessionId, state) {
361
+ let added = 0
362
+ for (const [messageId, message] of state.messages.entries()) {
363
+ if (message.captured) continue
364
+ const body = buildCapturePayload(message)
365
+ if (!body) {
366
+ message.captured = true
367
+ continue
630
368
  }
369
+ const ok = await addMessageToSession(state.ovSessionId, body)
370
+ if (!ok) break
371
+ message.captured = true
372
+ added += 1
373
+ }
374
+ if (added > 0) {
375
+ state.lastActivityAt = Date.now()
376
+ debouncedSaveState()
377
+ }
378
+ return added
379
+ }
380
+
381
+ async function addMessageToSession(ovSessionId, body) {
382
+ const health = await fetchJSON(config, "/health", {}, { timeoutMs: 5000 })
383
+ if (!health.ok) {
384
+ await enqueue("addMessage", ovSessionId, body)
385
+ return true
386
+ }
387
+ const res = await fetchJSON(config, `/api/v1/sessions/${encodeURIComponent(ovSessionId)}/messages`, {
388
+ method: "POST",
389
+ body: JSON.stringify(body),
390
+ }, { timeoutMs: 10000 })
391
+ if (res.ok) return true
392
+ if (isRetryableFailure(res)) {
393
+ const queued = await enqueue("addMessage", ovSessionId, body)
394
+ return Boolean(queued.ok)
631
395
  }
396
+ log("ERROR", "message", "Failed to add message to OpenViking session", {
397
+ openviking_session: ovSessionId,
398
+ role: body.role,
399
+ status: res.status,
400
+ error: res.error,
401
+ })
402
+ return false
632
403
  }
633
404
 
634
- function clearCommitState(mapping) {
635
- mapping.commitInFlight = false
636
- mapping.commitTaskId = undefined
637
- mapping.commitStartedAt = undefined
405
+ async function maybeCommitByThreshold(state) {
406
+ if (config.commitTokenThreshold <= 0) return { committed: false }
407
+ const meta = await fetchJSON(config, `/api/v1/sessions/${encodeURIComponent(state.ovSessionId)}`, {}, {
408
+ timeoutMs: 5000,
409
+ })
410
+ const pendingTokens = Number(meta.result?.pending_tokens || 0)
411
+ log("DEBUG", "session", "Pending token check", {
412
+ openviking_session: state.ovSessionId,
413
+ pendingTokens,
414
+ threshold: config.commitTokenThreshold,
415
+ })
416
+ if (!meta.ok || pendingTokens < config.commitTokenThreshold) return { committed: false, pendingTokens }
417
+ return commitOvSession(state.ovSessionId, { force: true, reason: "threshold" })
418
+ }
419
+
420
+ async function commitOvSession(ovSessionId, { force = false, reason = "manual", abortSignal } = {}) {
421
+ if (!force && config.commitTokenThreshold <= 0) return { status: "skipped" }
422
+ const body = { keep_recent_count: config.commitKeepRecentCount }
423
+ const res = await fetchJSON(config, `/api/v1/sessions/${encodeURIComponent(ovSessionId)}/commit`, {
424
+ method: "POST",
425
+ body: JSON.stringify(body),
426
+ signal: abortSignal,
427
+ }, { timeoutMs: 30000 })
428
+ if (res.ok) {
429
+ for (const state of sessions.values()) {
430
+ if (state.ovSessionId === ovSessionId) state.lastCommitTime = Date.now()
431
+ }
432
+ log("INFO", "session", "Committed OpenViking session", { openviking_session: ovSessionId, reason })
433
+ return { status: "accepted", result: res.result }
434
+ }
435
+ if (isRetryableFailure(res)) {
436
+ await enqueue("commitSession", ovSessionId, body)
437
+ log("WARN", "session", "Queued OpenViking session commit", { openviking_session: ovSessionId, reason })
438
+ return { status: "queued" }
439
+ }
440
+ throw new Error(`Failed to commit OpenViking session ${ovSessionId}: ${res.error?.message || res.status}`)
638
441
  }
639
442
 
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"))
443
+ async function migrateLegacySessionMap() {
444
+ if (!fs.existsSync(oldSessionMapPath)) return
445
+ if (fs.existsSync(`${oldSessionMapPath}.migrated`)) return
446
+ try {
447
+ const data = JSON.parse(await fs.promises.readFile(oldSessionMapPath, "utf8"))
448
+ const ovSessionIds = new Set()
449
+ for (const persisted of Object.values(data.sessions ?? {})) {
450
+ if (persisted?.ovSessionId) ovSessionIds.add(persisted.ovSessionId)
647
451
  }
648
- abortSignal.addEventListener("abort", onAbort, { once: true })
649
- })
452
+ for (const ovSessionId of ovSessionIds) {
453
+ try {
454
+ await commitOvSession(ovSessionId, { force: true, reason: "legacy-migration" })
455
+ } catch (error) {
456
+ log("WARN", "migration", "Legacy orphan session commit failed", {
457
+ openviking_session: ovSessionId,
458
+ error: error?.message,
459
+ })
460
+ }
461
+ }
462
+ await fs.promises.rename(oldSessionMapPath, `${oldSessionMapPath}.migrated`)
463
+ log("INFO", "migration", "Migrated legacy session map", { count: ovSessionIds.size })
464
+ } catch (error) {
465
+ log("ERROR", "migration", "Failed to migrate legacy session map", { error: error?.message })
466
+ }
467
+ }
468
+
469
+ function isRetryableFailure(res) {
470
+ if (!res || res.ok) return false
471
+ const status = Number(res.status || 0)
472
+ return !status || status >= 500 || status === 408 || status === 429
650
473
  }
651
474
  }