From a742fdf1a8b5e49c52ff1678176ca29ecbe3bf3b Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:21:08 +0100 Subject: [PATCH] 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 --- hub/src/sync/messageService.test.ts | 65 ++++++++++++++++++++++ hub/src/sync/messageService.ts | 18 +++++- hub/src/web/routes/sessions.test.ts | 15 +++-- shared/src/sessionExport.ts | 16 +++++- web/src/lib/locales/en.ts | 4 +- web/src/lib/locales/zh-CN.ts | 4 +- web/src/lib/sessionExport/markdown.test.ts | 48 +++++++++++++++- web/src/lib/sessionExport/markdown.ts | 22 +++++++- 8 files changed, 174 insertions(+), 18 deletions(-) diff --git a/hub/src/sync/messageService.test.ts b/hub/src/sync/messageService.test.ts index d4338147..7d1217ab 100644 --- a/hub/src/sync/messageService.test.ts +++ b/hub/src/sync/messageService.test.ts @@ -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') diff --git a/hub/src/sync/messageService.ts b/hub/src/sync/messageService.ts index 95b3fdc6..557de4ff 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -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 } } } diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index bb2cc534..ad6f18c8 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -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: [] } }) }) diff --git a/shared/src/sessionExport.ts b/shared/src/sessionExport.ts index 34a93af1..b65734d3 100644 --- a/shared/src/sessionExport.ts +++ b/shared/src/sessionExport.ts @@ -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 diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index a8a5f224..43784a3f 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 2b4e28a7..520e19fe 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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': '下载', diff --git a/web/src/lib/sessionExport/markdown.test.ts b/web/src/lib/sessionExport/markdown.test.ts index 575565ed..50936076 100644 --- a/web/src/lib/sessionExport/markdown.test.ts +++ b/web/src/lib/sessionExport/markdown.test.ts @@ -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([ { diff --git a/web/src/lib/sessionExport/markdown.ts b/web/src/lib/sessionExport/markdown.ts index 3e2fef04..1d50ebb6 100644 --- a/web/src/lib/sessionExport/markdown.ts +++ b/web/src/lib/sessionExport/markdown.ts @@ -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