feat(hub+web): include scratchlist in session export (#1235) (#1237)

Bump export schema to v2 with scratchlist text and attachment metadata
so operators keep notes when they export-then-delete. Markdown gets a
Scratchlist section; attachment bytes stay out of the JSON.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-07-30 23:21:08 +08:00
committed by GitHub
co-authored by Cursor
parent 99814e9668
commit a742fdf1a8
8 changed files with 174 additions and 18 deletions
+2 -2
View File
@@ -221,9 +221,9 @@ export default {
// Session export
'session.export.title': 'Export conversation',
'session.export.description': 'Choose a format, then download the full visible conversation.',
'session.export.description': 'Choose a format, then download the full visible conversation and scratchlist notes.',
'session.export.format.json': 'JSON',
'session.export.format.json.description': 'Lossless payload with session metadata and messages.',
'session.export.format.json.description': 'Lossless payload with session metadata, messages, and scratchlist notes (attachment metadata only).',
'session.export.format.markdown': 'Markdown',
'session.export.format.markdown.description': 'Readable view generated from the same export payload.',
'session.export.download': 'Download',
+2 -2
View File
@@ -225,9 +225,9 @@ export default {
// Session export
'session.export.title': '导出对话',
'session.export.description': '选择格式,然后下载完整可见对话。',
'session.export.description': '选择格式,然后下载完整可见对话与 scratchlist 笔记。',
'session.export.format.json': 'JSON',
'session.export.format.json.description': '保留会话元数据消息的无损载荷。',
'session.export.format.json.description': '保留会话元数据消息与 scratchlist 笔记的无损载荷(附件仅含元数据)。',
'session.export.format.markdown': 'Markdown',
'session.export.format.markdown.description': '从同一份导出载荷生成的可读视图。',
'session.export.download': '下载',
+45 -3
View File
@@ -2,9 +2,12 @@ import { describe, expect, it } from 'vitest'
import { serializeSessionMarkdown } from './markdown'
import type { HapiSessionExport } from '@hapi/protocol/sessionExport'
function makeExport(messages: HapiSessionExport['messages']): HapiSessionExport {
function makeExport(
messages: HapiSessionExport['messages'],
scratchlist: HapiSessionExport['scratchlist'] = []
): HapiSessionExport {
return {
schemaVersion: 1,
schemaVersion: 2,
exportedAt: Date.UTC(2026, 5, 5, 12, 0, 0),
session: {
id: 'session-abcdef123456',
@@ -32,7 +35,8 @@ function makeExport(messages: HapiSessionExport['messages']): HapiSessionExport
permissionMode: 'default',
collaborationMode: 'default'
},
messages
messages,
scratchlist
}
}
@@ -86,6 +90,44 @@ describe('serializeSessionMarkdown', () => {
expect(markdown).toMatch(/^---\n[\s\S]*\n---\n/)
})
it('renders a Scratchlist section with text and attachment metadata', () => {
const markdown = serializeSessionMarkdown(makeExport([], [
{
entryId: 'entry-1',
text: 'Remember to file the ticket',
createdAt: Date.UTC(2026, 5, 5, 10, 30, 0),
updatedAt: Date.UTC(2026, 5, 5, 10, 31, 0),
attachments: [{
id: 'att-1',
filename: 'sketch.png',
mimeType: 'image/png',
size: 128,
path: 'hapi-hub:scratchlist/att-1'
}]
},
{
entryId: 'entry-2',
text: 'Empty attachments ok',
createdAt: Date.UTC(2026, 5, 5, 10, 32, 0),
updatedAt: Date.UTC(2026, 5, 5, 10, 32, 0),
attachments: []
}
]))
expect(markdown).toContain('## Scratchlist')
expect(markdown).toContain('Remember to file the ticket')
expect(markdown).toContain('Empty attachments ok')
expect(markdown).toContain('- Attachment: sketch.png (image/png, 128 bytes)')
expect(markdown).toContain('scratchlistCount: 2')
})
it('omits the Scratchlist section when there are no notes', () => {
const markdown = serializeSessionMarkdown(makeExport([]))
expect(markdown).not.toContain('## Scratchlist')
expect(markdown).toContain('scratchlistCount: 0')
})
it('skips messages that normalize to null and summarizes tool calls', () => {
const markdown = serializeSessionMarkdown(makeExport([
{
+21 -1
View File
@@ -30,12 +30,14 @@ function formatTimestamp(value: number): string {
function formatFrontMatter(payload: HapiSessionExport, title: string): string {
const metadata = payload.session.metadata
const scratchlist = payload.scratchlist ?? []
const lines = [
'---',
`title: "${escapeYamlString(title)}"`,
`sessionId: "${escapeYamlString(payload.session.id)}"`,
`exportedAt: "${formatTimestamp(payload.exportedAt)}"`,
`messageCount: ${payload.messages.length}`
`messageCount: ${payload.messages.length}`,
`scratchlistCount: ${scratchlist.length}`
]
if (metadata?.path) {
lines.push(`path: "${escapeYamlString(metadata.path)}"`)
@@ -50,6 +52,21 @@ function formatFrontMatter(payload: HapiSessionExport, title: string): string {
return lines.join('\n')
}
function formatScratchlistSection(payload: HapiSessionExport): string | null {
const entries = payload.scratchlist ?? []
if (entries.length === 0) return null
const blocks: string[] = ['## Scratchlist']
for (const entry of entries) {
const timestamp = formatTimestamp(entry.createdAt)
const attachments = entry.attachments?.length
? `\n\n${entry.attachments.map((attachment) => `- Attachment: ${attachment.filename} (${attachment.mimeType}, ${attachment.size} bytes)`).join('\n')}`
: ''
blocks.push(`### Note\n\n_Time: ${timestamp}_\n\n${entry.text}${attachments}`)
}
return blocks.join('\n\n')
}
function truncate(value: string, maxLength: number): string {
if (value.length <= maxLength) return value
return `${value.slice(0, maxLength - 1)}`
@@ -122,6 +139,9 @@ export function serializeSessionMarkdown(payload: HapiSessionExport): string {
`Exported: ${formatTimestamp(payload.exportedAt)}`
]
const scratchlistSection = formatScratchlistSection(payload)
if (scratchlistSection) sections.push(scratchlistSection)
for (const message of payload.messages) {
const normalized = normalizeDecryptedMessage(message)
if (!normalized) continue