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
+65
View File
@@ -190,6 +190,71 @@ describe('MessageService goal status filtering', () => {
})
})
it('includes scratchlist text and attachment metadata in chronological order (tiann/hapi#1235)', () => {
const store = makeStore()
const session = makeSession(store, 'session-export-scratchlist')
store.messages.addMessage(session.id, { role: 'user', content: 'Hello' })
const older = store.scratchlist.create(session.id, 'Park this idea', {
entryId: 'entry-older',
createdAt: 1_000,
attachments: [{
id: 'att-1',
filename: 'note.png',
mimeType: 'image/png',
size: 42,
path: 'hapi-hub:scratchlist/att-1'
}]
})
const newer = store.scratchlist.create(session.id, 'Follow up tomorrow', {
entryId: 'entry-newer',
createdAt: 2_000
})
expect(older.outcome).toBe('created')
expect(newer.outcome).toBe('created')
const service = new MessageService(store, makeIo(() => {}), makePublisher() as any)
const result = service.getSessionExport(session.id, toProtocolSession(session))
expect(result.type).toBe('success')
if (result.type !== 'success') throw new Error('Expected success export')
expect(result.payload.schemaVersion).toBe(2)
expect(result.payload.scratchlist).toEqual([
{
entryId: 'entry-older',
text: 'Park this idea',
createdAt: 1_000,
updatedAt: expect.any(Number),
attachments: [{
id: 'att-1',
filename: 'note.png',
mimeType: 'image/png',
size: 42,
path: 'hapi-hub:scratchlist/att-1'
}]
},
{
entryId: 'entry-newer',
text: 'Follow up tomorrow',
createdAt: 2_000,
updatedAt: expect.any(Number),
attachments: []
}
])
})
it('emits an empty scratchlist array when the session has no notes', () => {
const store = makeStore()
const session = makeSession(store, 'session-export-no-scratchlist')
const service = new MessageService(store, makeIo(() => {}), makePublisher() as any)
const result = service.getSessionExport(session.id, toProtocolSession(session))
expect(result.type).toBe('success')
if (result.type !== 'success') throw new Error('Expected success export')
expect(result.payload.scratchlist).toEqual([])
})
it('pages past hidden-only goal status rows', () => {
const store = makeStore()
const session = makeSession(store, 'goal-status-pagination')
+17 -1
View File
@@ -138,13 +138,29 @@ export class MessageService {
}
}
// Chronological ASC for archive readability (store list is DESC).
const scratchlist = this.store.scratchlist.list(sessionId)
.slice()
.sort((a, b) => {
if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt
return a.entryId < b.entryId ? -1 : a.entryId > b.entryId ? 1 : 0
})
.map((row) => ({
entryId: row.entryId,
text: row.text,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
attachments: row.attachments
}))
return {
type: 'success',
payload: {
schemaVersion: HAPI_SESSION_EXPORT_SCHEMA_VERSION,
exportedAt: Date.now(),
session,
messages
messages,
scratchlist
}
}
}
+9 -6
View File
@@ -137,10 +137,11 @@ function createApp(session: Session, opts?: {
getSessionExport: opts?.getSessionExport ?? (() => ({
type: 'success',
payload: {
schemaVersion: 1,
schemaVersion: 2,
exportedAt: 1_762_000_000_000,
session,
messages: []
messages: [],
scratchlist: []
}
})),
listSlashCommands: opts?.listSlashCommands ?? (async () => ({
@@ -191,10 +192,11 @@ describe('sessions routes', () => {
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
schemaVersion: 1,
schemaVersion: 2,
exportedAt: 1_762_000_000_000,
session,
messages: []
messages: [],
scratchlist: []
})
})
@@ -224,10 +226,11 @@ describe('sessions routes', () => {
getSessionExport: () => ({
type: 'success',
payload: {
schemaVersion: 1,
schemaVersion: 2,
exportedAt: 1_762_000_000_000,
session,
messages
messages,
scratchlist: []
}
})
})
+13 -3
View File
@@ -1,14 +1,24 @@
import { z } from 'zod'
import { DecryptedMessageSchema, SessionSchema } from './schemas'
import { DecryptedMessageSchema, ScratchlistEntrySchema, SessionSchema } from './schemas'
export const HAPI_SESSION_EXPORT_SCHEMA_VERSION = 1
/**
* Session export schema version.
*
* v1: session + messages only (#793 / #808)
* v2: adds scratchlist text + attachment metadata (#1235). Attachment
* bytes are intentionally omitted - metadata (id/filename/mime/size/path)
* is enough for cold archive; download URLs only work while the session
* still exists on the hub.
*/
export const HAPI_SESSION_EXPORT_SCHEMA_VERSION = 2
export const SESSION_EXPORT_MESSAGE_LIMIT = 20_000
export const HapiSessionExportSchema = z.object({
schemaVersion: z.literal(HAPI_SESSION_EXPORT_SCHEMA_VERSION),
exportedAt: z.number().int().nonnegative(),
session: SessionSchema,
messages: z.array(DecryptedMessageSchema)
messages: z.array(DecryptedMessageSchema),
scratchlist: z.array(ScratchlistEntrySchema)
})
export type HapiSessionExport = z.infer<typeof HapiSessionExportSchema>
+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