opencode-convodump 0.0.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.
@@ -0,0 +1,876 @@
1
+ import { mkdir, rename, unlink, writeFile } from "node:fs/promises"
2
+ import path from "node:path"
3
+
4
+ const SERVICE_NAME = "opencode-convodump"
5
+ type UnknownRecord = Record<string, unknown>
6
+
7
+ type SessionState = {
8
+ queue: Promise<void>
9
+ scheduled?: boolean
10
+ pendingTrigger?: string
11
+ lastWriteAt?: number
12
+ lastFingerprint?: string
13
+ cachedSnapshot?: SessionSnapshot
14
+ prefetching?: Promise<void>
15
+ lastPrefetchAt?: number
16
+ }
17
+
18
+ type SessionSnapshot = {
19
+ session: UnknownRecord
20
+ messages: unknown[]
21
+ todos: unknown
22
+ diff: unknown
23
+ }
24
+
25
+ function normaliseNewlines(value: string): string {
26
+ return value.replace(/\r\n?/g, "\n")
27
+ }
28
+
29
+ function asString(value: unknown, fallback = ""): string {
30
+ if (typeof value === "string") return value
31
+ if (value === null || value === undefined) return fallback
32
+ return String(value)
33
+ }
34
+
35
+ function asObject(value: unknown): UnknownRecord {
36
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {}
37
+ return value as UnknownRecord
38
+ }
39
+
40
+ function asArray(value: unknown): unknown[] {
41
+ return Array.isArray(value) ? value : []
42
+ }
43
+
44
+ function bindMethod<T extends (...args: any[]) => any>(target: unknown, key: string): T | null {
45
+ if (!target || (typeof target !== "object" && typeof target !== "function")) return null
46
+ const candidate = (target as Record<string, unknown>)[key]
47
+ if (typeof candidate !== "function") return null
48
+ return candidate.bind(target) as T
49
+ }
50
+
51
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> {
52
+ return Promise.race([
53
+ promise.catch(() => null as T | null),
54
+ new Promise<null>((resolve) => setTimeout(() => resolve(null), timeoutMs)),
55
+ ])
56
+ }
57
+
58
+ function parseDate(value: unknown): Date | null {
59
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value
60
+
61
+ if (typeof value === "number" && Number.isFinite(value)) {
62
+ const asMillis = value < 1_000_000_000_000 ? value * 1000 : value
63
+ const parsed = new Date(asMillis)
64
+ return Number.isNaN(parsed.getTime()) ? null : parsed
65
+ }
66
+
67
+ if (typeof value === "string" && value.trim() !== "") {
68
+ const parsed = new Date(value)
69
+ return Number.isNaN(parsed.getTime()) ? null : parsed
70
+ }
71
+
72
+ return null
73
+ }
74
+
75
+ function toISO(value: unknown): string | null {
76
+ const parsed = parseDate(value)
77
+ return parsed ? parsed.toISOString() : null
78
+ }
79
+
80
+ function formatFilenameTimestamp(value: unknown): string {
81
+ const parsed = parseDate(value) ?? new Date()
82
+ const year = parsed.getFullYear().toString().padStart(4, "0")
83
+ const month = (parsed.getMonth() + 1).toString().padStart(2, "0")
84
+ const day = parsed.getDate().toString().padStart(2, "0")
85
+ const hour = parsed.getHours().toString().padStart(2, "0")
86
+ const minute = parsed.getMinutes().toString().padStart(2, "0")
87
+ const second = parsed.getSeconds().toString().padStart(2, "0")
88
+ return `${year}-${month}-${day}-${hour}-${minute}-${second}`
89
+ }
90
+
91
+ function sanitiseFilename(value: string): string {
92
+ const safe = value.replace(/[^a-zA-Z0-9._-]/g, "_")
93
+ return safe || "unknown-session"
94
+ }
95
+
96
+ function quoteYamlString(value: string): string {
97
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
98
+ return `"${escaped}"`
99
+ }
100
+
101
+ function writeYamlPair(lines: string[], key: string, value: unknown, indent: number): void {
102
+ const pad = " ".repeat(indent)
103
+
104
+ if (value === null || value === undefined) {
105
+ lines.push(`${pad}${key}: null`)
106
+ return
107
+ }
108
+
109
+ if (typeof value === "string") {
110
+ if (value.includes("\n")) {
111
+ lines.push(`${pad}${key}: |-`)
112
+ for (const segment of value.split("\n")) {
113
+ lines.push(`${pad} ${segment}`)
114
+ }
115
+ } else {
116
+ lines.push(`${pad}${key}: ${quoteYamlString(value)}`)
117
+ }
118
+ return
119
+ }
120
+
121
+ if (typeof value === "number" || typeof value === "boolean") {
122
+ lines.push(`${pad}${key}: ${String(value)}`)
123
+ return
124
+ }
125
+
126
+ if (Array.isArray(value)) {
127
+ if (value.length === 0) {
128
+ lines.push(`${pad}${key}: []`)
129
+ return
130
+ }
131
+
132
+ lines.push(`${pad}${key}:`)
133
+ for (const item of value) {
134
+ writeYamlArrayItem(lines, item, indent + 2)
135
+ }
136
+ return
137
+ }
138
+
139
+ const objectValue = asObject(value)
140
+ const entries = Object.entries(objectValue)
141
+ if (entries.length === 0) {
142
+ lines.push(`${pad}${key}: {}`)
143
+ return
144
+ }
145
+
146
+ lines.push(`${pad}${key}:`)
147
+ for (const [childKey, childValue] of entries) {
148
+ writeYamlPair(lines, childKey, childValue, indent + 2)
149
+ }
150
+ }
151
+
152
+ function writeYamlArrayItem(lines: string[], value: unknown, indent: number): void {
153
+ const pad = " ".repeat(indent)
154
+
155
+ if (value === null || value === undefined) {
156
+ lines.push(`${pad}- null`)
157
+ return
158
+ }
159
+
160
+ if (typeof value === "string") {
161
+ if (value.includes("\n")) {
162
+ lines.push(`${pad}- |-`)
163
+ for (const segment of value.split("\n")) {
164
+ lines.push(`${pad} ${segment}`)
165
+ }
166
+ } else {
167
+ lines.push(`${pad}- ${quoteYamlString(value)}`)
168
+ }
169
+ return
170
+ }
171
+
172
+ if (typeof value === "number" || typeof value === "boolean") {
173
+ lines.push(`${pad}- ${String(value)}`)
174
+ return
175
+ }
176
+
177
+ if (Array.isArray(value)) {
178
+ if (value.length === 0) {
179
+ lines.push(`${pad}- []`)
180
+ return
181
+ }
182
+
183
+ lines.push(`${pad}-`)
184
+ for (const item of value) {
185
+ writeYamlArrayItem(lines, item, indent + 2)
186
+ }
187
+ return
188
+ }
189
+
190
+ const objectValue = asObject(value)
191
+ const entries = Object.entries(objectValue)
192
+ if (entries.length === 0) {
193
+ lines.push(`${pad}- {}`)
194
+ return
195
+ }
196
+
197
+ let first = true
198
+ for (const [key, child] of entries) {
199
+ if (first) {
200
+ if (
201
+ child === null ||
202
+ child === undefined ||
203
+ typeof child === "string" ||
204
+ typeof child === "number" ||
205
+ typeof child === "boolean"
206
+ ) {
207
+ if (typeof child === "string" && child.includes("\n")) {
208
+ lines.push(`${pad}- ${key}: |-`)
209
+ for (const segment of child.split("\n")) {
210
+ lines.push(`${pad} ${segment}`)
211
+ }
212
+ } else if (child === null || child === undefined) {
213
+ lines.push(`${pad}- ${key}: null`)
214
+ } else if (typeof child === "string") {
215
+ lines.push(`${pad}- ${key}: ${quoteYamlString(child)}`)
216
+ } else {
217
+ lines.push(`${pad}- ${key}: ${String(child)}`)
218
+ }
219
+ } else {
220
+ lines.push(`${pad}- ${key}:`)
221
+ if (Array.isArray(child)) {
222
+ if (child.length === 0) {
223
+ lines.push(`${pad} []`)
224
+ } else {
225
+ for (const nested of child) {
226
+ writeYamlArrayItem(lines, nested, indent + 4)
227
+ }
228
+ }
229
+ } else {
230
+ const nestedEntries = Object.entries(asObject(child))
231
+ if (nestedEntries.length === 0) {
232
+ lines.push(`${pad} {}`)
233
+ } else {
234
+ for (const [nestedKey, nestedValue] of nestedEntries) {
235
+ writeYamlPair(lines, nestedKey, nestedValue, indent + 4)
236
+ }
237
+ }
238
+ }
239
+ }
240
+ first = false
241
+ continue
242
+ }
243
+
244
+ writeYamlPair(lines, key, child, indent + 2)
245
+ }
246
+ }
247
+
248
+ function toYaml(value: UnknownRecord): string {
249
+ const lines: string[] = []
250
+ for (const [key, child] of Object.entries(value)) {
251
+ writeYamlPair(lines, key, child, 0)
252
+ }
253
+ return lines.join("\n")
254
+ }
255
+
256
+ function sortForStableJson(value: unknown): unknown {
257
+ if (Array.isArray(value)) return value.map(sortForStableJson)
258
+ if (!value || typeof value !== "object") return value
259
+
260
+ const sorted: UnknownRecord = {}
261
+ const entries = Object.entries(value as UnknownRecord).sort(([a], [b]) => a.localeCompare(b))
262
+ for (const [key, child] of entries) {
263
+ sorted[key] = sortForStableJson(child)
264
+ }
265
+ return sorted
266
+ }
267
+
268
+ function stableJson(value: unknown): string {
269
+ return JSON.stringify(sortForStableJson(value), null, 2)
270
+ }
271
+
272
+ function snapshotFingerprint(snapshot: SessionSnapshot): string {
273
+ return stableJson({
274
+ session: snapshot.session,
275
+ messages: snapshot.messages,
276
+ todos: snapshot.todos,
277
+ diff: snapshot.diff,
278
+ })
279
+ }
280
+
281
+ function chooseTrigger(existing: string | undefined, incoming: string): string {
282
+ if (!existing) return incoming
283
+ if (existing === "session.status:idle") return existing
284
+ if (incoming === "session.status:idle") return incoming
285
+ return incoming
286
+ }
287
+
288
+ function codeFence(content: string): string {
289
+ let longest = 2
290
+ for (const match of content.matchAll(/`+/g)) {
291
+ const runLength = match[0]?.length ?? 0
292
+ if (runLength > longest) longest = runLength
293
+ }
294
+ return "`".repeat(longest + 1)
295
+ }
296
+
297
+ function renderCodeBlock(content: string, language = ""): string {
298
+ const clean = normaliseNewlines(content)
299
+ const fence = codeFence(clean)
300
+ const suffix = language ? language : ""
301
+ return `${fence}${suffix}\n${clean}\n${fence}`
302
+ }
303
+
304
+ function resolveSessionIdFromEvent(event: UnknownRecord): string | null {
305
+ const props = asObject(event.properties)
306
+ const session = asObject(props.session)
307
+
308
+ const candidate =
309
+ props.sessionID ??
310
+ props.sessionId ??
311
+ props.id ??
312
+ props.session_id ??
313
+ session.id ??
314
+ session.sessionID ??
315
+ session.sessionId
316
+
317
+ if (typeof candidate !== "string" || candidate.trim() === "") return null
318
+ return candidate
319
+ }
320
+
321
+ function getMessageRole(message: unknown): string {
322
+ const msg = asObject(message)
323
+ const info = asObject(msg.info)
324
+ const role = msg.role ?? info.role ?? info.type
325
+ return asString(role, "unknown")
326
+ }
327
+
328
+ function buildFrontmatter(session: UnknownRecord): UnknownRecord {
329
+ const time = asObject(session.time)
330
+
331
+ return {
332
+ session_id: session.id ?? null,
333
+ title: session.title ?? null,
334
+ created_at: toISO(time.created ?? session.createdAt ?? session.created_at),
335
+ updated_at: toISO(time.updated ?? session.updatedAt ?? session.updated_at),
336
+ }
337
+ }
338
+
339
+ function truncateText(value: string, maxChars = 5000): string {
340
+ const normalised = normaliseNewlines(value)
341
+ if (normalised.length <= maxChars) return normalised
342
+
343
+ const omitted = normalised.length - maxChars
344
+ return `${normalised.slice(0, maxChars)}\n\n... [truncated ${omitted} chars]`
345
+ }
346
+
347
+ function renderQuoteBlock(value: string, maxChars = 7000): string {
348
+ const text = truncateText(value, maxChars)
349
+ if (text.trim() === "") return ">"
350
+
351
+ return text
352
+ .split("\n")
353
+ .map((line) => (line === "" ? ">" : `> ${line}`))
354
+ .join("\n")
355
+ }
356
+
357
+ function renderPlainText(value: string, maxChars = 7000): string {
358
+ return truncateText(value, maxChars).trim()
359
+ }
360
+
361
+ function truncateInlineText(value: string, maxChars: number): string {
362
+ const flattened = normaliseNewlines(value).replace(/\s+/g, " ").trim()
363
+ if (flattened.length <= maxChars) return flattened
364
+ if (maxChars <= 3) return "..."
365
+ return `${flattened.slice(0, maxChars - 3)}...`
366
+ }
367
+
368
+ function compactToolJsonValue(value: unknown, baseLimit: number, depth = 0): unknown {
369
+ if (value === null || value === undefined) return null
370
+
371
+ if (typeof value === "string") {
372
+ const maxChars = Math.max(16, baseLimit - depth * 4)
373
+ return truncateInlineText(value, maxChars)
374
+ }
375
+
376
+ if (typeof value === "number" || typeof value === "boolean") return value
377
+
378
+ if (Array.isArray(value)) {
379
+ const limit = 16
380
+ const items = value.slice(0, limit).map((item) => compactToolJsonValue(item, baseLimit, depth + 1))
381
+ if (value.length > limit) {
382
+ items.push(`... (${value.length - limit} more items)`)
383
+ }
384
+ return items
385
+ }
386
+
387
+ if (typeof value === "object") {
388
+ const entries = Object.entries(asObject(value))
389
+ const limit = 24
390
+ const result: UnknownRecord = {}
391
+
392
+ for (const [key, child] of entries.slice(0, limit)) {
393
+ result[key] = compactToolJsonValue(child, baseLimit, depth + 1)
394
+ }
395
+
396
+ if (entries.length > limit) {
397
+ result._truncated = `${entries.length - limit} additional keys`
398
+ }
399
+
400
+ return result
401
+ }
402
+
403
+ return asString(value)
404
+ }
405
+
406
+ function formatToolCallJson(value: UnknownRecord): string {
407
+ for (const limit of [56, 48, 40, 32, 24]) {
408
+ const compact = compactToolJsonValue(value, limit)
409
+ const rendered = JSON.stringify(compact, null, 2)
410
+ if (rendered.split("\n").every((line) => line.length <= 80)) {
411
+ return rendered
412
+ }
413
+ }
414
+
415
+ return JSON.stringify(compactToolJsonValue(value, 20), null, 2)
416
+ }
417
+
418
+ function renderCompactJson(value: unknown, maxChars = 3200): string {
419
+ const raw = typeof value === "string" ? value : stableJson(value)
420
+ const language = typeof value === "string" ? "text" : "json"
421
+ return renderCodeBlock(truncateText(raw, maxChars), language)
422
+ }
423
+
424
+ function hasContent(value: unknown): boolean {
425
+ if (value === null || value === undefined) return false
426
+ if (typeof value === "string") return value.trim() !== ""
427
+ if (Array.isArray(value)) return value.length > 0
428
+ if (typeof value === "object") return Object.keys(asObject(value)).length > 0
429
+ return true
430
+ }
431
+
432
+ function formatRole(role: string): string {
433
+ if (!role) return "Unknown"
434
+ if (role.toLowerCase() === "assistant") return "Agent"
435
+ return `${role.charAt(0).toUpperCase()}${role.slice(1)}`
436
+ }
437
+
438
+ function renderPart(part: UnknownRecord): string {
439
+ const type = asString(part.type, "unknown")
440
+ const section: string[] = []
441
+
442
+ if (type === "text") {
443
+ const text = renderPlainText(asString(part.text ?? part.content, ""))
444
+ if (text) section.push(text)
445
+
446
+ const flags: string[] = []
447
+ if (part.synthetic === true) flags.push("synthetic")
448
+ if (part.ignored === true) flags.push("ignored")
449
+ if (flags.length > 0) {
450
+ section.push("")
451
+ section.push(`_(${flags.join(", ")})_`)
452
+ }
453
+ } else if (type === "reasoning") {
454
+ const thinking = asString(part.text ?? part.reasoning ?? part.content, "")
455
+ if (thinking.trim() !== "") {
456
+ section.push(renderQuoteBlock(thinking, 12000))
457
+ }
458
+ } else if (type === "tool") {
459
+ const state = asObject(part.state)
460
+ const toolInput = part.input ?? part.arguments ?? part.args ?? state.input ?? null
461
+ const toolResult = part.output ?? part.result ?? state.output
462
+ const toolError = part.error ?? state.error
463
+ const toolAttachments = part.attachments ?? state.attachments
464
+
465
+ const outputPayload: UnknownRecord = {}
466
+ if (hasContent(toolResult)) outputPayload.result = toolResult
467
+ if (hasContent(toolError)) outputPayload.error = toolError
468
+ if (hasContent(toolAttachments)) outputPayload.attachments = toolAttachments
469
+
470
+ const toolCall: UnknownRecord = {
471
+ tool: asString(part.tool ?? part.name ?? part.toolName, "unknown"),
472
+ input: toolInput,
473
+ output: Object.keys(outputPayload).length > 0 ? outputPayload : null,
474
+ }
475
+
476
+ section.push(renderCodeBlock(formatToolCallJson(toolCall), "json"))
477
+ } else if (type === "file") {
478
+ section.push(`File \`${asString(part.filename ?? part.name, "unknown")}\` (${asString(part.mime ?? part.mimeType, "unknown")})`)
479
+ if (part.url) section.push(`- URL: ${asString(part.url)}`)
480
+ if (part.source !== undefined) {
481
+ section.push("- Source:")
482
+ section.push(renderCompactJson(part.source, 1800))
483
+ }
484
+ } else if (type === "subtask") {
485
+ section.push(`Subtask \`${asString(part.agent, "unknown")}\` (${asString(part.model, "unknown")})`)
486
+
487
+ const command = asString(part.command, "")
488
+ if (command) section.push(`- Command: \`${command}\``)
489
+
490
+ if (part.description !== undefined) {
491
+ section.push("")
492
+ section.push("Description:")
493
+ section.push(renderQuoteBlock(asString(part.description, ""), 6000))
494
+ }
495
+
496
+ if (part.prompt !== undefined) {
497
+ section.push("")
498
+ section.push("Prompt:")
499
+ section.push(renderQuoteBlock(asString(part.prompt, ""), 6000))
500
+ }
501
+ } else if (type === "step-start" || type === "step-finish") {
502
+ return ""
503
+ } else {
504
+ section.push(`${type}:`)
505
+ section.push("")
506
+ section.push("Details:")
507
+ section.push(renderCompactJson(part, 2200))
508
+ }
509
+
510
+ return section.join("\n")
511
+ }
512
+
513
+ function renderMessage(message: unknown, index: number): string {
514
+ const msg = asObject(message)
515
+ const parts = asArray(msg.parts)
516
+ const role = getMessageRole(msg)
517
+ void index
518
+
519
+ const section: string[] = []
520
+ section.push(`### ${formatRole(role)}`)
521
+ section.push("")
522
+
523
+ if (parts.length === 0) {
524
+ section.push("_No content._")
525
+ return section.join("\n")
526
+ }
527
+
528
+ let hasRenderedPart = false
529
+
530
+ for (let i = 0; i < parts.length; i += 1) {
531
+ const renderedPart = renderPart(asObject(parts[i]))
532
+ if (!renderedPart) continue
533
+
534
+ if (hasRenderedPart) section.push("")
535
+ section.push(renderedPart)
536
+ hasRenderedPart = true
537
+ }
538
+
539
+ if (!hasRenderedPart) {
540
+ section.push("_No content._")
541
+ }
542
+
543
+ return section.join("\n")
544
+ }
545
+
546
+ function renderMarkdown(snapshot: SessionSnapshot, trigger: string, ctx: UnknownRecord): string {
547
+ const session = snapshot.session
548
+ const messages = snapshot.messages
549
+
550
+ void trigger
551
+ void ctx
552
+
553
+ const frontmatter = buildFrontmatter(session)
554
+
555
+ const content: string[] = []
556
+ content.push("---")
557
+ content.push(toYaml(frontmatter))
558
+ content.push("---")
559
+
560
+ if (messages.length === 0) {
561
+ content.push("")
562
+ content.push("_No messages yet._")
563
+ } else {
564
+ for (let i = 0; i < messages.length; i += 1) {
565
+ if (i > 0) {
566
+ content.push("")
567
+ content.push("---")
568
+ }
569
+
570
+ content.push("")
571
+ content.push(renderMessage(messages[i], i))
572
+ }
573
+ }
574
+
575
+ return normaliseNewlines(content.join("\n"))
576
+ }
577
+
578
+ async function atomicWrite(filePath: string, content: string): Promise<number> {
579
+ const dirPath = path.dirname(filePath)
580
+ await mkdir(dirPath, { recursive: true })
581
+
582
+ const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`
583
+ const normalised = normaliseNewlines(content)
584
+
585
+ await writeFile(tmpPath, normalised, "utf8")
586
+
587
+ try {
588
+ await rename(tmpPath, filePath)
589
+ } catch (error) {
590
+ await unlink(tmpPath).catch(() => undefined)
591
+ throw error
592
+ }
593
+
594
+ return Buffer.byteLength(normalised, "utf8")
595
+ }
596
+
597
+ async function fetchSessionSnapshot(ctx: UnknownRecord, sessionID: string): Promise<SessionSnapshot> {
598
+ const client = asObject(ctx.client)
599
+ const sessionClient = client.session
600
+
601
+ const get = bindMethod<(arg: unknown) => Promise<{ data: unknown }>>(sessionClient, "get")
602
+ const messages = bindMethod<(arg: unknown) => Promise<{ data: unknown }>>(sessionClient, "messages")
603
+ const todo = bindMethod<(arg: unknown) => Promise<{ data: unknown }>>(sessionClient, "todo")
604
+ const diff = bindMethod<(arg: unknown) => Promise<{ data: unknown }>>(sessionClient, "diff")
605
+
606
+ if (typeof get !== "function" || typeof messages !== "function") {
607
+ throw new Error("Session API unavailable on plugin context")
608
+ }
609
+
610
+ const [sessionResult, messagesResult, todoResult, diffResult] = await Promise.all([
611
+ get({ path: { id: sessionID } }),
612
+ messages({ path: { id: sessionID } }),
613
+ typeof todo === "function"
614
+ ? todo({ path: { id: sessionID } }).catch(() => ({ data: null }))
615
+ : Promise.resolve({ data: null }),
616
+ typeof diff === "function"
617
+ ? diff({ path: { id: sessionID } }).catch(() => ({ data: null }))
618
+ : Promise.resolve({ data: null }),
619
+ ])
620
+
621
+ const session = asObject(sessionResult?.data)
622
+ if (!session.id) {
623
+ throw new Error(`Session not found for id '${sessionID}'`)
624
+ }
625
+
626
+ return {
627
+ session,
628
+ messages: asArray(messagesResult?.data),
629
+ todos: todoResult?.data ?? null,
630
+ diff: diffResult?.data ?? null,
631
+ }
632
+ }
633
+
634
+ function resolveOutputRoot(ctx: UnknownRecord, session: UnknownRecord): string {
635
+ const sessionWorktree = asString(session.worktree, "")
636
+ if (sessionWorktree && sessionWorktree !== "/") return sessionWorktree
637
+
638
+ const ctxWorktree = asString(ctx.worktree, "")
639
+ if (ctxWorktree && ctxWorktree !== "/") return ctxWorktree
640
+
641
+ const sessionDirectory = asString(session.directory, "")
642
+ if (sessionDirectory) return sessionDirectory
643
+
644
+ const ctxDirectory = asString(ctx.directory, "")
645
+ if (ctxDirectory) return ctxDirectory
646
+
647
+ return process.cwd()
648
+ }
649
+
650
+ function resolveOutputPath(snapshot: SessionSnapshot, outputRoot: string): string {
651
+ const session = snapshot.session
652
+ const time = asObject(session.time)
653
+ const created = time.created ?? session.createdAt ?? session.created_at
654
+ const timestamp = formatFilenameTimestamp(created)
655
+ const id = sanitiseFilename(asString(session.id, "unknown-session"))
656
+
657
+ return path.join(outputRoot, "convos", `${timestamp}-${id}.md`)
658
+ }
659
+
660
+ async function logEvent(ctx: UnknownRecord, level: string, message: string, extra: UnknownRecord = {}): Promise<void> {
661
+ const client = asObject(ctx.client)
662
+ const logger = bindMethod<(arg: unknown) => Promise<unknown>>(client.app, "log")
663
+
664
+ if (typeof logger === "function") {
665
+ await logger({
666
+ body: {
667
+ service: SERVICE_NAME,
668
+ level,
669
+ message,
670
+ ...extra,
671
+ },
672
+ }).catch(() => undefined)
673
+ return
674
+ }
675
+
676
+ if (level === "error" || process.env.OPENCODE_CONVODUMP_DEBUG === "1" || process.env.OPENCODE_CONVODUMP_DEBUG === "true") {
677
+ const serialised = Object.keys(extra).length > 0 ? ` ${stableJson(extra)}` : ""
678
+ console.error(`[${SERVICE_NAME}] ${level} ${message}${serialised}`)
679
+ }
680
+ }
681
+
682
+ export const ConvoDumpPlugin = async (ctx: UnknownRecord) => {
683
+ const state = new Map<string, SessionState>()
684
+ const debug = process.env.OPENCODE_CONVODUMP_DEBUG === "1" || process.env.OPENCODE_CONVODUMP_DEBUG === "true"
685
+
686
+ if (debug) {
687
+ const sessionClient = asObject(ctx.client).session
688
+ console.error(
689
+ `[${SERVICE_NAME}] init hasGet=${String(Boolean(bindMethod(sessionClient, "get")))} hasMessages=${String(Boolean(bindMethod(sessionClient, "messages")))} hasTodo=${String(Boolean(bindMethod(sessionClient, "todo")))} hasDiff=${String(Boolean(bindMethod(sessionClient, "diff")))}`,
690
+ )
691
+ }
692
+
693
+ function sessionState(sessionID: string): SessionState {
694
+ const existing = state.get(sessionID)
695
+ if (existing) return existing
696
+
697
+ const created: SessionState = { queue: Promise.resolve() }
698
+ state.set(sessionID, created)
699
+ return created
700
+ }
701
+
702
+ function schedule(sessionID: string, trigger: string): void {
703
+ const entry = sessionState(sessionID)
704
+ entry.pendingTrigger = chooseTrigger(entry.pendingTrigger, trigger)
705
+ if (debug) {
706
+ console.error(`[${SERVICE_NAME}] schedule session=${sessionID} trigger=${trigger} pending=${entry.pendingTrigger}`)
707
+ }
708
+
709
+ if (entry.scheduled) return
710
+ entry.scheduled = true
711
+
712
+ entry.queue = entry.queue
713
+ .then(async () => {
714
+ const enqueueTrigger = entry.pendingTrigger ?? trigger
715
+ entry.pendingTrigger = undefined
716
+
717
+ const hold = setInterval(() => undefined, 50)
718
+ const start = Date.now()
719
+ try {
720
+ if (debug) {
721
+ console.error(`[${SERVICE_NAME}] export begin session=${sessionID} trigger=${enqueueTrigger}`)
722
+ }
723
+ void logEvent(ctx, "info", "export.started", { sessionID, trigger: enqueueTrigger })
724
+
725
+ let snapshot = entry.cachedSnapshot
726
+ if (snapshot) {
727
+ const refreshed = await withTimeout(fetchSessionSnapshot(ctx, sessionID), 120)
728
+ if (refreshed) {
729
+ snapshot = refreshed
730
+ entry.cachedSnapshot = refreshed
731
+ } else if (debug) {
732
+ console.error(`[${SERVICE_NAME}] export refresh timed out, using cached snapshot session=${sessionID}`)
733
+ }
734
+ } else {
735
+ snapshot = await fetchSessionSnapshot(ctx, sessionID)
736
+ entry.cachedSnapshot = snapshot
737
+ }
738
+
739
+ if (!snapshot) {
740
+ throw new Error(`No snapshot available for session '${sessionID}'`)
741
+ }
742
+
743
+ const fingerprint = snapshotFingerprint(snapshot)
744
+ if (entry.lastFingerprint === fingerprint) {
745
+ if (debug) {
746
+ console.error(`[${SERVICE_NAME}] export skipped unchanged session=${sessionID}`)
747
+ }
748
+ void logEvent(ctx, "info", "export.skipped.unchanged", {
749
+ sessionID,
750
+ trigger: enqueueTrigger,
751
+ duration_ms: Date.now() - start,
752
+ })
753
+ return
754
+ }
755
+
756
+ const outputRoot = resolveOutputRoot(ctx, snapshot.session)
757
+ const filePath = resolveOutputPath(snapshot, outputRoot)
758
+ const markdown = renderMarkdown(snapshot, enqueueTrigger, ctx)
759
+ const bytes = await atomicWrite(filePath, markdown)
760
+
761
+ entry.lastWriteAt = Date.now()
762
+ entry.lastFingerprint = fingerprint
763
+ if (debug) {
764
+ console.error(`[${SERVICE_NAME}] export wrote session=${sessionID} file=${filePath} bytes=${bytes}`)
765
+ }
766
+ void logEvent(ctx, "info", "export.completed", {
767
+ sessionID,
768
+ trigger: enqueueTrigger,
769
+ filePath,
770
+ bytes,
771
+ duration_ms: Date.now() - start,
772
+ last_write_at: entry.lastWriteAt,
773
+ })
774
+ } finally {
775
+ clearInterval(hold)
776
+ entry.scheduled = false
777
+ if (entry.pendingTrigger) {
778
+ schedule(sessionID, entry.pendingTrigger)
779
+ }
780
+ }
781
+ })
782
+ .catch(async (error) => {
783
+ entry.scheduled = false
784
+ const errorMessage = asString((error as Error)?.stack ?? (error as Error)?.message ?? error)
785
+ if (debug) {
786
+ console.error(`[${SERVICE_NAME}] export failed session=${sessionID} error=${errorMessage}`)
787
+ }
788
+ void logEvent(ctx, "error", "export.failed", {
789
+ sessionID,
790
+ trigger: entry.pendingTrigger ?? trigger,
791
+ error: errorMessage,
792
+ })
793
+
794
+ if (entry.pendingTrigger) {
795
+ schedule(sessionID, entry.pendingTrigger)
796
+ }
797
+ })
798
+ }
799
+
800
+ function prefetchSnapshot(sessionID: string, reason: string): void {
801
+ const entry = sessionState(sessionID)
802
+ const now = Date.now()
803
+
804
+ if (entry.prefetching) return
805
+ if (entry.lastPrefetchAt && now - entry.lastPrefetchAt < 300) return
806
+
807
+ entry.lastPrefetchAt = now
808
+ entry.prefetching = fetchSessionSnapshot(ctx, sessionID)
809
+ .then((snapshot) => {
810
+ entry.cachedSnapshot = snapshot
811
+ if (debug) {
812
+ console.error(`[${SERVICE_NAME}] prefetched session=${sessionID} reason=${reason}`)
813
+ }
814
+ })
815
+ .catch((error) => {
816
+ if (debug) {
817
+ console.error(`[${SERVICE_NAME}] prefetch failed session=${sessionID} reason=${reason} error=${asString((error as Error)?.message ?? error)}`)
818
+ }
819
+ })
820
+ .finally(() => {
821
+ entry.prefetching = undefined
822
+ })
823
+ }
824
+
825
+ return {
826
+ event: async (payload: unknown) => {
827
+ try {
828
+ const payloadObject = asObject(payload)
829
+ const nestedEvent = asObject(payloadObject.event)
830
+ const event = Object.keys(nestedEvent).length > 0 ? nestedEvent : payloadObject
831
+
832
+ const type = asString(event.type)
833
+ const sessionID = resolveSessionIdFromEvent(event)
834
+ if (debug) {
835
+ console.error(`[${SERVICE_NAME}] event type=${type} sessionID=${sessionID ?? ""}`)
836
+ }
837
+ if (!sessionID) return
838
+
839
+ if (type === "session.status") {
840
+ const status = asObject(asObject(event.properties).status)
841
+ if (debug) {
842
+ console.error(`[${SERVICE_NAME}] session.status state=${asString(status.type, "unknown")}`)
843
+ }
844
+ const statusType = asString(status.type)
845
+ if (statusType === "busy" || statusType === "retry") {
846
+ prefetchSnapshot(sessionID, `session.status:${statusType}`)
847
+ }
848
+ if (status.type === "idle") {
849
+ schedule(sessionID, "session.status:idle")
850
+ }
851
+ return
852
+ }
853
+
854
+ if (type === "session.idle") {
855
+ schedule(sessionID, "session.idle")
856
+ return
857
+ }
858
+
859
+ if (type === "session.diff" || type === "todo.updated") {
860
+ prefetchSnapshot(sessionID, type)
861
+ return
862
+ }
863
+
864
+ if (debug && (type === "message.updated" || type === "message.part.updated")) {
865
+ await logEvent(ctx, "debug", "event.observed", { type, sessionID })
866
+ }
867
+ } catch (error) {
868
+ await logEvent(ctx, "error", "event.handler.failed", {
869
+ error: asString((error as Error)?.stack ?? (error as Error)?.message ?? error),
870
+ })
871
+ }
872
+ },
873
+ }
874
+ }
875
+
876
+ export default ConvoDumpPlugin
package/LICENSE ADDED
@@ -0,0 +1,121 @@
1
+ Creative Commons Legal Code
2
+
3
+ CC0 1.0 Universal
4
+
5
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
6
+ LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
7
+ ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
8
+ INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
9
+ REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
10
+ PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
11
+ THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
12
+ HEREUNDER.
13
+
14
+ Statement of Purpose
15
+
16
+ The laws of most jurisdictions throughout the world automatically confer
17
+ exclusive Copyright and Related Rights (defined below) upon the creator
18
+ and subsequent owner(s) (each and all, an "owner") of an original work of
19
+ authorship and/or a database (each, a "Work").
20
+
21
+ Certain owners wish to permanently relinquish those rights to a Work for
22
+ the purpose of contributing to a commons of creative, cultural and
23
+ scientific works ("Commons") that the public can reliably and without fear
24
+ of later claims of infringement build upon, modify, incorporate in other
25
+ works, reuse and redistribute as freely as possible in any form whatsoever
26
+ and for any purposes, including without limitation commercial purposes.
27
+ These owners may contribute to the Commons to promote the ideal of a free
28
+ culture and the further production of creative, cultural and scientific
29
+ works, or to gain reputation or greater distribution for their Work in
30
+ part through the use and efforts of others.
31
+
32
+ For these and/or other purposes and motivations, and without any
33
+ expectation of additional consideration or compensation, the person
34
+ associating CC0 with a Work (the "Affirmer"), to the extent that he or she
35
+ is an owner of Copyright and Related Rights in the Work, voluntarily
36
+ elects to apply CC0 to the Work and publicly distribute the Work under its
37
+ terms, with knowledge of his or her Copyright and Related Rights in the
38
+ Work and the meaning and intended legal effect of CC0 on those rights.
39
+
40
+ 1. Copyright and Related Rights. A Work made available under CC0 may be
41
+ protected by copyright and related or neighboring rights ("Copyright and
42
+ Related Rights"). Copyright and Related Rights include, but are not
43
+ limited to, the following:
44
+
45
+ i. the right to reproduce, adapt, distribute, perform, display,
46
+ communicate, and translate a Work;
47
+ ii. moral rights retained by the original author(s) and/or performer(s);
48
+ iii. publicity and privacy rights pertaining to a person's image or
49
+ likeness depicted in a Work;
50
+ iv. rights protecting against unfair competition in regards to a Work,
51
+ subject to the limitations in paragraph 4(a), below;
52
+ v. rights protecting the extraction, dissemination, use and reuse of data
53
+ in a Work;
54
+ vi. database rights (such as those arising under Directive 96/9/EC of the
55
+ European Parliament and of the Council of 11 March 1996 on the legal
56
+ protection of databases, and under any national implementation
57
+ thereof, including any amended or successor version of such
58
+ directive); and
59
+ vii. other similar, equivalent or corresponding rights throughout the
60
+ world based on applicable law or treaty, and any national
61
+ implementations thereof.
62
+
63
+ 2. Waiver. To the greatest extent permitted by, but not in contravention
64
+ of, applicable law, Affirmer hereby overtly, fully, permanently,
65
+ irrevocably and unconditionally waives, abandons, and surrenders all of
66
+ Affirmer's Copyright and Related Rights and associated claims and causes
67
+ of action, whether now known or unknown (including existing as well as
68
+ future claims and causes of action), in the Work (i) in all territories
69
+ worldwide, (ii) for the maximum duration provided by applicable law or
70
+ treaty (including future time extensions), (iii) in any current or future
71
+ medium and for any number of copies, and (iv) for any purpose whatsoever,
72
+ including without limitation commercial, advertising or promotional
73
+ purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
74
+ member of the public at large and to the detriment of Affirmer's heirs and
75
+ successors, fully intending that such Waiver shall not be subject to
76
+ revocation, rescission, cancellation, termination, or any other legal or
77
+ equitable action to disrupt the quiet enjoyment of the Work by the public
78
+ as contemplated by Affirmer's express Statement of Purpose.
79
+
80
+ 3. Public License Fallback. Should any part of the Waiver for any reason
81
+ be judged legally invalid or ineffective under applicable law, then the
82
+ Waiver shall be preserved to the maximum extent permitted taking into
83
+ account Affirmer's express Statement of Purpose. In addition, to the
84
+ extent the Waiver is so judged Affirmer hereby grants to each affected
85
+ person a royalty-free, non transferable, non sublicensable, non exclusive,
86
+ irrevocable and unconditional license to exercise Affirmer's Copyright and
87
+ Related Rights in the Work (i) in all territories worldwide, (ii) for the
88
+ maximum duration provided by applicable law or treaty (including future
89
+ time extensions), (iii) in any current or future medium and for any number
90
+ of copies, and (iv) for any purpose whatsoever, including without
91
+ limitation commercial, advertising or promotional purposes (the
92
+ "License"). The License shall be deemed effective as of the date CC0 was
93
+ applied by Affirmer to the Work. Should any part of the License for any
94
+ reason be judged legally invalid or ineffective under applicable law, such
95
+ partial invalidity or ineffectiveness shall not invalidate the remainder
96
+ of the License, and in such case Affirmer hereby affirms that he or she
97
+ will not (i) exercise any of his or her remaining Copyright and Related
98
+ Rights in the Work or (ii) assert any associated claims and causes of
99
+ action with respect to the Work, in either case contrary to Affirmer's
100
+ express Statement of Purpose.
101
+
102
+ 4. Limitations and Disclaimers.
103
+
104
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
105
+ surrendered, licensed or otherwise affected by this document.
106
+ b. Affirmer offers the Work as-is and makes no representations or
107
+ warranties of any kind concerning the Work, express, implied,
108
+ statutory or otherwise, including without limitation warranties of
109
+ title, merchantability, fitness for a particular purpose, non
110
+ infringement, or the absence of latent or other defects, accuracy, or
111
+ the present or absence of errors, whether or not discoverable, all to
112
+ the greatest extent permissible under applicable law.
113
+ c. Affirmer disclaims responsibility for clearing rights of other persons
114
+ that may apply to the Work or any use thereof, including without
115
+ limitation any person's Copyright and Related Rights in the Work.
116
+ Further, Affirmer disclaims responsibility for obtaining any necessary
117
+ consents, permissions or other rights required for any use of the
118
+ Work.
119
+ d. Affirmer understands and acknowledges that Creative Commons is not a
120
+ party to this document and has no duty or obligation with respect to
121
+ this CC0 or use of the Work.
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # opencode-convodump
2
+
3
+ An OpenCode plugin that exports each conversation session to a readable
4
+ Markdown transcript.
5
+
6
+ ## What it does
7
+
8
+ - Listens for session idle events.
9
+ - Fetches the latest session snapshot (session metadata + messages).
10
+ - Writes/updates transcript files at:
11
+ `convos/YYYY-MM-DD-HH-mm-ss-<session-id>.md`
12
+ - Uses a compact frontmatter and conversation-first body format.
13
+
14
+ ## Install from npm
15
+
16
+ Add the package name to `plugin` in your OpenCode config:
17
+
18
+ ```json
19
+ {
20
+ "$schema": "https://opencode.ai/config.json",
21
+ "plugin": ["opencode-convodump"]
22
+ }
23
+ ```
24
+
25
+ OpenCode installs npm plugins automatically with Bun.
26
+
27
+ ## Local file usage (without npm)
28
+
29
+ Use the plugin file directly:
30
+
31
+ - Project: `.opencode/plugins/convodump.ts`
32
+ - Global: `~/.config/opencode/plugins/convodump.ts`
33
+
34
+ ## Release process
35
+
36
+ Publishing to npm is automated from GitHub Releases using
37
+ `.github/workflows/publish-npm.yml`.
38
+
39
+ 1. Bump `version` in `package.json`.
40
+ 2. Commit and push the version change.
41
+ 3. Create a GitHub Release with tag `v<version>` (for example `v0.0.1`).
42
+ 4. The workflow publishes that version to npm.
43
+
44
+ You can also run the same workflow manually from GitHub Actions using
45
+ `workflow_dispatch` (optionally passing `release_tag`, defaulting to
46
+ `v<package.json version>`).
47
+
48
+ ### One-time setup required
49
+
50
+ 1. Create an npm automation token for the account that will publish this
51
+ package.
52
+ 2. Add the token as a GitHub Actions secret named `NPM_TOKEN`.
53
+ 3. Ensure the npm account tied to `NPM_TOKEN` has publish rights to
54
+ `opencode-convodump`.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "opencode-convodump",
4
+ "version": "0.0.1",
5
+ "description": "OpenCode plugin that exports each conversation session to a readable Markdown transcript.",
6
+ "type": "module",
7
+ "license": "CC0-1.0",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/intellectronica/opencode-convodump.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/intellectronica/opencode-convodump/issues"
14
+ },
15
+ "homepage": "https://github.com/intellectronica/opencode-convodump#readme",
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "plugin",
20
+ "transcript",
21
+ "markdown"
22
+ ],
23
+ "exports": {
24
+ ".": {
25
+ "import": "./.opencode/plugins/convodump.ts",
26
+ "types": "./.opencode/plugins/convodump.ts"
27
+ }
28
+ },
29
+ "files": [
30
+ ".opencode/plugins/convodump.ts",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "engines": {
38
+ "bun": ">=1.1.0"
39
+ }
40
+ }