mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: []
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '下载',
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user