oc-transcript-save 0.0.5 → 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.
package/README.md CHANGED
@@ -15,13 +15,19 @@ An [OpenCode](https://github.com/sst/opencode) plugin that automatically saves f
15
15
 
16
16
  ## Installation
17
17
 
18
- 1. Install dependencies:
18
+ Requires OpenCode v2. Add the plugin to `opencode.json(c)`:
19
19
 
20
- ```bash
21
- bun install
20
+ ```jsonc
21
+ {
22
+ "plugins": ["oc-transcript-save@latest"]
23
+ }
22
24
  ```
23
25
 
24
- 2. The plugin exports `TranscriptSavePlugin` which can be registered with your OpenCode instance.
26
+ OpenCode installs the package and loads it automatically. Transcripts are
27
+ written on every turn the session goes idle.
28
+
29
+ > **OpenCode v1?** Pin the legacy release: `"plugin": ["oc-transcript-save@0.0.5"]`,
30
+ > or import the V1 entrypoint explicitly via `oc-transcript-save/v1`.
25
31
 
26
32
  ## Configuration
27
33
 
package/index.ts ADDED
@@ -0,0 +1,330 @@
1
+ /**
2
+ * OpenCode V2 plugin entrypoint.
3
+ *
4
+ * V2 loads this module's default export as `{ id, setup }` and expects a
5
+ * cleanup function from `setup`. Events arrive as `{ type, data }` wire
6
+ * objects on `ctx.event.subscribe()`, session data comes from
7
+ * `ctx.session.get()` / `ctx.session.context()`, and there is no
8
+ * `ctx.client.app.log` or `tui` surface on the server side, so logging is
9
+ * console-only behind OC_TRANSCRIPT_SAVE_DEBUG.
10
+ *
11
+ * The V1 entrypoint remains in ./transcript-save.ts (package subpath "./v1").
12
+ */
13
+
14
+ import {
15
+ asArray,
16
+ asObject,
17
+ asString,
18
+ atomicWrite,
19
+ formatFilenameTimestamp,
20
+ formatToolCallJson,
21
+ normaliseNewlines,
22
+ renderCodeBlock,
23
+ renderCompactJson,
24
+ renderPlainText,
25
+ renderQuoteBlock,
26
+ sanitiseFilename,
27
+ stableJson,
28
+ toYaml,
29
+ truncateText,
30
+ } from './transcript-save.ts'
31
+
32
+ const SERVICE_NAME = 'oc-transcript-save'
33
+
34
+ type UnknownRecord = Record<string, unknown>
35
+ type AnyCtx = any
36
+
37
+ type Snapshot = {
38
+ session: UnknownRecord
39
+ messages: unknown[]
40
+ }
41
+
42
+ function resolveSessionId(event: UnknownRecord): string | null {
43
+ const data = asObject(event.data)
44
+ const candidate = data.sessionID ?? data.sessionId ?? asObject(data.session).id
45
+ if (typeof candidate !== 'string' || candidate.trim() === '') return null
46
+ return candidate
47
+ }
48
+
49
+ function buildFrontmatter(session: UnknownRecord): UnknownRecord {
50
+ const time = asObject(session.time)
51
+ return {
52
+ session_id: session.id ?? null,
53
+ title: session.title ?? null,
54
+ created_at: asString(time.created ?? '') || null,
55
+ updated_at: asString(time.updated ?? '') || null,
56
+ }
57
+ }
58
+
59
+ function renderToolPart(content: UnknownRecord): string {
60
+ const state = asObject(content.state)
61
+ const status = asString(state.status, 'unknown')
62
+ const outputPayload: UnknownRecord = {}
63
+
64
+ const toolResult = state.content ?? state.output
65
+ if (hasContent(toolResult)) outputPayload.result = toolResult
66
+ if (hasContent(state.error)) outputPayload.error = state.error
67
+
68
+ const toolCall: UnknownRecord = {
69
+ tool: asString(content.name, 'unknown'),
70
+ status,
71
+ input: state.input ?? null,
72
+ output: Object.keys(outputPayload).length > 0 ? outputPayload : null,
73
+ }
74
+ return renderCodeBlock(formatToolCallJson(toolCall), 'json')
75
+ }
76
+
77
+ function hasContent(value: unknown): boolean {
78
+ if (value === null || value === undefined) return false
79
+ if (typeof value === 'string') return value.trim() !== ''
80
+ if (Array.isArray(value)) return value.length > 0
81
+ if (typeof value === 'object') return Object.keys(asObject(value)).length > 0
82
+ return true
83
+ }
84
+
85
+ function renderV2Content(content: UnknownRecord): string {
86
+ const type = asString(content.type, 'unknown')
87
+
88
+ if (type === 'text') {
89
+ const text = renderPlainText(asString(content.text, ''))
90
+ return text
91
+ }
92
+
93
+ if (type === 'reasoning') {
94
+ const thinking = asString(content.text, '')
95
+ if (thinking.trim() === '') return ''
96
+ return renderQuoteBlock(thinking, 12000)
97
+ }
98
+
99
+ if (type === 'tool') {
100
+ return renderToolPart(content)
101
+ }
102
+
103
+ return `Unknown part type \`${type}\`:\n${renderCompactJson(content, 1600)}`
104
+ }
105
+
106
+ function renderV2Message(message: UnknownRecord): string {
107
+ const type = asString(message.type, 'unknown')
108
+ const section: string[] = []
109
+
110
+ if (type === 'user') {
111
+ section.push('### User')
112
+ section.push('')
113
+ const text = renderPlainText(asString(message.text, ''))
114
+ if (text) section.push(text)
115
+ if (hasContent(message.files)) {
116
+ section.push('')
117
+ section.push('Attachments:')
118
+ section.push(renderCompactJson(message.files, 2200))
119
+ }
120
+ return section.join('\n')
121
+ }
122
+
123
+ if (type === 'assistant') {
124
+ section.push('### Agent')
125
+ section.push('')
126
+ const contents = asArray(message.content)
127
+ if (contents.length === 0) {
128
+ section.push('_No content._')
129
+ return section.join('\n')
130
+ }
131
+ let hasRendered = false
132
+ for (const item of contents) {
133
+ const rendered = renderV2Content(asObject(item))
134
+ if (!rendered) continue
135
+ if (hasRendered) section.push('')
136
+ section.push(rendered)
137
+ hasRendered = true
138
+ }
139
+ if (!hasRendered) section.push('_No content._')
140
+ return section.join('\n')
141
+ }
142
+
143
+ if (type === 'synthetic' || type === 'system') {
144
+ const description = asString(message.description, '')
145
+ const text = asString(message.text, '')
146
+ if (!description && !text) return ''
147
+ section.push(`### System${description ? `: ${truncateText(description, 120)}` : ''}`)
148
+ section.push('')
149
+ if (text) section.push(renderQuoteBlock(text, 6000))
150
+ return section.join('\n')
151
+ }
152
+
153
+ if (type === 'skill') {
154
+ section.push(`### Skill: ${asString(message.name, 'unknown')}`)
155
+ section.push('')
156
+ section.push(renderQuoteBlock(asString(message.text, ''), 6000))
157
+ return section.join('\n')
158
+ }
159
+
160
+ if (type === 'shell') {
161
+ section.push('### Shell')
162
+ section.push('')
163
+ section.push(renderCodeBlock(asString(message.command, ''), 'sh'))
164
+ if (hasContent(message.output)) {
165
+ section.push('')
166
+ section.push(`Exit: ${asString(message.exit ?? asString(message.status, 'unknown'))}`)
167
+ section.push('')
168
+ section.push(renderCodeBlock(asString(message.output, ''), 'text'))
169
+ }
170
+ return section.join('\n')
171
+ }
172
+
173
+ if (type === 'compaction') {
174
+ const status = asString(message.status, 'unknown')
175
+ section.push(`### Compaction (${status})`)
176
+ section.push('')
177
+ section.push(renderQuoteBlock(asString(message.summary, ''), 8000))
178
+ return section.join('\n')
179
+ }
180
+
181
+ // agent-selected / model-selected / location-switched / idle and any
182
+ // future event-ish records carry no conversational content.
183
+ return ''
184
+ }
185
+
186
+ function renderMarkdown(snapshot: Snapshot): string {
187
+ const content: string[] = []
188
+ content.push('---')
189
+ content.push(toYaml(buildFrontmatter(snapshot.session)))
190
+ content.push('---')
191
+
192
+ if (snapshot.messages.length === 0) {
193
+ content.push('')
194
+ content.push('_No messages yet._')
195
+ } else {
196
+ for (let i = 0; i < snapshot.messages.length; i += 1) {
197
+ const rendered = renderV2Message(asObject(snapshot.messages[i]))
198
+ if (!rendered) continue
199
+ if (content.length > 4) {
200
+ content.push('')
201
+ content.push('---')
202
+ }
203
+ content.push('')
204
+ content.push(rendered)
205
+ }
206
+ }
207
+
208
+ return normaliseNewlines(content.join('\n'))
209
+ }
210
+
211
+ function fingerprint(snapshot: Snapshot): string {
212
+ return stableJson({ session: snapshot.session, messages: snapshot.messages })
213
+ }
214
+
215
+ function resolveOutputPath(ctx: AnyCtx, snapshot: Snapshot): string {
216
+ const session = snapshot.session
217
+ const time = asObject(session.time)
218
+ const timestamp = formatFilenameTimestamp(time.created)
219
+ const id = sanitiseFilename(asString(session.id, 'unknown-session'))
220
+ const root = process.env.OC_TRANSCRIPT_SAVE_PATH || asString(ctx.location?.directory, '') || process.cwd()
221
+ return `${root.replace(/\/+$/, '')}/${timestamp}-${id}.md`
222
+ }
223
+
224
+ async function fetchSnapshot(ctx: AnyCtx, sessionID: string): Promise<Snapshot> {
225
+ const sessionResult = await ctx.session.get({ sessionID })
226
+ const session = asObject((sessionResult as UnknownRecord)?.data ?? sessionResult)
227
+ if (!session.id) throw new Error(`Session not found for id '${sessionID}'`)
228
+
229
+ const contextResult = await ctx.session.context({ sessionID })
230
+ const messages = asArray((contextResult as UnknownRecord)?.data ?? contextResult)
231
+
232
+ return { session, messages }
233
+ }
234
+
235
+ export const TranscriptSavePluginV2 = {
236
+ id: 'oc-transcript-save',
237
+ setup: async (ctx: AnyCtx) => {
238
+ const state = new Map<string, { running: boolean; dirty: boolean; queue: Promise<void>; lastFingerprint: string }>()
239
+ const debug = process.env.OC_TRANSCRIPT_SAVE_DEBUG === '1' || process.env.OC_TRANSCRIPT_SAVE_DEBUG === 'true'
240
+ const controller = new AbortController()
241
+
242
+ const log = (message: string) => {
243
+ if (debug) console.error(`[${SERVICE_NAME}] ${message}`)
244
+ }
245
+
246
+ function entryFor(sessionID: string) {
247
+ const existing = state.get(sessionID)
248
+ if (existing) return existing
249
+ const created = { running: false, dirty: false, queue: Promise.resolve(), lastFingerprint: '' }
250
+ state.set(sessionID, created)
251
+ return created
252
+ }
253
+
254
+ async function exportOnce(sessionID: string): Promise<void> {
255
+ const entry = entryFor(sessionID)
256
+ const snapshot = await fetchSnapshot(ctx, sessionID)
257
+ const next = fingerprint(snapshot)
258
+ if (entry.lastFingerprint === next) {
259
+ log(`export skipped unchanged session=${sessionID}`)
260
+ return
261
+ }
262
+ const filePath = resolveOutputPath(ctx, snapshot)
263
+ const markdown = renderMarkdown(snapshot)
264
+ const bytes = await atomicWrite(filePath, markdown)
265
+ entry.lastFingerprint = next
266
+ log(`export wrote session=${sessionID} file=${filePath} bytes=${bytes}`)
267
+ }
268
+
269
+ function schedule(sessionID: string): void {
270
+ const entry = entryFor(sessionID)
271
+ entry.dirty = true
272
+ if (entry.running) return
273
+ entry.running = true
274
+ entry.queue = entry.queue
275
+ .then(async () => {
276
+ while (entry.dirty) {
277
+ entry.dirty = false
278
+ try {
279
+ await exportOnce(sessionID)
280
+ } catch (error) {
281
+ log(`export failed session=${sessionID} error=${asString((error as Error)?.stack ?? error)}`)
282
+ }
283
+ }
284
+ })
285
+ .finally(() => {
286
+ entry.running = false
287
+ })
288
+ }
289
+
290
+ async function onEvent(event: UnknownRecord): Promise<void> {
291
+ const type = asString(event.type)
292
+ const sessionID = resolveSessionId(event)
293
+ if (!sessionID) return
294
+
295
+ // V2 replaced the ephemeral `session.status` / deprecated `session.idle`
296
+ // bus events (never delivered to plugins) with durable
297
+ // `session.execution.*` events. A turn ends in exactly one of these.
298
+ if (
299
+ type === 'session.execution.succeeded'
300
+ || type === 'session.execution.failed'
301
+ || type === 'session.execution.interrupted'
302
+ ) {
303
+ schedule(sessionID)
304
+ }
305
+ }
306
+
307
+ const pump = (async () => {
308
+ try {
309
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
310
+ try {
311
+ await onEvent(asObject(event))
312
+ } catch (error) {
313
+ log(`event handler failed: ${asString((error as Error)?.stack ?? error)}`)
314
+ }
315
+ }
316
+ } catch (error) {
317
+ if (!controller.signal.aborted) {
318
+ log(`event stream failed: ${asString((error as Error)?.stack ?? error)}`)
319
+ }
320
+ }
321
+ })()
322
+
323
+ return () => {
324
+ controller.abort()
325
+ return pump
326
+ }
327
+ },
328
+ }
329
+
330
+ export default TranscriptSavePluginV2
package/package.json CHANGED
@@ -1,13 +1,21 @@
1
1
  {
2
2
  "name": "oc-transcript-save",
3
3
  "description": "OpenCode plugin that autosaves full transcript in specified directory",
4
- "version": "0.0.5",
4
+ "version": "0.2.1",
5
5
  "type": "module",
6
- "main": "./transcript-save.ts",
7
- "module": "./transcript-save.ts",
8
- "types": "./transcript-save.ts",
6
+ "main": "./index.ts",
7
+ "module": "./index.ts",
8
+ "types": "./index.ts",
9
9
  "exports": {
10
10
  ".": {
11
+ "types": "./index.ts",
12
+ "import": "./index.ts"
13
+ },
14
+ "./server": {
15
+ "types": "./index.ts",
16
+ "import": "./index.ts"
17
+ },
18
+ "./v1": {
11
19
  "types": "./transcript-save.ts",
12
20
  "import": "./transcript-save.ts"
13
21
  }
@@ -19,6 +27,7 @@
19
27
  "fmt": "dprint fmt"
20
28
  },
21
29
  "files": [
30
+ "index.ts",
22
31
  "transcript-save.ts",
23
32
  "tsconfig.json",
24
33
  "README.md"
@@ -34,10 +43,9 @@
34
43
  "url": "git+https://github.com/mdrv/oc-transcript-save.git"
35
44
  },
36
45
  "homepage": "https://github.com/mdrv/oc-transcript-save",
37
- "dependencies": {
38
- "@opencode-ai/plugin": "1.17.11"
39
- },
46
+ "dependencies": {},
40
47
  "devDependencies": {
48
+ "@opencode-ai/plugin": "1.17.11",
41
49
  "@types/bun": "latest"
42
50
  },
43
51
  "peerDependencies": {
@@ -28,22 +28,22 @@ type SessionSnapshot = {
28
28
  diff: unknown
29
29
  }
30
30
 
31
- function normaliseNewlines(value: string): string {
31
+ export function normaliseNewlines(value: string): string {
32
32
  return value.replace(/\r\n?/g, '\n')
33
33
  }
34
34
 
35
- function asString(value: unknown, fallback = ''): string {
35
+ export function asString(value: unknown, fallback = ''): string {
36
36
  if (typeof value === 'string') return value
37
37
  if (value === null || value === undefined) return fallback
38
38
  return String(value)
39
39
  }
40
40
 
41
- function asObject(value: unknown): UnknownRecord {
41
+ export function asObject(value: unknown): UnknownRecord {
42
42
  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
43
43
  return value as UnknownRecord
44
44
  }
45
45
 
46
- function asArray(value: unknown): unknown[] {
46
+ export function asArray(value: unknown): unknown[] {
47
47
  return Array.isArray(value) ? value : []
48
48
  }
49
49
 
@@ -54,7 +54,7 @@ function bindMethod<T extends (...args: any[]) => any>(target: unknown, key: str
54
54
  return candidate.bind(target) as T
55
55
  }
56
56
 
57
- function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> {
57
+ export function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> {
58
58
  return Promise.race([
59
59
  promise.catch(() => null as T | null),
60
60
  new Promise<null>((resolve) => setTimeout(() => resolve(null), timeoutMs)),
@@ -78,12 +78,12 @@ function parseDate(value: unknown): Date | null {
78
78
  return null
79
79
  }
80
80
 
81
- function toISO(value: unknown): string | null {
81
+ export function toISO(value: unknown): string | null {
82
82
  const parsed = parseDate(value)
83
83
  return parsed ? parsed.toISOString() : null
84
84
  }
85
85
 
86
- function formatFilenameTimestamp(value: unknown): string {
86
+ export function formatFilenameTimestamp(value: unknown): string {
87
87
  const parsed = parseDate(value) ?? new Date()
88
88
  const year = parsed.getFullYear().toString().padStart(4, '0')
89
89
  const month = (parsed.getMonth() + 1).toString().padStart(2, '0')
@@ -94,7 +94,7 @@ function formatFilenameTimestamp(value: unknown): string {
94
94
  return `${year}-${month}-${day}-${hour}-${minute}-${second}`
95
95
  }
96
96
 
97
- function sanitiseFilename(value: string): string {
97
+ export function sanitiseFilename(value: string): string {
98
98
  const safe = value.replace(/[^a-zA-Z0-9._-]/g, '_')
99
99
  return safe || 'unknown-session'
100
100
  }
@@ -251,7 +251,7 @@ function writeYamlArrayItem(lines: string[], value: unknown, indent: number): vo
251
251
  }
252
252
  }
253
253
 
254
- function toYaml(value: UnknownRecord): string {
254
+ export function toYaml(value: UnknownRecord): string {
255
255
  const lines: string[] = []
256
256
  for (const [key, child] of Object.entries(value)) {
257
257
  writeYamlPair(lines, key, child, 0)
@@ -271,7 +271,7 @@ function sortForStableJson(value: unknown): unknown {
271
271
  return sorted
272
272
  }
273
273
 
274
- function stableJson(value: unknown): string {
274
+ export function stableJson(value: unknown): string {
275
275
  return JSON.stringify(sortForStableJson(value), null, 2)
276
276
  }
277
277
 
@@ -291,7 +291,7 @@ function chooseTrigger(existing: string | undefined, incoming: string): string {
291
291
  return incoming
292
292
  }
293
293
 
294
- function codeFence(content: string): string {
294
+ export function codeFence(content: string): string {
295
295
  let longest = 2
296
296
  for (const match of content.matchAll(/`+/g)) {
297
297
  const runLength = match[0]?.length ?? 0
@@ -300,7 +300,7 @@ function codeFence(content: string): string {
300
300
  return '`'.repeat(longest + 1)
301
301
  }
302
302
 
303
- function renderCodeBlock(content: string, language = ''): string {
303
+ export function renderCodeBlock(content: string, language = ''): string {
304
304
  const clean = normaliseNewlines(content)
305
305
  const fence = codeFence(clean)
306
306
  const suffix = language ? language : ''
@@ -341,7 +341,7 @@ function buildFrontmatter(session: UnknownRecord): UnknownRecord {
341
341
  }
342
342
  }
343
343
 
344
- function truncateText(value: string, maxChars = 5000): string {
344
+ export function truncateText(value: string, maxChars = 5000): string {
345
345
  const normalised = normaliseNewlines(value)
346
346
  if (normalised.length <= maxChars) return normalised
347
347
 
@@ -349,7 +349,7 @@ function truncateText(value: string, maxChars = 5000): string {
349
349
  return `${normalised.slice(0, maxChars)}\n\n... [truncated ${omitted} chars]`
350
350
  }
351
351
 
352
- function renderQuoteBlock(value: string, maxChars = 7000): string {
352
+ export function renderQuoteBlock(value: string, maxChars = 7000): string {
353
353
  const text = truncateText(value, maxChars)
354
354
  if (text.trim() === '') return '>'
355
355
 
@@ -359,18 +359,18 @@ function renderQuoteBlock(value: string, maxChars = 7000): string {
359
359
  .join('\n')
360
360
  }
361
361
 
362
- function renderPlainText(value: string, maxChars = 7000): string {
362
+ export function renderPlainText(value: string, maxChars = 7000): string {
363
363
  return truncateText(value, maxChars).trim()
364
364
  }
365
365
 
366
- function truncateInlineText(value: string, maxChars: number): string {
366
+ export function truncateInlineText(value: string, maxChars: number): string {
367
367
  const flattened = normaliseNewlines(value).replace(/\s+/g, ' ').trim()
368
368
  if (flattened.length <= maxChars) return flattened
369
369
  if (maxChars <= 3) return '...'
370
370
  return `${flattened.slice(0, maxChars - 3)}...`
371
371
  }
372
372
 
373
- function compactToolJsonValue(value: unknown, baseLimit: number, depth = 0): unknown {
373
+ export function compactToolJsonValue(value: unknown, baseLimit: number, depth = 0): unknown {
374
374
  if (value === null || value === undefined) return null
375
375
 
376
376
  if (typeof value === 'string') {
@@ -408,7 +408,7 @@ function compactToolJsonValue(value: unknown, baseLimit: number, depth = 0): unk
408
408
  return asString(value)
409
409
  }
410
410
 
411
- function formatToolCallJson(value: UnknownRecord): string {
411
+ export function formatToolCallJson(value: UnknownRecord): string {
412
412
  for (const limit of [56, 48, 40, 32, 24]) {
413
413
  const compact = compactToolJsonValue(value, limit)
414
414
  const rendered = JSON.stringify(compact, null, 2)
@@ -420,7 +420,7 @@ function formatToolCallJson(value: UnknownRecord): string {
420
420
  return JSON.stringify(compactToolJsonValue(value, 20), null, 2)
421
421
  }
422
422
 
423
- function renderCompactJson(value: unknown, maxChars = 3200): string {
423
+ export function renderCompactJson(value: unknown, maxChars = 3200): string {
424
424
  const raw = typeof value === 'string' ? value : stableJson(value)
425
425
  const language = typeof value === 'string' ? 'text' : 'json'
426
426
  return renderCodeBlock(truncateText(raw, maxChars), language)
@@ -584,7 +584,7 @@ function renderMarkdown(snapshot: SessionSnapshot, trigger: string, ctx: Unknown
584
584
  return normaliseNewlines(content.join('\n'))
585
585
  }
586
586
 
587
- async function atomicWrite(filePath: string, content: string): Promise<number> {
587
+ export async function atomicWrite(filePath: string, content: string): Promise<number> {
588
588
  const dirPath = path.dirname(filePath)
589
589
  await mkdir(dirPath, { recursive: true })
590
590