diff --git a/apps/cli/src/commands/share-resume.test.ts b/apps/cli/src/commands/share-resume.test.ts index 16134a5c..207ead89 100644 --- a/apps/cli/src/commands/share-resume.test.ts +++ b/apps/cli/src/commands/share-resume.test.ts @@ -11,7 +11,11 @@ import { import { tmpdir } from 'node:os' import { join, sep } from 'node:path' -import { sequenceRoot, serializePortableSession } from '@spool-lab/session-kit' +import { + parseSummaryFrontMatter, + sequenceRoot, + serializePortableSession, +} from '@spool-lab/session-kit' import Database from 'better-sqlite3' import { describe, expect, it } from 'vite-plus/test' @@ -621,6 +625,139 @@ describe('spool share local Agent Summary flow', () => { expect(events).toContain('select:Which local Agent should generate the Summary?') }) + it('uploads a Summary whose only defect is an overlong title', async () => { + const hub = makeHub() + const workspace = mkdtempSync(join(tmpdir(), 'spool-summary-long-title-')) + const home = mkdtempSync(join(tmpdir(), 'spool-summary-long-title-home-')) + const filePath = writeFixtureSession(workspace) + const share = shareDeps(hub, workspace, filePath, home) + const events: string[] = [] + const overlong = `Rename alpha to beta across the demo workspace ${'and every downstream caller '.repeat(4)}` + + const exit = await handleShareCommand( + `${SESSION_UUID}@2`, + {}, + { + ...share.deps, + ui: interactiveUi({ selected: 'claude', events }), + detectSummaryAgents: async () => [ + { id: 'claude', name: 'Claude Code', path: '/bin/claude' }, + ], + generateSummary: async () => + BILINGUAL_SUMMARY.replace('title: Rename alpha to beta', `title: ${overlong}`), + }, + ) + + expect(exit).toBe(0) + const stored = hub.sessions.get(`claude_${SESSION_UUID}`)?.summaryMd as string + const titles = parseSummaryFrontMatter(stored).titles + expect(Array.from(titles?.en ?? '').length).toBeLessThanOrEqual(96) + expect(titles?.en).toMatch(/^Rename alpha to beta across the demo workspace .*…$/) + expect(titles?.zh).toBe('将 alpha 重命名为 beta') + expect(stored).toContain('The demo now uses the requested beta name.') + expect(events).toContain('info:Shortened `title` to the 96-character Session title limit.') + expect(events.some((event) => event.startsWith('spinner:error:'))).toBe(false) + }) + + it('rejects an Agent Summary that exceeded 64 KiB before title repair', async () => { + const hub = makeHub() + const workspace = mkdtempSync(join(tmpdir(), 'spool-summary-too-large-')) + const home = mkdtempSync(join(tmpdir(), 'spool-summary-too-large-home-')) + const filePath = writeFixtureSession(workspace) + const share = shareDeps(hub, workspace, filePath, home) + const events: string[] = [] + const generated = BILINGUAL_SUMMARY.replace( + 'title: Rename alpha to beta', + `title: ${'a'.repeat(64 * 1024)}`, + ) + + const exit = await handleShareCommand( + `${SESSION_UUID}@2`, + {}, + { + ...share.deps, + ui: interactiveUi({ selected: 'claude', events }), + detectSummaryAgents: async () => [ + { id: 'claude', name: 'Claude Code', path: '/bin/claude' }, + ], + generateSummary: async () => generated, + }, + ) + + expect(exit).toBe(1) + expect(hub.sessions.get(`claude_${SESSION_UUID}`)?.summaryMd).toBeNull() + expect(events).toContain( + 'error:Claude Code returned an invalid bilingual Summary: the UTF-8 document exceeds 64 KiB', + ) + }) + + it('rejects a repeated first H1 even when its overlong title is repaired', async () => { + const hub = makeHub() + const workspace = mkdtempSync(join(tmpdir(), 'spool-summary-repeated-long-title-')) + const home = mkdtempSync(join(tmpdir(), 'spool-summary-repeated-long-title-home-')) + const filePath = writeFixtureSession(workspace) + const share = shareDeps(hub, workspace, filePath, home) + const events: string[] = [] + const overlong = `Rename alpha to beta across the demo workspace ${'and every downstream caller '.repeat(4)}` + const generated = BILINGUAL_SUMMARY.replace( + 'title: Rename alpha to beta', + `title: ${overlong}`, + ).replace('\n', `\n# ${overlong}\n\n`) + + const exit = await handleShareCommand( + `${SESSION_UUID}@2`, + {}, + { + ...share.deps, + ui: interactiveUi({ selected: 'claude', events }), + detectSummaryAgents: async () => [ + { id: 'claude', name: 'Claude Code', path: '/bin/claude' }, + ], + generateSummary: async () => generated, + }, + ) + + expect(exit).toBe(1) + expect(hub.sessions.get(`claude_${SESSION_UUID}`)?.summaryMd).toBeNull() + expect(events).toContain( + 'error:Claude Code returned an invalid bilingual Summary: Summary bodies must not repeat the Session title as their first H1', + ) + }) + + it('rejects a first H1 that repeats the repaired Session title', async () => { + const hub = makeHub() + const workspace = mkdtempSync(join(tmpdir(), 'spool-summary-repeated-repaired-title-')) + const home = mkdtempSync(join(tmpdir(), 'spool-summary-repeated-repaired-title-home-')) + const filePath = writeFixtureSession(workspace) + const share = shareDeps(hub, workspace, filePath, home) + const events: string[] = [] + const overlong = 'a'.repeat(120) + const repairedTitle = `${'a'.repeat(95)}…` + const generated = BILINGUAL_SUMMARY.replace( + 'title: Rename alpha to beta', + `title: ${overlong}`, + ).replace('\n', `\n# ${repairedTitle}\n\n`) + + const exit = await handleShareCommand( + `${SESSION_UUID}@2`, + {}, + { + ...share.deps, + ui: interactiveUi({ selected: 'claude', events }), + detectSummaryAgents: async () => [ + { id: 'claude', name: 'Claude Code', path: '/bin/claude' }, + ], + generateSummary: async () => generated, + }, + ) + + expect(exit).toBe(1) + expect(hub.sessions.get(`claude_${SESSION_UUID}`)?.summaryMd).toBeNull() + expect(events).toContain( + 'error:Claude Code returned an invalid bilingual Summary: Summary bodies must not repeat the Session title as their first H1', + ) + }) + it('does not prompt or invoke an Agent when non-interactive visibility is acknowledged', async () => { const hub = makeHub() const workspace = mkdtempSync(join(tmpdir(), 'spool-summary-nontty-')) diff --git a/apps/cli/src/commands/share.ts b/apps/cli/src/commands/share.ts index 8ddf0c59..3833a3b4 100644 --- a/apps/cli/src/commands/share.ts +++ b/apps/cli/src/commands/share.ts @@ -16,9 +16,12 @@ import { isResumableSessionProvider, parseSummaryFrontMatter, parseSessionText, + repairOverlongSummaryTitles, sessionRecordData, SESSION_PROVIDERS, + SUMMARY_TITLE_CHAR_LIMIT, type SessionProvider, + type SummaryTitleRepair, } from '@spool-lab/session-kit' import { Command } from 'commander' @@ -420,12 +423,18 @@ export async function handleShareCommand( const prompt = dependencies.buildSummaryPrompt?.(target, prepared) ?? buildPreparedSummaryPrompt(target, prepared) - const summary = (await summarize(agent, prompt)).trim() - if (!summary) throw new Error(`${agent.name} returned an empty Summary.`) - const invalidSummary = bilingualSummaryValidationError(summary) + const generated = (await summarize(agent, prompt)).trim() + if (!generated) throw new Error(`${agent.name} returned an empty Summary.`) + const oversizedSummary = summaryDocumentSizeValidationError(generated) + if (oversizedSummary) { + throw new Error(`${agent.name} returned an invalid bilingual Summary: ${oversizedSummary}`) + } + const repaired = repairOverlongSummaryTitles(generated) + const invalidSummary = repairedBilingualSummaryValidationError(repaired) if (invalidSummary) { throw new Error(`${agent.name} returned an invalid bilingual Summary: ${invalidSummary}`) } + const { summary, shortened } = repaired generation.message('Uploading generated Summary') await publishPreparedShare(client, prepared, { card, @@ -436,6 +445,11 @@ export async function handleShareCommand( generation.message(`Uploading Summary objects ${uploaded}/${total}`), }) generation.stop(`Summary generated by ${agent.name} and uploaded`) + if (shortened.length > 0) { + ui.info( + `Shortened ${shortened.map((key) => `\`${key}\``).join(' and ')} to the ${SUMMARY_TITLE_CHAR_LIMIT}-character Session title limit.`, + ) + } ui.outro('Summary uploaded.') return 0 } catch (cause) { @@ -457,13 +471,15 @@ export async function handleShareCommand( } } +const SUMMARY_REPEATED_TITLE_HEADING_ERROR = + 'Summary bodies must not repeat the Session title as their first H1' + export function bilingualSummaryValidationError(summary: string): string | null { - if (Buffer.byteLength(summary, 'utf8') > 64 * 1024) { - return 'the UTF-8 document exceeds 64 KiB' - } + const oversizedSummary = summaryDocumentSizeValidationError(summary) + if (oversizedSummary) return oversizedSummary const parsed = parseSummaryFrontMatter(summary) if (parsed.titleOverflow) { - return '`title` and `title_zh` must each be at most 96 characters' + return `\`title\` and \`title_zh\` must each be at most ${SUMMARY_TITLE_CHAR_LIMIT} characters` } if (!parsed.titles?.en || !parsed.titles.zh) { return 'both `title` and `title_zh` are required in leading front-matter' @@ -475,11 +491,31 @@ export function bilingualSummaryValidationError(summary: string): string | null repeatsTitleAsFirstHeading(parsed.summaries.en, parsed.titles.en) || repeatsTitleAsFirstHeading(parsed.summaries.zh, parsed.titles.zh) ) { - return 'Summary bodies must not repeat the Session title as their first H1' + return SUMMARY_REPEATED_TITLE_HEADING_ERROR } return null } +function repairedBilingualSummaryValidationError(repaired: SummaryTitleRepair): string | null { + const invalidSummary = bilingualSummaryValidationError(repaired.summary) + if (invalidSummary || !repaired.sourceTitles) return invalidSummary + const parsed = parseSummaryFrontMatter(repaired.summary) + if (!parsed.summaries?.en || !parsed.summaries.zh) return null + if ( + (repaired.sourceTitles.en && + repeatsTitleAsFirstHeading(parsed.summaries.en, repaired.sourceTitles.en)) || + (repaired.sourceTitles.zh && + repeatsTitleAsFirstHeading(parsed.summaries.zh, repaired.sourceTitles.zh)) + ) { + return SUMMARY_REPEATED_TITLE_HEADING_ERROR + } + return null +} + +function summaryDocumentSizeValidationError(summary: string): string | null { + return Buffer.byteLength(summary, 'utf8') > 64 * 1024 ? 'the UTF-8 document exceeds 64 KiB' : null +} + function repeatsTitleAsFirstHeading(markdown: string, title: string): boolean { const first = markdown.split(/\r?\n/).find((line) => line.trim() !== '') if (!first || !/^\s{0,3}#\s+/.test(first)) return false diff --git a/packages/session-kit/src/index.ts b/packages/session-kit/src/index.ts index cb15a5c9..4a2f9732 100644 --- a/packages/session-kit/src/index.ts +++ b/packages/session-kit/src/index.ts @@ -95,7 +95,17 @@ export { isSessionProvider, } from './types.js' -export { parseSummaryFrontMatter } from './summary.js' -export type { ParsedSummary, SessionSummaries, SessionTitles } from './summary.js' +export { + parseSummaryFrontMatter, + repairOverlongSummaryTitles, + SUMMARY_TITLE_CHAR_LIMIT, +} from './summary.js' +export type { + ParsedSummary, + SessionSummaries, + SessionTitles, + SummaryTitleKey, + SummaryTitleRepair, +} from './summary.js' export { costForUsage, MODEL_PRICING } from './pricing.js' export type { ModelPricing, SessionCost } from './pricing.js' diff --git a/packages/session-kit/src/summary.test.ts b/packages/session-kit/src/summary.test.ts index 97c8f02a..b9035178 100644 --- a/packages/session-kit/src/summary.test.ts +++ b/packages/session-kit/src/summary.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vite-plus/test' import { costForUsage } from './pricing.js' -import { parseSummaryFrontMatter } from './summary.js' +import { parseSummaryFrontMatter, repairOverlongSummaryTitles } from './summary.js' describe('parseSummaryFrontMatter', () => { it('parses bilingual titles and strips the block from the body', () => { @@ -106,6 +106,96 @@ describe('parseSummaryFrontMatter', () => { }) }) +describe('repairOverlongSummaryTitles', () => { + it('shortens English and Chinese titles without changing the bodies', () => { + const source = [ + '---', + 'title: Rename alpha to beta', + 'title_zh: 将 alpha 重命名为 beta', + '---', + '', + '', + 'The demo now uses the requested beta name.', + '', + '', + '', + '演示项目现在使用要求的 beta 名称。', + '', + ].join('\n') + const longEnglish = + 'Fix the daemon reconnect loop that keeps retrying after macOS sleep and wake, and add regression coverage for the backoff' + + const repairedEnglish = repairOverlongSummaryTitles( + source.replace('title: Rename alpha to beta', `title: ${longEnglish}`), + ) + + expect(repairedEnglish.shortened).toEqual(['title']) + expect(repairedEnglish.sourceTitles?.en).toBe(longEnglish) + expect(parseSummaryFrontMatter(repairedEnglish.summary).titles?.en).toBe( + 'Fix the daemon reconnect loop that keeps retrying after macOS sleep and wake, and add…', + ) + expect(repairedEnglish.summary.slice(repairedEnglish.summary.indexOf('\n---\n'))).toBe( + source.slice(source.indexOf('\n---\n')), + ) + + const longChinese = '修'.repeat(120) + const repairedChinese = repairOverlongSummaryTitles( + source.replace('title_zh: 将 alpha 重命名为 beta', `title_zh: ${longChinese}`), + ) + expect(repairedChinese.shortened).toEqual(['title_zh']) + expect(parseSummaryFrontMatter(repairedChinese.summary).titles?.zh).toBe(`${'修'.repeat(95)}…`) + + expect(repairOverlongSummaryTitles(source)).toEqual({ + summary: source, + shortened: [], + sourceTitles: { en: 'Rename alpha to beta', zh: '将 alpha 重命名为 beta' }, + }) + const legacy = '# Legacy summary\n\nA single-language body.' + expect(repairOverlongSummaryTitles(legacy)).toEqual({ + summary: legacy, + shortened: [], + sourceTitles: null, + }) + }) + + it('measures the word-boundary floor in Unicode codepoints', () => { + const source = [ + '---', + `title: ${'😀'.repeat(30)} ${'a'.repeat(70)}`, + 'title_zh: 中文标题', + '---', + 'Body.', + ].join('\n') + + const repaired = repairOverlongSummaryTitles(source) + + expect(parseSummaryFrontMatter(repaired.summary).titles?.en).toBe( + `${'😀'.repeat(30)} ${'a'.repeat(64)}…`, + ) + }) + + it('preserves mixed LF and CRLF outside repaired title lines', () => { + const source = [ + '---\r\n', + `title: ${'a'.repeat(120)}\n`, + 'title_zh: 中文标题\r\n', + '---\n', + '\r\n', + '\r\n', + 'English body.\n', + '\r\n', + '\n', + '\r\n', + '中文正文。\n', + '', + ].join('') + + const repaired = repairOverlongSummaryTitles(source) + + expect(repaired.summary).toBe(source.replace('a'.repeat(120), `${'a'.repeat(95)}…`)) + }) +}) + describe('costForUsage', () => { it('prices by longest model prefix and reports totals', () => { const cost = costForUsage({ diff --git a/packages/session-kit/src/summary.ts b/packages/session-kit/src/summary.ts index 418b7fc2..0dcde678 100644 --- a/packages/session-kit/src/summary.ts +++ b/packages/session-kit/src/summary.ts @@ -22,7 +22,26 @@ export interface ParsedSummary { titleOverflow?: boolean } -const MAX_TITLE_CHARS = 96 +export const SUMMARY_TITLE_CHAR_LIMIT = 96 +export type SummaryTitleKey = 'title' | 'title_zh' + +export interface SummaryTitleRepair { + summary: string + shortened: SummaryTitleKey[] + /** Normalized, unbounded titles from the source document. */ + sourceTitles: SessionTitles | null +} + +interface SummarySourceLine { + content: string + ending: string +} + +interface LeadingSummaryFrontMatter { + lines: SummarySourceLine[] + closing: number +} + const SUMMARY_SECTION_START_RE = /^$/ const SUMMARY_SECTION_END = '' @@ -43,40 +62,94 @@ const SUMMARY_SECTION_END = '' */ export function parseSummaryFrontMatter(summaryMd: string | null | undefined): ParsedSummary { const source = summaryMd ?? '' - const lines = source.split(/\r?\n/) - if (lines[0]?.trim() !== '---') return withParsedSummaries(null, source, false) - - let closing = -1 - for (let i = 1; i < lines.length; i++) { - if (lines[i]!.trim() === '---') { - closing = i - break - } - } - if (closing === -1) return withParsedSummaries(null, source, false) + const frontMatter = leadingSummaryFrontMatter(source) + if (!frontMatter) return withParsedSummaries(null, source, false) const titles: SessionTitles = {} let titleOverflow = false - for (const line of lines.slice(1, closing)) { - const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line.trim()) - if (!match) continue - const locale = match[1] === 'title' ? 'en' : match[1] === 'title_zh' ? 'zh' : null - if (locale === null) continue - const normalized = normalizeTitle(match[2]!) - if (Array.from(normalized).length > MAX_TITLE_CHARS) titleOverflow = true - const value = boundTitle(normalized) + for (const line of frontMatter.lines.slice(1, frontMatter.closing)) { + const title = parseSummaryTitleLine(line.content) + if (!title) continue + const locale = title.key === 'title' ? 'en' : 'zh' + if (Array.from(title.value).length > SUMMARY_TITLE_CHAR_LIMIT) titleOverflow = true + const value = boundTitle(title.value) if (!value) continue titles[locale] = value } - const body = lines - .slice(closing + 1) + const body = frontMatter.lines + .slice(frontMatter.closing + 1) + .map((line) => line.content) .join('\n') .replace(/^\s*\n/, '') return withParsedSummaries(titles.en || titles.zh ? titles : null, body, titleOverflow) } +/** + * Shorten only overlong leading front-matter titles while preserving every + * other source byte, including mixed LF/CRLF line endings and both bodies. + */ +export function repairOverlongSummaryTitles(summary: string): SummaryTitleRepair { + const frontMatter = leadingSummaryFrontMatter(summary) + if (!frontMatter) return { summary, shortened: [], sourceTitles: null } + + const shortened: SummaryTitleKey[] = [] + const sourceTitles: SessionTitles = {} + for (const line of frontMatter.lines.slice(1, frontMatter.closing)) { + const title = parseSummaryTitleLine(line.content) + if (!title) continue + sourceTitles[title.key === 'title' ? 'en' : 'zh'] = title.value + if (Array.from(title.value).length <= SUMMARY_TITLE_CHAR_LIMIT) continue + line.content = `${title.key}: ${shortenSummaryTitle(title.value)}` + shortened.push(title.key) + } + + return { + summary: + shortened.length === 0 + ? summary + : frontMatter.lines.map((line) => `${line.content}${line.ending}`).join(''), + shortened, + sourceTitles: sourceTitles.en || sourceTitles.zh ? sourceTitles : null, + } +} + +function leadingSummaryFrontMatter(source: string): LeadingSummaryFrontMatter | null { + const lines = splitSummarySourceLines(source) + if (lines[0]?.content.trim() !== '---') return null + const closing = lines.findIndex((line, index) => index > 0 && line.content.trim() === '---') + return closing === -1 ? null : { lines, closing } +} + +function splitSummarySourceLines(source: string): SummarySourceLine[] { + const lines: SummarySourceLine[] = [] + let start = 0 + while (start <= source.length) { + const newline = source.indexOf('\n', start) + if (newline === -1) { + lines.push({ content: source.slice(start), ending: '' }) + break + } + const contentEnd = newline > start && source[newline - 1] === '\r' ? newline - 1 : newline + lines.push({ + content: source.slice(start, contentEnd), + ending: source.slice(contentEnd, newline + 1), + }) + start = newline + 1 + } + return lines +} + +function parseSummaryTitleLine(line: string): { key: SummaryTitleKey; value: string } | null { + const trimmed = line.trim() + const separator = trimmed.indexOf(':') + if (separator === -1) return null + const candidate = trimmed.slice(0, separator).trimEnd() + if (candidate !== 'title' && candidate !== 'title_zh') return null + return { key: candidate, value: normalizeTitle(trimmed.slice(separator + 1)) } +} + /** * Canonical bilingual bodies are delimited with invisible Markdown comments: * @@ -143,5 +216,13 @@ function normalizeTitle(raw: string): string { } function boundTitle(value: string): string { - return Array.from(value).slice(0, MAX_TITLE_CHARS).join('') + return Array.from(value).slice(0, SUMMARY_TITLE_CHAR_LIMIT).join('') +} + +function shortenSummaryTitle(value: string): string { + const budget = SUMMARY_TITLE_CHAR_LIMIT - 1 + const clipped = Array.from(value).slice(0, budget) + const lastSpace = clipped.lastIndexOf(' ') + const bounded = lastSpace >= Math.floor(budget / 2) ? clipped.slice(0, lastSpace) : clipped + return `${bounded.join('').replace(/[\s\p{P}]+$/u, '')}…` }