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 +10 -4
- package/index.ts +330 -0
- package/package.json +15 -7
- package/transcript-save.ts +20 -20
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
|
-
|
|
18
|
+
Requires OpenCode v2. Add the plugin to `opencode.json(c)`:
|
|
19
19
|
|
|
20
|
-
```
|
|
21
|
-
|
|
20
|
+
```jsonc
|
|
21
|
+
{
|
|
22
|
+
"plugins": ["oc-transcript-save@latest"]
|
|
23
|
+
}
|
|
22
24
|
```
|
|
23
25
|
|
|
24
|
-
|
|
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.
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./
|
|
7
|
-
"module": "./
|
|
8
|
-
"types": "./
|
|
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": {
|
package/transcript-save.ts
CHANGED
|
@@ -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
|
|