From ad7fc202ca6cc93638ce907c4c9b1473bd3c9baf Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 19:30:28 +0000 Subject: [PATCH 1/6] refactor(examples/audiobook-curator): compose tool documents from a shared component library (stage 1) --- .../src/components/audiobook-card.tsx | 102 ++++++++++++++ .../src/components/candidate-ranking.tsx | 89 ++++++++++++ .../src/components/chapter-outline.tsx | 62 ++++++++ .../src/components/curator-document.tsx | 40 ++++++ .../src/components/evidence-trail.tsx | 96 +++++++++++++ .../src/components/integrity-report.tsx | 115 +++++++++++++++ .../src/components/library-shelf.tsx | 133 ++++++++++++++++++ .../src/components/mutation-receipt.tsx | 100 +++++++++++++ .../src/components/primitives.tsx | 37 +++++ .../tools/apply_audiobook_chapters.tsx | 19 ++- .../tools/apply_audiobook_metadata.tsx | 17 ++- .../src/mcp/curator/tools/audit_audiobook.tsx | 17 ++- .../src/mcp/curator/tools/audit_library.tsx | 15 +- .../curator/tools/cache_audible_edition.tsx | 21 ++- .../mcp/curator/tools/convert_audiobook.tsx | 19 ++- .../curator/tools/identify_audible_sample.tsx | 17 ++- .../src/mcp/curator/tools/inspect_sources.tsx | 15 +- .../mcp/curator/tools/inventory_sources.tsx | 15 +- .../mcp/curator/tools/prepare_audiobook.tsx | 15 +- .../src/mcp/curator/tools/search_audible.tsx | 15 +- .../curator/tools/select_audible_edition.tsx | 15 +- .../src/mcp/curator/tools/select_sources.tsx | 16 ++- .../curator/tools/verify_audible_sample.tsx | 15 +- .../mcp/curator/tools/verify_with_whisper.tsx | 15 +- examples/audiobook-curator/src/result.tsx | 77 ---------- .../tests/route-unit/routes.test.ts | 72 ++++++++++ 26 files changed, 1047 insertions(+), 122 deletions(-) create mode 100644 examples/audiobook-curator/src/components/audiobook-card.tsx create mode 100644 examples/audiobook-curator/src/components/candidate-ranking.tsx create mode 100644 examples/audiobook-curator/src/components/chapter-outline.tsx create mode 100644 examples/audiobook-curator/src/components/curator-document.tsx create mode 100644 examples/audiobook-curator/src/components/evidence-trail.tsx create mode 100644 examples/audiobook-curator/src/components/integrity-report.tsx create mode 100644 examples/audiobook-curator/src/components/library-shelf.tsx create mode 100644 examples/audiobook-curator/src/components/mutation-receipt.tsx create mode 100644 examples/audiobook-curator/src/components/primitives.tsx delete mode 100644 examples/audiobook-curator/src/result.tsx diff --git a/examples/audiobook-curator/src/components/audiobook-card.tsx b/examples/audiobook-curator/src/components/audiobook-card.tsx new file mode 100644 index 000000000..dfbc0e11c --- /dev/null +++ b/examples/audiobook-curator/src/components/audiobook-card.tsx @@ -0,0 +1,102 @@ +import { Agent } from '@agent-bundle/runtime'; +import React from 'react'; + +import type { AudibleCandidate } from '../audible.ts'; +import type { InspectionReceipt } from '../curator-core.ts'; +import type { AcousticIdentifyReceipt } from '../evidence.ts'; +import type { LibraryAuditFile, MediaRecord } from '../library.ts'; +import { DataList, type Field } from './primitives.tsx'; + +type FileRecord = InspectionReceipt['files'][number] | LibraryAuditFile | MediaRecord; +type EditionRecord = AcousticIdentifyReceipt['attempts'][number] | AudibleCandidate; + +export type AudiobookCardProps = + | { readonly edition: EditionRecord; readonly file?: never; readonly kind: 'edition' } + | { readonly edition?: never; readonly file: FileRecord; readonly kind: 'file' }; + +const text = (value: unknown): string | undefined => { + if (typeof value === 'string' && value.trim() !== '') return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return undefined; +}; + +const contributorList = (value: unknown): string | undefined => { + if (!Array.isArray(value)) return text(value); + const names = value.flatMap((entry) => { + if (typeof entry === 'string') return entry.trim() === '' ? [] : [entry.trim()]; + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return []; + const record = entry as Readonly>; + const name = text(record.name ?? record.display_name); + return name === undefined ? [] : [name]; + }); + return names.length === 0 ? undefined : names.join(', '); +}; + +const seconds = (value: number): string => { + const rounded = Math.max(0, Math.round(value)); + const hours = Math.floor(rounded / 3600); + const minutes = Math.floor(rounded % 3600 / 60); + const remaining = rounded % 60; + return hours > 0 + ? `${String(hours)}h ${String(minutes)}m ${String(remaining)}s` + : `${String(minutes)}m ${String(remaining)}s`; +}; + +const fileCard = (file: FileRecord) => { + const tags = 'tags' in file && file.tags !== undefined ? file.tags : {}; + const relativePath = 'relativePath' in file ? file.relativePath : undefined; + const title = text(tags.title ?? tags.album) ?? relativePath ?? file.path; + const author = text(tags.artist ?? tags.album_artist ?? tags.author); + const narrator = text(tags.composer); + const duration = 'durationSeconds' in file ? file.durationSeconds : undefined; + const format = 'format' in file + ? file.format + : [file.codec, file.extension].filter((value) => value !== undefined && value !== '').join(' / '); + const fields: Field[] = [ + { label: 'File', value: file.path }, + ...(author === undefined ? [] : [{ label: 'Author', value: author }]), + ...(narrator === undefined ? [] : [{ label: 'Narrator', value: narrator }]), + ...(duration === undefined ? [] : [{ label: 'Duration', value: seconds(duration) }]), + ...(format === '' ? [] : [{ label: 'Format', value: format }]), + ]; + return ( + <> + {`### ${title}`} + + + ); +}; + +const editionCard = (edition: EditionRecord) => { + const title = text(edition.title) ?? 'Untitled Audible edition'; + const author = contributorList(edition.authors); + const narrator = contributorList(edition.narrators); + const runtimeMinutes = typeof edition.runtime_length_min === 'number' ? edition.runtime_length_min : undefined; + const fields: Field[] = [ + ...(author === undefined ? [] : [{ label: 'Author', value: author }]), + ...(narrator === undefined ? [] : [{ label: 'Narrator', value: narrator }]), + ...(runtimeMinutes === undefined ? [] : [{ label: 'Duration', value: seconds(runtimeMinutes * 60) }]), + ...(text(edition.format_type) === undefined ? [] : [{ label: 'Format', value: text(edition.format_type)! }]), + ...(text(edition.region) === undefined ? [] : [{ label: 'Region', value: text(edition.region)! }]), + ...(text(edition.asin) === undefined ? [] : [{ label: 'ASIN', value: text(edition.asin)! }]), + ]; + return ( + <> + {`### ${title}`} + + + ); +}; + +export const AudiobookCard = (props: AudiobookCardProps) => { + switch (props.kind) { + case 'file': + return fileCard(props.file); + case 'edition': + return editionCard(props.edition); + default: { + const unhandled: never = props; + throw new Error(`Unhandled audiobook card: ${JSON.stringify(unhandled)}`); + } + } +}; diff --git a/examples/audiobook-curator/src/components/candidate-ranking.tsx b/examples/audiobook-curator/src/components/candidate-ranking.tsx new file mode 100644 index 000000000..f29b6b719 --- /dev/null +++ b/examples/audiobook-curator/src/components/candidate-ranking.tsx @@ -0,0 +1,89 @@ +import { Agent } from '@agent-bundle/runtime'; +import React from 'react'; + +import type { AudibleSearchReceipt, AudibleSelectionReceipt } from '../audible.ts'; +import type { AcousticIdentifyReceipt } from '../evidence.ts'; +import { AudiobookCard } from './audiobook-card.tsx'; +import { Callout, DataList } from './primitives.tsx'; + +type RankingReceipt = AcousticIdentifyReceipt | AudibleSearchReceipt | AudibleSelectionReceipt; + +export interface CandidateRankingProps { + readonly receipt: RankingReceipt; +} + +const maximumCandidates = 10; + +const searchRanking = (receipt: AudibleSearchReceipt) => ( + <> + + {receipt.candidates.slice(0, maximumCandidates).map((candidate, index) => ( + + + {`## Rank ${String(index + 1)} · score ${String(candidate.evidence.score)} · ${candidate.region}`} + + + + ))} + {receipt.candidates.length > maximumCandidates + ? {`_+${String(receipt.candidates.length - maximumCandidates)} more candidates retained in the structured receipt._`} + : null} + {receipt.errors.length > 0 + ? {receipt.errors.map((row) => `${row.region}: ${row.error}`).join('; ')} + : null} + {receipt.reviewNote} + +); + +const identifyRanking = (receipt: AcousticIdentifyReceipt) => ( + <> + + {receipt.attempts.slice(0, maximumCandidates).map((attempt, index) => ( + + + {`## Attempt ${String(index + 1)} · score ${String(attempt.score ?? 'unavailable')} · ${String(attempt.region ?? 'unknown region')} · ${String(attempt.status ?? 'unknown status')}`} + + + + ))} + {receipt.attempts.length > maximumCandidates + ? {`_+${String(receipt.attempts.length - maximumCandidates)} more attempts retained in the structured receipt._`} + : null} + {receipt.reviewNote} + +); + +const selectionRanking = (receipt: AudibleSelectionReceipt) => ( + <> + + + {receipt.reviewNote ?? 'The selected Audible edition was explicitly human-reviewed.'} + +); + +export const CandidateRanking = ({ receipt }: CandidateRankingProps) => { + switch (receipt.operation) { + case 'audible-search': + return searchRanking(receipt); + case 'acoustic-identify': + return identifyRanking(receipt); + case 'audible-select': + return selectionRanking(receipt); + default: { + const unhandled: never = receipt; + throw new Error(`Unhandled candidate ranking: ${JSON.stringify(unhandled)}`); + } + } +}; diff --git a/examples/audiobook-curator/src/components/chapter-outline.tsx b/examples/audiobook-curator/src/components/chapter-outline.tsx new file mode 100644 index 000000000..5c4152590 --- /dev/null +++ b/examples/audiobook-curator/src/components/chapter-outline.tsx @@ -0,0 +1,62 @@ +import { Agent } from '@agent-bundle/runtime'; +import React from 'react'; + +import type { ConvertReceipt } from '../conversion.ts'; +import type { IntegrityAuditReceipt } from '../integrity-audit.ts'; +import type { ChapterReceipt } from '../media-mutation.ts'; + +type ChapterSourceReceipt = ChapterReceipt | ConvertReceipt | IntegrityAuditReceipt; + +export interface ChapterOutlineProps { + readonly receipt: ChapterSourceReceipt; +} + +interface DisplayChapter { + readonly endSeconds: number; + readonly number: number; + readonly startSeconds: number; + readonly title: string; +} + +const maximumChapters = 20; + +const timestamp = (seconds: number): string => { + const total = Math.max(0, Math.round(seconds)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor(total % 3600 / 60); + const remaining = total % 60; + return [hours, minutes, remaining].map((value) => String(value).padStart(2, '0')).join(':'); +}; + +const chaptersFor = (receipt: ChapterSourceReceipt): readonly DisplayChapter[] => { + switch (receipt.operation) { + case 'apply-chapters': + return receipt.chapters.map((chapter, index) => ({ ...chapter, number: index + 1 })); + case 'convert': + return receipt.expectedChapters; + case 'audit': + return receipt.chapters; + default: { + const unhandled: never = receipt; + throw new Error(`Unhandled chapter source: ${JSON.stringify(unhandled)}`); + } + } +}; + +export const ChapterOutline = ({ receipt }: ChapterOutlineProps) => { + const chapters = chaptersFor(receipt); + return ( + + {[ + `## Chapter outline (${String(chapters.length)})`, + '', + ...chapters.slice(0, maximumChapters).map((chapter) => ( + `${String(chapter.number)}. **${chapter.title || 'Untitled chapter'}** · ${timestamp(chapter.startSeconds)}–${timestamp(chapter.endSeconds)}` + )), + ...(chapters.length > maximumChapters + ? [`_+${String(chapters.length - maximumChapters)} more chapters retained in the structured receipt._`] + : []), + ].join('\n')} + + ); +}; diff --git a/examples/audiobook-curator/src/components/curator-document.tsx b/examples/audiobook-curator/src/components/curator-document.tsx new file mode 100644 index 000000000..7ae01fb5a --- /dev/null +++ b/examples/audiobook-curator/src/components/curator-document.tsx @@ -0,0 +1,40 @@ +import { Agent, type JsonValue } from '@agent-bundle/runtime'; +import React, { type ReactNode } from 'react'; + +import type { AudibleCacheReceipt, AudibleSearchReceipt, AudibleSelectionReceipt } from '../audible.ts'; +import type { ConvertReceipt } from '../conversion.ts'; +import type { InspectionReceipt, PrepareReceipt } from '../curator-core.ts'; +import type { AcousticIdentifyReceipt, AcousticReceipt, WhisperReceipt } from '../evidence.ts'; +import type { IntegrityAuditReceipt } from '../integrity-audit.ts'; +import type { InventoryReceipt, LibraryAuditReceipt, SelectionReceipt } from '../library.ts'; +import type { ChapterReceipt, MetadataReceipt } from '../media-mutation.ts'; + +export type CuratorReceipt = + | AcousticIdentifyReceipt + | AcousticReceipt + | AudibleCacheReceipt + | AudibleSearchReceipt + | AudibleSelectionReceipt + | ChapterReceipt + | ConvertReceipt + | InspectionReceipt + | IntegrityAuditReceipt + | InventoryReceipt + | LibraryAuditReceipt + | MetadataReceipt + | PrepareReceipt + | SelectionReceipt + | WhisperReceipt; + +export interface CuratorDocumentProps { + readonly children: ReactNode; + readonly headline: string; + readonly receipt: CuratorReceipt; +} + +export const CuratorDocument = ({ children, headline, receipt }: CuratorDocumentProps) => ( + + {headline} + {children} + +); diff --git a/examples/audiobook-curator/src/components/evidence-trail.tsx b/examples/audiobook-curator/src/components/evidence-trail.tsx new file mode 100644 index 000000000..654c7eb93 --- /dev/null +++ b/examples/audiobook-curator/src/components/evidence-trail.tsx @@ -0,0 +1,96 @@ +import { Agent } from '@agent-bundle/runtime'; +import React from 'react'; + +import type { AcousticIdentifyReceipt, AcousticReceipt, WhisperReceipt } from '../evidence.ts'; +import { Callout, DataList } from './primitives.tsx'; + +type EvidenceReceipt = AcousticIdentifyReceipt | AcousticReceipt | WhisperReceipt; + +export interface EvidenceTrailProps { + readonly receipt: EvidenceReceipt; +} + +const acousticTrail = (receipt: AcousticReceipt) => ( + <> + + + {[ + '## Acoustic evidence trail', + '', + `1. Retrieved the reviewed ${receipt.region} Audible sample for ${receipt.asin}.`, + `2. Compared the sample fingerprint with ${receipt.file}.`, + `3. Matcher outcome: **${receipt.verifiedRecording ? 'same recording verified' : 'no match'}**.`, + '', + '```json', + JSON.stringify(receipt.fingerprint, null, 2), + '```', + ].join('\n')} + + + {receipt.verifiedRecording + ? 'Verified recording evidence: the local audio matched the reviewed Audible sample.' + : 'Review required: the local audio did not match the reviewed Audible sample.'} + + +); + +const identifyTrail = (receipt: AcousticIdentifyReceipt) => ( + <> + + {[ + '## Acoustic attempt timeline', + '', + ...receipt.attempts.map((attempt, index) => ( + `${String(index + 1)}. ${String(attempt.region ?? 'unknown')}/${String(attempt.asin ?? 'missing ASIN')} — **${String(attempt.status ?? 'unknown')}**${attempt.reason === undefined ? '' : `: ${String(attempt.reason)}`}` + )), + ].join('\n')} + + + {receipt.verifiedRecording + ? `Verified recording evidence: ${String(receipt.identified?.region ?? 'unknown')}/${String(receipt.identified?.asin ?? 'unknown')} matched acoustically.` + : 'Review required: none of the attempted Audible candidates matched acoustically.'} + + +); + +const whisperTrail = (receipt: WhisperReceipt) => ( + <> + + + {[ + '## Transcript evidence timeline', + '', + ...receipt.windows.map((window) => ( + `${String(window.index)}. ${window.startSeconds.toFixed(1)}s–${(window.startSeconds + window.sampleSeconds).toFixed(1)}s · ${window.usable ? 'usable' : 'insufficient'}\n > ${window.text === '' ? '_No transcript text_' : window.text.replaceAll(/\s+/gu, ' ')}` + )), + ].join('\n')} + + + {`${receipt.status === 'transcript-ready' ? 'Transcript evidence is ready for review.' : 'Review required because too few spoken windows were usable.'} ${receipt.review}`} + + +); + +export const EvidenceTrail = ({ receipt }: EvidenceTrailProps) => { + switch (receipt.operation) { + case 'audiolocate': + return acousticTrail(receipt); + case 'acoustic-identify': + return identifyTrail(receipt); + case 'whisper-identity': + return whisperTrail(receipt); + default: { + const unhandled: never = receipt; + throw new Error(`Unhandled evidence trail: ${JSON.stringify(unhandled)}`); + } + } +}; diff --git a/examples/audiobook-curator/src/components/integrity-report.tsx b/examples/audiobook-curator/src/components/integrity-report.tsx new file mode 100644 index 000000000..db6ee88d1 --- /dev/null +++ b/examples/audiobook-curator/src/components/integrity-report.tsx @@ -0,0 +1,115 @@ +import React from 'react'; + +import type { ConvertReceipt } from '../conversion.ts'; +import type { IntegrityAuditReceipt } from '../integrity-audit.ts'; +import type { ChapterReceipt, MetadataReceipt } from '../media-mutation.ts'; +import { Callout, DataList, type Field } from './primitives.tsx'; + +type IntegrityReceipt = ChapterReceipt | ConvertReceipt | IntegrityAuditReceipt | MetadataReceipt; + +export interface IntegrityReportProps { + readonly receipt: IntegrityReceipt; +} + +const auditReport = (receipt: IntegrityAuditReceipt) => { + const fields: Field[] = [ + { label: 'Audit status', value: receipt.status }, + { label: 'File', value: receipt.file }, + { label: 'Bytes', value: receipt.bytes }, + { label: 'File SHA-256', value: receipt.sha256 }, + { label: 'Audio SHA-256', value: receipt.audioSha256 }, + { label: 'Codec', value: receipt.probe.codec }, + { label: 'Duration seconds', value: receipt.probe.durationSeconds }, + { label: 'Chapters', value: receipt.chapters.length }, + { label: 'Full decode', value: receipt.fullDecode }, + { label: 'Source mapping', value: receipt.sourceChapterMapping.status }, + { label: 'Defects', value: receipt.chapterIssues.length }, + ]; + const issues = [...receipt.chapterIssues, ...receipt.sourceChapterMapping.issues]; + return ( + <> + + + {issues.length === 0 + ? 'Verified: hashes, probe facts, chapter structure, and requested source mapping checks passed.' + : `Review required: ${issues.join('; ')}`} + + + ); +}; + +const metadataReport = (receipt: MetadataReceipt) => ( + <> + + + {receipt.status === 'planned' + ? 'Integrity verification is pending because this receipt is a plan.' + : `Verified metadata keys: ${(receipt.verifiedMetadataKeys ?? []).join(', ')}. Audio hashes and chapter boundaries were preserved.`} + + +); + +const chapterReport = (receipt: ChapterReceipt) => ( + <> + + + {receipt.status === 'planned' + ? 'Integrity verification is pending because this receipt is a plan.' + : 'Verified: chapter boundaries match the plan and all audio streams and non-chapter media state were preserved.'} + + +); + +const conversionReport = (receipt: ConvertReceipt) => ( + <> + + + {receipt.status === 'planned' + ? 'Integrity verification is pending because this receipt is a conversion plan.' + : 'Verified: the output duration and chapter mapping match the source plan; source files were preserved.'} + + +); + +export const IntegrityReport = ({ receipt }: IntegrityReportProps) => { + switch (receipt.operation) { + case 'audit': + return auditReport(receipt); + case 'apply-metadata': + return metadataReport(receipt); + case 'apply-chapters': + return chapterReport(receipt); + case 'convert': + return conversionReport(receipt); + default: { + const unhandled: never = receipt; + throw new Error(`Unhandled integrity report: ${JSON.stringify(unhandled)}`); + } + } +}; diff --git a/examples/audiobook-curator/src/components/library-shelf.tsx b/examples/audiobook-curator/src/components/library-shelf.tsx new file mode 100644 index 000000000..3747a8aa6 --- /dev/null +++ b/examples/audiobook-curator/src/components/library-shelf.tsx @@ -0,0 +1,133 @@ +import { Agent } from '@agent-bundle/runtime'; +import React from 'react'; + +import type { InspectionReceipt } from '../curator-core.ts'; +import type { InventoryReceipt, LibraryAuditReceipt, SelectionReceipt } from '../library.ts'; +import { AudiobookCard } from './audiobook-card.tsx'; +import { Callout, DataList, type Field } from './primitives.tsx'; + +type ShelfReceipt = InspectionReceipt | InventoryReceipt | LibraryAuditReceipt | SelectionReceipt; + +export interface LibraryShelfProps { + readonly receipt: ShelfReceipt; +} + +const maximumCards = 20; + +const remaining = (count: number): React.ReactNode => count > maximumCards + ? {`_+${String(count - maximumCards)} more files retained in the structured receipt._`} + : null; + +const inspectionShelf = (receipt: InspectionReceipt) => ( + <> + + {receipt.files.slice(0, maximumCards).map((file) => ( + + ))} + {remaining(receipt.files.length)} + +); + +const inventoryShelf = (receipt: InventoryReceipt) => ( + <> + + {receipt.files.slice(0, maximumCards).map((file) => ( + + ))} + {remaining(receipt.files.length)} + {receipt.errors.length > 0 + ? {receipt.errors.map((row) => `${row.path}: ${row.error}`).join('; ')} + : null} + +); + +const auditShelf = (receipt: LibraryAuditReceipt) => { + const summaryFields: Field[] = [ + { label: 'Files', value: receipt.summary.files }, + { label: 'Total bytes', value: receipt.summary.bytes }, + { label: 'Missing album', value: receipt.summary.missingAlbum }, + { label: 'Missing artwork', value: receipt.summary.missingArtwork }, + { label: 'Missing author', value: receipt.summary.missingAuthor }, + { label: 'Missing chapters', value: receipt.summary.missingChapters }, + { label: 'Missing title', value: receipt.summary.missingTitle }, + { label: 'Probe failures', value: receipt.summary.probeFailures }, + ]; + return ( + <> + + {receipt.files.slice(0, maximumCards).map((file) => ( + + ))} + {remaining(receipt.files.length)} + {receipt.duplicateCandidates.slice(0, 10).map((group) => ( + + {`Duplicate candidate group ${group.identityKey}: ${group.files.join(', ')}. ${receipt.reviewNote}`} + + ))} + {receipt.multipartCandidates.slice(0, 10).map((group) => ( + + {`Multipart candidate group ${group.identityKey}: ${group.files.map((file) => `part ${String(file.part)} ${file.path}`).join(', ')}. ${receipt.reviewNote}`} + + ))} + {receipt.duplicateCandidates.length === 0 && receipt.multipartCandidates.length === 0 + ? {receipt.reviewNote} + : null} + + ); +}; + +const selectionShelf = (receipt: SelectionReceipt) => ( + <> + selection.reviewRequired).length }, + ...(receipt.inventory === undefined ? [] : [{ label: 'Inventory', value: receipt.inventory }]), + ]} /> + {receipt.selections.slice(0, maximumCards).map((selection) => ( + + {`## Source group: ${selection.identityKey}`} + + + {[ + `Selected because: ${selection.reason}.`, + `Alternates: ${selection.alternates.length === 0 ? 'none' : selection.alternates.map((file) => file.path).join(', ')}`, + `Duration spread: ${String(selection.durationSpreadSeconds)} seconds.`, + ].join('\n\n')} + + {selection.reviewRequired + ? {selection.reviewReason ?? 'The source group requires human review.'} + : null} + + ))} + {receipt.selections.length > maximumCards + ? {`_+${String(receipt.selections.length - maximumCards)} more source groups retained in the structured receipt._`} + : null} + +); + +export const LibraryShelf = ({ receipt }: LibraryShelfProps) => { + switch (receipt.operation) { + case 'inspect': + return inspectionShelf(receipt); + case 'inventory': + return inventoryShelf(receipt); + case 'library-audit': + return auditShelf(receipt); + case 'quality-selection': + return selectionShelf(receipt); + default: { + const unhandled: never = receipt; + throw new Error(`Unhandled shelf receipt: ${JSON.stringify(unhandled)}`); + } + } +}; diff --git a/examples/audiobook-curator/src/components/mutation-receipt.tsx b/examples/audiobook-curator/src/components/mutation-receipt.tsx new file mode 100644 index 000000000..5acac1d25 --- /dev/null +++ b/examples/audiobook-curator/src/components/mutation-receipt.tsx @@ -0,0 +1,100 @@ +import React from 'react'; + +import type { ConvertReceipt } from '../conversion.ts'; +import type { PrepareReceipt } from '../curator-core.ts'; +import type { ChapterReceipt, MetadataReceipt } from '../media-mutation.ts'; +import { Callout, DataList } from './primitives.tsx'; + +type PlanFirstReceipt = ChapterReceipt | ConvertReceipt | MetadataReceipt | PrepareReceipt; + +export interface MutationReceiptProps { + readonly receipt: PlanFirstReceipt; +} + +const metadataMutation = (receipt: MetadataReceipt) => ( + <> + value !== undefined && value !== '').length }, + { label: 'Audio hash before', value: receipt.audioSha256Before }, + ...(receipt.audioSha256After === undefined ? [] : [{ label: 'Audio hash after', value: receipt.audioSha256After }]), + ]} /> + + {receipt.status === 'planned' + ? 'Plan only: the audiobook, every audio stream, and its chapter structure remain unchanged.' + : 'Applied and verified: every audio stream and the existing chapter structure remain unchanged.'} + + +); + +const chapterMutation = (receipt: ChapterReceipt) => ( + <> + + + {receipt.status === 'planned' + ? 'Plan only: the audiobook and all non-chapter media state remain unchanged.' + : 'Applied and verified: audio streams, metadata, artwork, duration, and other non-chapter media state remain unchanged.'} + + +); + +const conversionMutation = (receipt: ConvertReceipt) => ( + <> + + + {receipt.status === 'planned' + ? 'Plan only: no output was written and every source file remains unchanged.' + : 'Converted and verified: the derived output passed duration and chapter checks; every source file remains unchanged.'} + + +); + +const prepareMutation = (receipt: PrepareReceipt) => ( + <> + + + {receipt.applied + ? 'Applied: the derived output was probed after preparation; the source file remains unchanged.' + : 'Plan only: no output was written and the source file remains unchanged.'} + + +); + +export const MutationReceipt = ({ receipt }: MutationReceiptProps) => { + switch (receipt.operation) { + case 'apply-metadata': + return metadataMutation(receipt); + case 'apply-chapters': + return chapterMutation(receipt); + case 'convert': + return conversionMutation(receipt); + case 'prepare': + return prepareMutation(receipt); + default: { + const unhandled: never = receipt; + throw new Error(`Unhandled mutation receipt: ${JSON.stringify(unhandled)}`); + } + } +}; diff --git a/examples/audiobook-curator/src/components/primitives.tsx b/examples/audiobook-curator/src/components/primitives.tsx new file mode 100644 index 000000000..fb8fad215 --- /dev/null +++ b/examples/audiobook-curator/src/components/primitives.tsx @@ -0,0 +1,37 @@ +import { Agent } from '@agent-bundle/runtime'; +import React from 'react'; + +export interface Field { + readonly label: string; + readonly value: boolean | number | string; +} + +export interface DataListProps { + readonly fields: readonly Field[]; +} + +export const DataList = ({ fields }: DataListProps) => ( + + {fields.map(({ label, value }) => `- **${label}:** ${String(value).replaceAll(/\s*\n\s*/gu, ' ')}`).join('\n')} + +); + +export interface CalloutProps { + readonly children: string; + readonly tone: 'error' | 'review' | 'warning'; +} + +export const Callout = ({ children, tone }: CalloutProps) => { + switch (tone) { + case 'review': + return {children}; + case 'warning': + return {`Warning: ${children}`}; + case 'error': + return {children}; + default: { + const unhandled: never = tone; + throw new Error(`Unhandled callout tone: ${String(unhandled)}`); + } + } +}; diff --git a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx index e7b232fce..3d9bf2cb5 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx @@ -1,7 +1,11 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { ChapterOutline } from '../../../components/chapter-outline.js'; +import { CuratorDocument } from '../../../components/curator-document.js'; +import { IntegrityReport } from '../../../components/integrity-report.js'; +import { MutationReceipt } from '../../../components/mutation-receipt.js'; +import type { ChapterReceipt } from '../../../media-mutation.js'; import { defaultMediaMutationOperations, mediaMutationOperations } from '../../../operations/media-mutation.js'; const operation = mediaMutationOperations(defaultMediaMutationOperations).applyChapters; @@ -11,6 +15,15 @@ export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { - const receipt = await operation.handler(input, { signal }) as CuratorReceipt; - return ; + const receipt = await operation.handler(input, { signal }) as ChapterReceipt; + const headline = receipt.status === 'planned' + ? `Planned ${receipt.chapters.length} chapters for ${receipt.file}.` + : `Applied and verified ${receipt.chapters.length} chapters for ${receipt.file}.`; + return ( + + + + + + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx index b2a406ce1..c6cae65a0 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx @@ -1,7 +1,10 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { CuratorDocument } from '../../../components/curator-document.js'; +import { IntegrityReport } from '../../../components/integrity-report.js'; +import { MutationReceipt } from '../../../components/mutation-receipt.js'; +import type { MetadataReceipt } from '../../../media-mutation.js'; import { defaultMediaMutationOperations, mediaMutationOperations } from '../../../operations/media-mutation.js'; const operation = mediaMutationOperations(defaultMediaMutationOperations).applyMetadata; @@ -11,6 +14,14 @@ export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { - const receipt = await operation.handler(input, { signal }) as CuratorReceipt; - return ; + const receipt = await operation.handler(input, { signal }) as MetadataReceipt; + const headline = receipt.status === 'planned' + ? `Planned metadata for ${receipt.file}; audio remains unchanged.` + : `Applied and verified metadata for ${receipt.file}.`; + return ( + + + + + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx b/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx index f245d0ad8..1da09d5cd 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx @@ -1,7 +1,10 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { ChapterOutline } from '../../../components/chapter-outline.js'; +import { CuratorDocument } from '../../../components/curator-document.js'; +import { IntegrityReport } from '../../../components/integrity-report.js'; +import type { IntegrityAuditReceipt } from '../../../integrity-audit.js'; import { defaultOutputOperations, outputOperations } from '../../../operations/output.js'; const operation = outputOperations(defaultOutputOperations).audit; @@ -11,6 +14,14 @@ export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { - const receipt = await operation.handler(input, { signal }) as CuratorReceipt; - return ; + const receipt = await operation.handler(input, { signal }) as IntegrityAuditReceipt; + return ( + + + + + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx b/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx index 973668882..33ca08578 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx @@ -1,7 +1,9 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { CuratorDocument } from '../../../components/curator-document.js'; +import { LibraryShelf } from '../../../components/library-shelf.js'; +import type { LibraryAuditReceipt } from '../../../library.js'; import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; const operation = discoveryOperations(defaultDiscoveryOperations).libraryAudit; @@ -11,6 +13,13 @@ export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { - const receipt = await operation.handler(input, { signal }) as CuratorReceipt; - return ; + const receipt = await operation.handler(input, { signal }) as LibraryAuditReceipt; + return ( + + + + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/cache_audible_edition.tsx b/examples/audiobook-curator/src/mcp/curator/tools/cache_audible_edition.tsx index 041c95683..5b90eff00 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/cache_audible_edition.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/cache_audible_edition.tsx @@ -1,7 +1,9 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import type { AudibleCacheReceipt } from '../../../audible.js'; +import { CuratorDocument } from '../../../components/curator-document.js'; +import { Callout, DataList } from '../../../components/primitives.js'; import { defaultAudibleOperations, audibleOperations } from '../../../operations/audible.js'; const operation = audibleOperations(defaultAudibleOperations).audibleCache; @@ -11,6 +13,19 @@ export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { - const receipt = await operation.handler(input, { signal }) as CuratorReceipt; - return ; + const receipt = await operation.handler(input, { signal }) as AudibleCacheReceipt; + const headline = `Cached Audible ${receipt.region}/${receipt.asin} product evidence${receipt.chapters === undefined ? ' without chapter metadata' : ' with chapter metadata'}.`; + return ( + + + {receipt.chapterError === undefined + ? null + : {`Chapter metadata was not cached: ${receipt.chapterError}`}} + + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/convert_audiobook.tsx b/examples/audiobook-curator/src/mcp/curator/tools/convert_audiobook.tsx index e2895df81..9615be027 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/convert_audiobook.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/convert_audiobook.tsx @@ -1,7 +1,11 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { ChapterOutline } from '../../../components/chapter-outline.js'; +import { CuratorDocument } from '../../../components/curator-document.js'; +import { IntegrityReport } from '../../../components/integrity-report.js'; +import { MutationReceipt } from '../../../components/mutation-receipt.js'; +import type { ConvertReceipt } from '../../../conversion.js'; import { defaultOutputOperations, outputOperations } from '../../../operations/output.js'; const operation = outputOperations(defaultOutputOperations).convert; @@ -11,6 +15,15 @@ export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { - const receipt = await operation.handler(input, { signal }) as CuratorReceipt; - return ; + const receipt = await operation.handler(input, { signal }) as ConvertReceipt; + const headline = receipt.status === 'planned' + ? `Planned ${receipt.audioMode} output at ${receipt.output}; sources remain unchanged.` + : `Converted and verified ${receipt.output}; sources remain unchanged.`; + return ( + + + + + + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/identify_audible_sample.tsx b/examples/audiobook-curator/src/mcp/curator/tools/identify_audible_sample.tsx index 0051e83f6..bec5e66a2 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/identify_audible_sample.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/identify_audible_sample.tsx @@ -1,7 +1,10 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { CandidateRanking } from '../../../components/candidate-ranking.js'; +import { CuratorDocument } from '../../../components/curator-document.js'; +import { EvidenceTrail } from '../../../components/evidence-trail.js'; +import type { AcousticIdentifyReceipt } from '../../../evidence.js'; import { defaultEvidenceOperations, evidenceOperations } from '../../../operations/evidence.js'; const operation = evidenceOperations(defaultEvidenceOperations).acousticIdentify; @@ -11,6 +14,14 @@ export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { - const receipt = await operation.handler(input, { signal }) as CuratorReceipt; - return ; + const receipt = await operation.handler(input, { signal }) as AcousticIdentifyReceipt; + const headline = receipt.verifiedRecording + ? `Identified an acoustic match after ${receipt.attempts.length} candidate attempts.` + : `No acoustic match after ${receipt.attempts.length} candidate attempts.`; + return ( + + + + + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/inspect_sources.tsx b/examples/audiobook-curator/src/mcp/curator/tools/inspect_sources.tsx index 998feaa92..c55ac863c 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/inspect_sources.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/inspect_sources.tsx @@ -1,7 +1,9 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { CuratorDocument } from '../../../components/curator-document.js'; +import { LibraryShelf } from '../../../components/library-shelf.js'; +import type { InspectionReceipt } from '../../../curator-core.js'; import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; const operation = discoveryOperations(defaultDiscoveryOperations).inspect; @@ -11,6 +13,13 @@ export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { - const receipt = await operation.handler(input, { signal }) as CuratorReceipt; - return ; + const receipt = await operation.handler(input, { signal }) as InspectionReceipt; + return ( + + + + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx b/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx index 31d8dafd1..da62a737e 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx @@ -2,7 +2,9 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { CuratorDocument } from '../../../components/curator-document.js'; +import { LibraryShelf } from '../../../components/library-shelf.js'; +import type { InventoryReceipt } from '../../../library.js'; import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; const operation = discoveryOperations(defaultDiscoveryOperations).inventory; @@ -16,6 +18,13 @@ export const inputSchema = z.object({ export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { - const receipt = await operation.handler(input, { signal }) as CuratorReceipt; - return ; + const receipt = await operation.handler(input, { signal }) as InventoryReceipt; + return ( + + + + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/prepare_audiobook.tsx b/examples/audiobook-curator/src/mcp/curator/tools/prepare_audiobook.tsx index 50d7fa99a..a649ffa9f 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/prepare_audiobook.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/prepare_audiobook.tsx @@ -1,7 +1,9 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { CuratorDocument } from '../../../components/curator-document.js'; +import { MutationReceipt } from '../../../components/mutation-receipt.js'; +import type { PrepareReceipt } from '../../../curator-core.js'; import { defaultOutputOperations, outputOperations } from '../../../operations/output.js'; const operation = outputOperations(defaultOutputOperations).prepare; @@ -11,6 +13,13 @@ export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { - const receipt = await operation.handler(input, { signal }) as CuratorReceipt; - return ; + const receipt = await operation.handler(input, { signal }) as PrepareReceipt; + const headline = receipt.applied + ? `Prepared audiobook output at ${receipt.output}.` + : `Planned audiobook output at ${receipt.output}; no media was changed.`; + return ( + + + + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx b/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx index 99b324d14..e065fe0af 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx @@ -1,7 +1,9 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import type { AudibleSearchReceipt } from '../../../audible.js'; +import { CandidateRanking } from '../../../components/candidate-ranking.js'; +import { CuratorDocument } from '../../../components/curator-document.js'; import { defaultAudibleOperations, audibleOperations } from '../../../operations/audible.js'; const operation = audibleOperations(defaultAudibleOperations).audibleSearch; @@ -11,6 +13,13 @@ export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { - const receipt = await operation.handler(input, { signal }) as CuratorReceipt; - return ; + const receipt = await operation.handler(input, { signal }) as AudibleSearchReceipt; + return ( + + + + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx b/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx index bc892de11..c8f2b04a9 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx @@ -1,7 +1,9 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import type { AudibleSelectionReceipt } from '../../../audible.js'; +import { CandidateRanking } from '../../../components/candidate-ranking.js'; +import { CuratorDocument } from '../../../components/curator-document.js'; import { defaultAudibleOperations, audibleOperations } from '../../../operations/audible.js'; const operation = audibleOperations(defaultAudibleOperations).audibleSelect; @@ -11,6 +13,13 @@ export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { - const receipt = await operation.handler(input, { signal }) as CuratorReceipt; - return ; + const receipt = await operation.handler(input, { signal }) as AudibleSelectionReceipt; + return ( + + + + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/select_sources.tsx b/examples/audiobook-curator/src/mcp/curator/tools/select_sources.tsx index 0f1cb7e79..0eb594214 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/select_sources.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/select_sources.tsx @@ -1,7 +1,9 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { CuratorDocument } from '../../../components/curator-document.js'; +import { LibraryShelf } from '../../../components/library-shelf.js'; +import type { SelectionReceipt } from '../../../library.js'; import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; const operation = discoveryOperations(defaultDiscoveryOperations).select; @@ -11,6 +13,14 @@ export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { - const receipt = await operation.handler(input, { signal }) as CuratorReceipt; - return ; + const receipt = await operation.handler(input, { signal }) as SelectionReceipt; + const reviewCount = receipt.selections.filter((selection) => selection.reviewRequired).length; + return ( + + + + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/verify_audible_sample.tsx b/examples/audiobook-curator/src/mcp/curator/tools/verify_audible_sample.tsx index fbc07f1e5..78818e01e 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/verify_audible_sample.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/verify_audible_sample.tsx @@ -1,7 +1,9 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { CuratorDocument } from '../../../components/curator-document.js'; +import { EvidenceTrail } from '../../../components/evidence-trail.js'; +import type { AcousticReceipt } from '../../../evidence.js'; import { defaultEvidenceOperations, evidenceOperations } from '../../../operations/evidence.js'; const operation = evidenceOperations(defaultEvidenceOperations).acousticVerify; @@ -11,6 +13,13 @@ export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { - const receipt = await operation.handler(input, { signal }) as CuratorReceipt; - return ; + const receipt = await operation.handler(input, { signal }) as AcousticReceipt; + const headline = receipt.verifiedRecording + ? `Audiolocate matched Audible ${receipt.asin} to the local recording.` + : `Audiolocate did not match Audible ${receipt.asin}; review is required.`; + return ( + + + + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/verify_with_whisper.tsx b/examples/audiobook-curator/src/mcp/curator/tools/verify_with_whisper.tsx index feddd8788..e71608b62 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/verify_with_whisper.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/verify_with_whisper.tsx @@ -1,7 +1,9 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { CuratorDocument } from '../../../components/curator-document.js'; +import { EvidenceTrail } from '../../../components/evidence-trail.js'; +import type { WhisperReceipt } from '../../../evidence.js'; import { defaultEvidenceOperations, evidenceOperations } from '../../../operations/evidence.js'; const operation = evidenceOperations(defaultEvidenceOperations).whisperVerify; @@ -11,6 +13,13 @@ export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { - const receipt = await operation.handler(input, { signal }) as CuratorReceipt; - return ; + const receipt = await operation.handler(input, { signal }) as WhisperReceipt; + return ( + + + + ); } diff --git a/examples/audiobook-curator/src/result.tsx b/examples/audiobook-curator/src/result.tsx deleted file mode 100644 index 63f674916..000000000 --- a/examples/audiobook-curator/src/result.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/** Shared Agent Document rendering for filesystem tool routes. */ -import { Agent, type JsonValue } from '@agent-bundle/runtime'; -import React from 'react'; - -import type { AudibleCacheReceipt, AudibleSearchReceipt, AudibleSelectionReceipt } from './audible.ts'; -import type { InspectionReceipt, PrepareReceipt } from './curator-core.ts'; -import type { IntegrityAuditReceipt } from './integrity-audit.ts'; -import type { ConvertReceipt } from './conversion.ts'; -import type { InventoryReceipt, LibraryAuditReceipt, SelectionReceipt } from './library.ts'; -import type { ChapterReceipt, MetadataReceipt } from './media-mutation.ts'; -import type { AcousticIdentifyReceipt, AcousticReceipt, WhisperReceipt } from './evidence.ts'; - -export type CuratorReceipt = - | AcousticIdentifyReceipt - | AcousticReceipt - | AudibleCacheReceipt - | AudibleSearchReceipt - | AudibleSelectionReceipt - | ChapterReceipt - | MetadataReceipt - | WhisperReceipt - | IntegrityAuditReceipt - | ConvertReceipt - | InspectionReceipt - | InventoryReceipt - | LibraryAuditReceipt - | PrepareReceipt - | SelectionReceipt; - -const summary = (receipt: CuratorReceipt): string => { - switch (receipt.operation) { - case 'audiolocate': - return receipt.verifiedRecording ? `Audiolocate matched Audible ${receipt.asin} to the local recording.` : `Audiolocate did not match Audible ${receipt.asin}; review is required.`; - case 'acoustic-identify': - return receipt.verifiedRecording ? `Identified an acoustic match after ${receipt.attempts.length} candidate attempts.` : `No acoustic match after ${receipt.attempts.length} candidate attempts.`; - case 'whisper-identity': - return `Collected ${receipt.usableWindows} usable transcript windows; human identity review is required.`; - case 'audible-search': - return `Ranked ${receipt.candidates.length} Audible candidates across reviewed regions; human selection is required.`; - case 'audible-select': - return `Recorded human-reviewed Audible candidate ${receipt.candidateNumber}.`; - case 'audible-cache': - return `Cached Audible ${receipt.region}/${receipt.asin} product evidence${receipt.chapters === undefined ? ' without chapter metadata' : ' with chapter metadata'}.`; - case 'apply-metadata': - return receipt.status === 'planned' ? `Planned metadata for ${receipt.file}; audio remains unchanged.` : `Applied and verified metadata for ${receipt.file}.`; - case 'apply-chapters': - return receipt.status === 'planned' ? `Planned ${receipt.chapters.length} chapters for ${receipt.file}.` : `Applied and verified ${receipt.chapters.length} chapters for ${receipt.file}.`; - case 'inspect': - return `Inspected ${receipt.files.length} audio files (${receipt.totalBytes} bytes).`; - case 'prepare': - return receipt.applied - ? `Prepared audiobook output at ${receipt.output}.` - : `Planned audiobook output at ${receipt.output}; no media was changed.`; - case 'audit': - return `Audited ${receipt.bytes} bytes with SHA-256 ${receipt.sha256}; status is ${receipt.status}.`; - case 'inventory': - return `Inventoried ${receipt.summary.files} media files with ${receipt.summary.errors} retained errors.`; - case 'library-audit': - return `Audited ${receipt.summary.files} library media files and found ${receipt.duplicateCandidates.length} duplicate candidate groups.`; - case 'quality-selection': - return `Selected ${receipt.selections.length} source groups; ${receipt.selections.filter((selection) => selection.reviewRequired).length} require review.`; - case 'convert': - return receipt.status === 'planned' - ? `Planned ${receipt.audioMode} output at ${receipt.output}; sources remain unchanged.` - : `Converted and verified ${receipt.output}; sources remain unchanged.`; - default: { - const unhandled: never = receipt; - throw new Error(`Unhandled receipt operation: ${JSON.stringify(unhandled)}`); - } - } -}; - -export const CuratorResult = ({ receipt }: { readonly receipt: CuratorReceipt }) => ( - - {summary(receipt)} - -); diff --git a/examples/audiobook-curator/tests/route-unit/routes.test.ts b/examples/audiobook-curator/tests/route-unit/routes.test.ts index 4812eef5b..c83747e3c 100644 --- a/examples/audiobook-curator/tests/route-unit/routes.test.ts +++ b/examples/audiobook-curator/tests/route-unit/routes.test.ts @@ -38,6 +38,78 @@ it('renders the curation prompt route into a final Agent Document', async () => expect(rendered.provenance).toMatchObject({ proofLevel: 'route-unit', routeId: 'prompt:curator/curate' }); }); +it('renders the composed library-audit tool document with its canonical receipt', async () => { + const directory = await mkdtemp(join(tmpdir(), 'curator-route-unit-tool-audit-')); + try { + const sources = join(directory, 'library'); + await mkdir(sources, { recursive: true }); + const rendered = await renderRoute('tool:curator/audit_library', { + input: { concurrency: 1, sources: [sources] }, + }); + const receipt = rendered.document.value as { + readonly duplicateCandidates: readonly unknown[]; + readonly operation: string; + readonly summary: { readonly files: number }; + }; + + expectDocument(rendered) + .toHaveStatus('success') + .toContainText('Audited 0 library media files') + .toContainMarkdown('**Files:** 0') + .toContainContext('review candidates') + .toHaveValue(receipt); + expect(receipt).toMatchObject({ + duplicateCandidates: [], + operation: 'library-audit', + summary: { files: 0 }, + }); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +it('renders composed inspection and inventory tool documents with unchanged values', async () => { + const directory = await mkdtemp(join(tmpdir(), 'curator-route-unit-discovery-')); + try { + const sources = join(directory, 'library'); + await mkdir(sources, { recursive: true }); + const inspected = await renderRoute('tool:curator/inspect_sources', { + input: { root: sources }, + }); + const inventoried = await renderRoute('tool:curator/inventory_sources', { + input: { source: sources }, + }); + const inventoryReceipt = inventoried.document.value as { + readonly files: readonly unknown[]; + readonly operation: string; + readonly summary: { readonly files: number }; + }; + + expectDocument(inspected) + .toHaveStatus('success') + .toContainText('Inspected 0 audio files') + .toContainMarkdown('**Files:** 0') + .toHaveValue({ + files: [], + operation: 'inspect', + root: sources, + totalBytes: 0, + }); + expectDocument(inventoried) + .toHaveStatus('success') + .toContainText('Inventoried 0 media files') + .toContainMarkdown('**Files:** 0') + .toHaveValue(inventoryReceipt); + expect(inventoryReceipt).toMatchObject({ + files: [], + operation: 'inventory', + summary: { files: 0 }, + }); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + it('renders the library-audit CLI route with in-flight progress and the canonical receipt (#102 stage 3)', async () => { const directory = await mkdtemp(join(tmpdir(), 'curator-route-unit-audit-')); try { From a5ab32ce76b0e3c078958fed59c71488f4548e6e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 20:32:39 +0000 Subject: [PATCH 2/6] feat(examples/audiobook-curator): add a library request provider and compose the catalog and curate routes (stage 2a) --- .../src/mcp/curator/prompts/curate.tsx | 22 ++++- .../src/mcp/curator/resources/catalog.tsx | 69 +++++++++++++- .../src/providers/library.ts | 56 ++++++++++++ .../tests/route-unit/context.test.ts | 89 +++++++++++++++++++ .../tests/route-unit/routes.test.ts | 4 +- 5 files changed, 230 insertions(+), 10 deletions(-) create mode 100644 examples/audiobook-curator/src/providers/library.ts create mode 100644 examples/audiobook-curator/tests/route-unit/context.test.ts diff --git a/examples/audiobook-curator/src/mcp/curator/prompts/curate.tsx b/examples/audiobook-curator/src/mcp/curator/prompts/curate.tsx index 3b3c295c6..9f9b39de7 100644 --- a/examples/audiobook-curator/src/mcp/curator/prompts/curate.tsx +++ b/examples/audiobook-curator/src/mcp/curator/prompts/curate.tsx @@ -1,8 +1,10 @@ -import React from 'react'; -import type { PromptConfig, ToolRouteProps } from 'agent-bundle'; import { Agent, type JsonValue } from '@agent-bundle/runtime'; +import type { PromptConfig, ToolRouteProps } from 'agent-bundle'; +import React from 'react'; import { z } from 'zod'; +import { Callout, DataList } from '../../../components/primitives.tsx'; + export const config = { description: 'Start an evidence-first audiobook curation review.', } satisfies PromptConfig; @@ -17,13 +19,25 @@ export const resultSchema = z.object({ export default async function Curate({ input }: ToolRouteProps) { const result = { messages: [{ - content: { text: `Inspect ${input.root}, retain evidence, and require review before mutation.`, type: 'text' as const }, + content: { + text: `Inspect ${input.root} through discover, identify, curate, and verify. Retain evidence and require review before any mutation.`, + type: 'text' as const, + }, role: 'user' as const, }], }; + return ( - Evidence-first curation prompt ready. + Curation review prepared. + + + Evidence first: retain source observations and identification evidence before proposing mutations. + ); } diff --git a/examples/audiobook-curator/src/mcp/curator/resources/catalog.tsx b/examples/audiobook-curator/src/mcp/curator/resources/catalog.tsx index ae3861267..4c81eeace 100644 --- a/examples/audiobook-curator/src/mcp/curator/resources/catalog.tsx +++ b/examples/audiobook-curator/src/mcp/curator/resources/catalog.tsx @@ -1,8 +1,11 @@ -import React from 'react'; +import { Agent, agent, type JsonValue } from '@agent-bundle/runtime'; import type { ResourceConfig, ToolRouteProps } from 'agent-bundle'; -import { Agent, type JsonValue } from '@agent-bundle/runtime'; +import React from 'react'; import { z } from 'zod'; +import { Callout, DataList } from '../../../components/primitives.tsx'; +import type { LibraryContext } from '../../../providers/library.ts'; + export const config = { description: 'Read the audiobook curator workflow catalog.', mimeType: 'application/json', @@ -13,17 +16,75 @@ export const resultSchema = z.object({ contents: z.array(z.object({ mimeType: z.literal('application/json'), text: z.string(), uri: z.string() }).strict()), }).strict(); +const workflowStages = ['discover', 'identify', 'curate', 'verify'] as const; + +const isRecord = (value: unknown): value is Readonly> => + value !== null && typeof value === 'object'; + +const isToolProbe = (value: unknown): value is { readonly available: boolean; readonly version?: string } => + isRecord(value) + && typeof value.available === 'boolean' + && (value.version === undefined || typeof value.version === 'string'); + +const isLibraryContext = (value: unknown): value is LibraryContext => { + if (!isRecord(value) || !isRecord(value.tooling)) return false; + if (!isToolProbe(value.tooling.ffmpeg) || !isToolProbe(value.tooling.ffprobe)) return false; + if (typeof value.probedAt !== 'string' || !Array.isArray(value.stages)) return false; + return value.stages.length > 0 && value.stages.every((stage) => typeof stage === 'string' && stage !== ''); +}; + export default async function Catalog({ input }: ToolRouteProps) { + const request = await agent(); + const library = isLibraryContext(request.providers.library) + ? request.providers.library + : undefined; + const stages = library?.stages ?? workflowStages; + const tooling = library?.tooling ?? { + ffmpeg: { available: false }, + ffprobe: { available: false }, + }; + const catalog = library ?? { + context: { + available: false, + reason: 'Library request context is missing or malformed.', + }, + stages, + tooling, + }; const result = { contents: [{ mimeType: 'application/json' as const, - text: JSON.stringify({ stages: ['discover', 'identify', 'curate', 'verify'] }), + text: JSON.stringify(catalog), uri: input.uri, }], }; + return ( - Audiobook curator catalog ready. + + {library === undefined + ? 'Audiobook curator catalog unavailable.' + : 'Audiobook curator catalog ready.'} + + + {library === undefined + ? ( + + Library request context is missing or malformed; tooling availability could not be probed. + + ) + : ( + + Tooling availability reflects a request-time probe; unavailable tools are not assumed. + + )} ); } diff --git a/examples/audiobook-curator/src/providers/library.ts b/examples/audiobook-curator/src/providers/library.ts new file mode 100644 index 000000000..ce78bfa9b --- /dev/null +++ b/examples/audiobook-curator/src/providers/library.ts @@ -0,0 +1,56 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +export interface LibraryContext { + readonly tooling: { + readonly ffmpeg: { + readonly available: boolean; + readonly version?: string; + }; + readonly ffprobe: { + readonly available: boolean; + readonly version?: string; + }; + }; + readonly stages: readonly string[]; + readonly probedAt: string; +} + +interface ProviderContext { + readonly invocation: unknown; + readonly signal: AbortSignal; +} + +interface ToolProbe { + readonly available: boolean; + readonly version?: string; +} + +const execFileAsync = promisify(execFile); + +const probeTool = async (tool: 'ffmpeg' | 'ffprobe', signal: AbortSignal): Promise => { + try { + const result = await execFileAsync(tool, ['-version'], { encoding: 'utf8', signal }); + const version = result.stdout.trim().split(/\r?\n/u)[0]; + return version === undefined || version === '' + ? { available: true } + : { available: true, version }; + } catch { + return { available: false }; + } +}; + +export default async function libraryProvider( + { signal }: ProviderContext, +): Promise { + const [ffmpeg, ffprobe] = await Promise.all([ + probeTool('ffmpeg', signal), + probeTool('ffprobe', signal), + ]); + + return { + probedAt: new Date().toISOString(), + stages: ['discover', 'identify', 'curate', 'verify'], + tooling: { ffmpeg, ffprobe }, + }; +} diff --git a/examples/audiobook-curator/tests/route-unit/context.test.ts b/examples/audiobook-curator/tests/route-unit/context.test.ts new file mode 100644 index 000000000..af9abfeaf --- /dev/null +++ b/examples/audiobook-curator/tests/route-unit/context.test.ts @@ -0,0 +1,89 @@ +import { expect, it } from '@rstest/core'; +import { expectDocument, renderRoute, testManifest } from 'agent-bundle/test'; + +import type { LibraryContext } from '../../src/providers/library.ts'; + +const manifest = testManifest(); + +it('renders the catalog from injected library context with its contents envelope', async () => { + expect(Object.keys(manifest.routes)).toContain('resource:curator/catalog'); + const library = { + probedAt: '2026-09-02T18:00:00.000Z', + stages: ['discover', 'identify', 'curate', 'verify'], + tooling: { + ffmpeg: { available: true, version: 'ffmpeg version 8.0' }, + ffprobe: { available: false }, + }, + } satisfies LibraryContext; + const uri = 'audiobook-curator://catalog'; + const rendered = await renderRoute('resource:curator/catalog', { + context: { providers: { library } }, + input: { uri }, + }); + const value = { + contents: [{ + mimeType: 'application/json', + text: JSON.stringify(library), + uri, + }], + }; + + expectDocument(rendered) + .toHaveStatus('success') + .toContainText('Audiobook curator catalog ready.') + .toContainMarkdown('**ffmpeg available:** true') + .toContainMarkdown('**ffprobe available:** false') + .toContainContext('request-time probe') + .toHaveValue(value); +}); + +it('renders an honest degraded catalog when library context is absent', async () => { + const rendered = await renderRoute('resource:curator/catalog', { + input: { uri: 'audiobook-curator://catalog' }, + }); + const value = rendered.document.value as { + readonly contents: readonly [{ readonly text: string }]; + }; + const catalog = JSON.parse(value.contents[0].text) as { + readonly context: { readonly available: boolean }; + readonly tooling: { + readonly ffmpeg: { readonly available: boolean }; + readonly ffprobe: { readonly available: boolean }; + }; + }; + + expectDocument(rendered) + .toHaveStatus('success') + .toContainText('Audiobook curator catalog unavailable.') + .toContainMarkdown('**ffmpeg available:** false') + .toContainMarkdown('**ffprobe available:** false') + .toContainContext('Library request context is missing or malformed'); + expect(catalog).toMatchObject({ + context: { available: false }, + tooling: { + ffmpeg: { available: false }, + ffprobe: { available: false }, + }, + }); +}); + +it('renders the composed curation prompt with its messages envelope', async () => { + const rendered = await renderRoute('prompt:curator/curate', { + input: { root: '/library' }, + }); + + expectDocument(rendered) + .toHaveStatus('success') + .toContainText('Curation review prepared.') + .toContainMarkdown('**Root:** /library') + .toContainContext('Evidence first') + .toHaveValue({ + messages: [{ + content: { + text: 'Inspect /library through discover, identify, curate, and verify. Retain evidence and require review before any mutation.', + type: 'text', + }, + role: 'user', + }], + }); +}); diff --git a/examples/audiobook-curator/tests/route-unit/routes.test.ts b/examples/audiobook-curator/tests/route-unit/routes.test.ts index c83747e3c..18a4e5e1c 100644 --- a/examples/audiobook-curator/tests/route-unit/routes.test.ts +++ b/examples/audiobook-curator/tests/route-unit/routes.test.ts @@ -25,11 +25,11 @@ it('renders the curation prompt route into a final Agent Document', async () => expectDocument(rendered) .toHaveStatus('success') - .toContainText('Evidence-first curation prompt ready.') + .toContainText('Curation review prepared.') .toHaveValue({ messages: [{ content: { - text: 'Inspect /library, retain evidence, and require review before mutation.', + text: 'Inspect /library through discover, identify, curate, and verify. Retain evidence and require review before any mutation.', type: 'text', }, role: 'user', From 454ddaf62cf57f67dcd130772388bbf77d46c6c0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 20:32:41 +0000 Subject: [PATCH 3/6] feat(examples/audiobook-curator): persist a workspace-durable curation shelf with a review tool and CLI command (stage 2b) --- examples/audiobook-curator/src/cli/shelf.tsx | 41 +++++++ .../src/components/curation-shelf.tsx | 57 +++++++++ .../tools/apply_audiobook_chapters.tsx | 19 +++ .../tools/apply_audiobook_metadata.tsx | 19 +++ .../curator/tools/review_curation_shelf.tsx | 41 +++++++ .../curator/tools/select_audible_edition.tsx | 20 ++++ examples/audiobook-curator/src/state.ts | 77 ++++++++++++ .../tests/application.test.ts | 7 +- examples/audiobook-curator/tests/cli.test.ts | 3 +- .../tests/route-unit/state.test.ts | 111 ++++++++++++++++++ 10 files changed, 391 insertions(+), 4 deletions(-) create mode 100644 examples/audiobook-curator/src/cli/shelf.tsx create mode 100644 examples/audiobook-curator/src/components/curation-shelf.tsx create mode 100644 examples/audiobook-curator/src/mcp/curator/tools/review_curation_shelf.tsx create mode 100644 examples/audiobook-curator/src/state.ts create mode 100644 examples/audiobook-curator/tests/route-unit/state.test.ts diff --git a/examples/audiobook-curator/src/cli/shelf.tsx b/examples/audiobook-curator/src/cli/shelf.tsx new file mode 100644 index 000000000..a9418c150 --- /dev/null +++ b/examples/audiobook-curator/src/cli/shelf.tsx @@ -0,0 +1,41 @@ +import { Agent, agent, type JsonValue } from '@agent-bundle/runtime'; +import type { CliRouteConfig } from 'agent-bundle'; +import React from 'react'; +import { z } from 'zod'; + +import { CurationShelf, ShelfUnavailable } from '../components/curation-shelf.js'; +import { + CurationShelfStateSchema, + type CurationShelfState, +} from '../state.js'; + +const emptyShelf: CurationShelfState = { + mutations: [], + selections: [], +}; + +export const config = { + description: 'Show the persisted curation shelf.', +} satisfies CliRouteConfig; + +export const inputSchema = z.object({}).strict(); +export const resultSchema = CurationShelfStateSchema; + +export default async function Shelf() { + const state = (await agent()).state; + if (state === undefined) { + return ( + + Persisted curation shelf unavailable. + + + ); + } + const shelf = CurationShelfStateSchema.parse((await state.read()).state); + return ( + + Persisted curation shelf ready. + + + ); +} diff --git a/examples/audiobook-curator/src/components/curation-shelf.tsx b/examples/audiobook-curator/src/components/curation-shelf.tsx new file mode 100644 index 000000000..47f26a883 --- /dev/null +++ b/examples/audiobook-curator/src/components/curation-shelf.tsx @@ -0,0 +1,57 @@ +import { Agent } from '@agent-bundle/runtime'; +import React from 'react'; + +import type { CurationShelfState } from '../state.js'; +import { Callout, DataList } from './primitives.js'; + +export interface CurationShelfProps { + readonly state: CurationShelfState; +} + +export const CurationShelf = async ({ state }: CurationShelfProps) => ( + <> + ## Persisted curation shelf + {state.selections.length === 0 && state.mutations.length === 0 + ? The persisted curation shelf is empty. + : null} + {state.selections.length === 0 + ? null + : ( + <> + ### Selected Audible editions + {state.selections.map((selection) => ( + + {`#### ${selection.title}`} + + + ))} + + )} + {state.mutations.length === 0 + ? null + : ( + <> + ### Media mutations + {state.mutations.map((mutation) => ( + + {`#### ${mutation.file}`} + + + ))} + + )} + +); + +export const ShelfUnavailable = () => ( + State is not mounted on this invocation surface. +); diff --git a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx index 3d9bf2cb5..0b88b9e3f 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx @@ -1,12 +1,15 @@ +import { agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import { ChapterOutline } from '../../../components/chapter-outline.js'; import { CuratorDocument } from '../../../components/curator-document.js'; +import { CurationShelf, ShelfUnavailable } from '../../../components/curation-shelf.js'; import { IntegrityReport } from '../../../components/integrity-report.js'; import { MutationReceipt } from '../../../components/mutation-receipt.js'; import type { ChapterReceipt } from '../../../media-mutation.js'; import { defaultMediaMutationOperations, mediaMutationOperations } from '../../../operations/media-mutation.js'; +import { CurationShelfStateSchema } from '../../../state.js'; const operation = mediaMutationOperations(defaultMediaMutationOperations).applyChapters; @@ -19,11 +22,27 @@ export default async function Route({ input, signal }: ToolRouteProps + : ( + + ); return ( + {shelf} ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx index c6cae65a0..8fcbff9d8 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx @@ -1,11 +1,14 @@ +import { agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import { CuratorDocument } from '../../../components/curator-document.js'; +import { CurationShelf, ShelfUnavailable } from '../../../components/curation-shelf.js'; import { IntegrityReport } from '../../../components/integrity-report.js'; import { MutationReceipt } from '../../../components/mutation-receipt.js'; import type { MetadataReceipt } from '../../../media-mutation.js'; import { defaultMediaMutationOperations, mediaMutationOperations } from '../../../operations/media-mutation.js'; +import { CurationShelfStateSchema } from '../../../state.js'; const operation = mediaMutationOperations(defaultMediaMutationOperations).applyMetadata; @@ -18,10 +21,26 @@ export default async function Route({ input, signal }: ToolRouteProps + : ( + + ); return ( + {shelf} ); } diff --git a/examples/audiobook-curator/src/mcp/curator/tools/review_curation_shelf.tsx b/examples/audiobook-curator/src/mcp/curator/tools/review_curation_shelf.tsx new file mode 100644 index 000000000..6193755f9 --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/tools/review_curation_shelf.tsx @@ -0,0 +1,41 @@ +import { Agent, agent, type JsonValue } from '@agent-bundle/runtime'; +import React from 'react'; +import { z } from 'zod'; + +import { CurationShelf, ShelfUnavailable } from '../../../components/curation-shelf.js'; +import { + CurationShelfStateSchema, + type CurationShelfState, +} from '../../../state.js'; + +const emptyShelf: CurationShelfState = { + mutations: [], + selections: [], +}; + +export const config = { + annotations: { readOnlyHint: true }, + description: 'Review the persisted curation shelf of selected Audible editions and media mutations.', +}; + +export const inputSchema = z.object({}).strict(); +export const resultSchema = CurationShelfStateSchema; + +export default async function ReviewCurationShelf() { + const state = (await agent()).state; + if (state === undefined) { + return ( + + Persisted curation shelf unavailable. + + + ); + } + const shelf = CurationShelfStateSchema.parse((await state.read()).state); + return ( + + Persisted curation shelf ready. + + + ); +} diff --git a/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx b/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx index c8f2b04a9..657f007f7 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx @@ -1,10 +1,13 @@ +import { agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import type { AudibleSelectionReceipt } from '../../../audible.js'; import { CandidateRanking } from '../../../components/candidate-ranking.js'; import { CuratorDocument } from '../../../components/curator-document.js'; +import { CurationShelf, ShelfUnavailable } from '../../../components/curation-shelf.js'; import { defaultAudibleOperations, audibleOperations } from '../../../operations/audible.js'; +import { CurationShelfStateSchema } from '../../../state.js'; const operation = audibleOperations(defaultAudibleOperations).audibleSelect; @@ -14,12 +17,29 @@ export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { const receipt = await operation.handler(input, { signal }) as AudibleSelectionReceipt; + const context = await agent(); + const shelf = context.state === undefined + ? + : ( + + ); return ( + {shelf} ); } diff --git a/examples/audiobook-curator/src/state.ts b/examples/audiobook-curator/src/state.ts new file mode 100644 index 000000000..f2d660cd5 --- /dev/null +++ b/examples/audiobook-curator/src/state.ts @@ -0,0 +1,77 @@ +import { defineState } from '@agent-bundle/runtime/state'; +import { z } from 'zod'; + +const shortText = z.string().max(1024); +const timestamp = z.string().max(64); + +export const ShelfSelectionSchema = z.object({ + asin: z.string().max(64), + candidateNumber: z.number().int().min(1).max(500), + region: z.string().max(16), + selectedAt: timestamp, + title: shortText, +}).strict(); + +export const ShelfMutationSchema = z.object({ + appliedAt: timestamp, + file: z.string().max(4096), + operation: z.enum(['apply-metadata', 'apply-chapters']), + status: z.enum(['applied-verified', 'planned']), +}).strict(); + +export const CurationShelfStateSchema = z.object({ + mutations: z.array(ShelfMutationSchema), + selections: z.array(ShelfSelectionSchema), +}).strict(); + +export type ShelfSelection = z.output; +export type ShelfMutation = z.output; +export type CurationShelfState = z.output; + +export const curationShelfEventSchemas = { + editionSelected: ShelfSelectionSchema, + mutationApplied: ShelfMutationSchema, + shelfCleared: z.object({}).strict(), +} as const; + +const initial: CurationShelfState = { + mutations: [], + selections: [], +}; + +export default defineState({ + events: curationShelfEventSchemas, + id: 'audiobook-curator/shelf', + initial, + lifetime: 'workspace-durable', + reduce: (state, event): CurationShelfState => { + switch (event.name) { + case 'editionSelected': + return { + ...state, + selections: [ + ...state.selections.filter((selection) => + selection.asin !== event.payload.asin || selection.region !== event.payload.region), + event.payload, + ], + }; + case 'mutationApplied': + return { + ...state, + mutations: [ + ...state.mutations.filter((mutation) => + mutation.file !== event.payload.file || mutation.operation !== event.payload.operation), + event.payload, + ], + }; + case 'shelfCleared': + return initial; + default: { + const unhandled: never = event; + throw new Error(`Unhandled curation shelf event: ${String(unhandled)}`); + } + } + }, + schema: CurationShelfStateSchema, + version: 1, +}); diff --git a/examples/audiobook-curator/tests/application.test.ts b/examples/audiobook-curator/tests/application.test.ts index 45510cb4f..e471dec11 100644 --- a/examples/audiobook-curator/tests/application.test.ts +++ b/examples/audiobook-curator/tests/application.test.ts @@ -18,6 +18,7 @@ const toolNames = [ 'inspect_sources', 'inventory_sources', 'prepare_audiobook', + 'review_curation_shelf', 'search_audible', 'select_audible_edition', 'select_sources', @@ -47,15 +48,15 @@ describe('audiobook curator filesystem application', () => { it('derives the complete routed CLI and projected MCP toolset', async () => { const graph = await compileRouteGraph(root, config); expect(graph.cli).toMatchObject({ mode: 'generated' }); - expect(graph.cli!.commands).toHaveLength(30); + expect(graph.cli!.commands).toHaveLength(32); const customCommands = graph.cli!.commands!.filter((command) => command.mcp === undefined); const projectedCommands = graph.cli!.commands!.filter((command) => command.mcp !== undefined); - expect(customCommands).toHaveLength(15); + expect(customCommands).toHaveLength(16); expect(projectedCommands.map((command) => command.path.join(' '))).toEqual( toolNames.map((tool) => `curator ${tool}`), ); expect(customCommands.filter((command) => command.rendered).map((command) => command.path.join(' '))) - .toEqual(['library-audit']); + .toEqual(['library-audit', 'shelf']); expect(projectedCommands.every((command) => command.rendered)).toBe(true); }); }); diff --git a/examples/audiobook-curator/tests/cli.test.ts b/examples/audiobook-curator/tests/cli.test.ts index c995e0bb8..a08336afe 100644 --- a/examples/audiobook-curator/tests/cli.test.ts +++ b/examples/audiobook-curator/tests/cli.test.ts @@ -50,9 +50,10 @@ describe('audiobook-curator routed CLI', () => { 'library-audit', 'prepare', 'select', + 'shelf', 'whisper-verify', ]); - expect(projectedCommands).toHaveLength(15); + expect(projectedCommands).toHaveLength(16); expect(projectedCommands.every((command) => command.path[0] === 'curator' && command.rendered)).toBe(true); diff --git a/examples/audiobook-curator/tests/route-unit/state.test.ts b/examples/audiobook-curator/tests/route-unit/state.test.ts new file mode 100644 index 000000000..1ecf9307b --- /dev/null +++ b/examples/audiobook-curator/tests/route-unit/state.test.ts @@ -0,0 +1,111 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; +import { + createAgentStateHandle, + createMemoryStateDriver, + defineState, +} from '@agent-bundle/runtime/state'; +import { expectDocument, renderRoute } from 'agent-bundle/test'; + +import * as ReviewCurationShelfRoute from '../../src/mcp/curator/tools/review_curation_shelf.js'; +import shelfStateDefinition from '../../src/state.js'; + +it('persists an Audible selection across tool renders with the same state handle', async () => { + const directory = await mkdtemp(join(tmpdir(), 'curator-route-unit-state-')); + const candidates = join(directory, 'candidates.json'); + const generatedAt = '2026-09-02T18:00:00.000Z'; + await writeFile(candidates, JSON.stringify({ + candidates: [{ + asin: 'B0CURATOR01', + authors: [{ name: 'Ada Author' }], + evidence: { + authorMatch: true, + languageMatch: true, + narratorMatch: true, + score: 100, + strictIdentityMatch: true, + titleMatch: true, + unabridged: true, + }, + narrators: [{ name: 'Nora Narrator' }], + region: 'us', + title: 'The Persisted Edition', + }], + errors: [], + exitCode: 0, + generatedAt, + humanReviewRequired: true, + mutation: false, + operation: 'audible-search', + query: { title: 'The Persisted Edition' }, + reviewNote: 'Choose the matching edition.', + })); + + const definition = defineState({ + ...shelfStateDefinition, + id: 'audiobook-curator/test-shelf', + lifetime: 'process', + }); + const driver = createMemoryStateDriver({ lifetime: 'process' }); + const store = await driver.open(definition); + const state = createAgentStateHandle(store); + + try { + const selected = await renderRoute('tool:curator/select_audible_edition', { + context: { + invocation: { id: 'state-test:select' }, + state, + }, + input: { candidate: 1, candidates }, + }); + + expectDocument(selected) + .toHaveStatus('success') + .toContainText('Recorded human-reviewed Audible candidate 1.') + .toContainMarkdown('The Persisted Edition') + .toContainMarkdown('B0CURATOR01'); + const receipt = selected.document.value as { readonly generatedAt: string }; + + const reviewed = await renderRoute('tool:curator/review_curation_shelf', { + context: { + invocation: { id: 'state-test:review' }, + state, + }, + input: {}, + }); + + expectDocument(reviewed) + .toHaveStatus('success') + .toContainMarkdown('The Persisted Edition') + .toContainMarkdown('B0CURATOR01') + .toHaveValue({ + mutations: [], + selections: [{ + asin: 'B0CURATOR01', + candidateNumber: 1, + region: 'us', + selectedAt: receipt.generatedAt, + title: 'The Persisted Edition', + }], + }); + } finally { + await driver.close(); + await rm(directory, { force: true, recursive: true }); + } +}); + +it('renders an honest unavailable shelf without mounted state', async () => { + const rendered = await renderRoute(ReviewCurationShelfRoute, { + input: {}, + kind: 'tool', + }); + + expectDocument(rendered) + .toHaveStatus('success') + .toContainText('Persisted curation shelf unavailable.') + .toContainContext('State is not mounted on this invocation surface.') + .toHaveValue({ mutations: [], selections: [] }); +}); From f84f19e0d1c7af86a9be7ddfc49e25fc54715154 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 20:46:08 +0000 Subject: [PATCH 4/6] feat(examples/audiobook-curator): stream library analysis behind Suspense with an MCP projector proof (stage 3a) --- .../src/cli/library-audit.tsx | 39 ++++---- .../src/components/library-analysis.tsx | 92 +++++++++++++++++++ .../src/mcp/curator/tools/audit_library.tsx | 33 ++++++- .../tests/route-unit/cli-dispatch.test.ts | 23 +++-- .../tests/route-unit/streaming.test.ts | 81 ++++++++++++++++ 5 files changed, 239 insertions(+), 29 deletions(-) create mode 100644 examples/audiobook-curator/src/components/library-analysis.tsx create mode 100644 examples/audiobook-curator/tests/route-unit/streaming.test.ts diff --git a/examples/audiobook-curator/src/cli/library-audit.tsx b/examples/audiobook-curator/src/cli/library-audit.tsx index 0d3f00e06..11d0e2b81 100644 --- a/examples/audiobook-curator/src/cli/library-audit.tsx +++ b/examples/audiobook-curator/src/cli/library-audit.tsx @@ -1,8 +1,11 @@ -import React from 'react'; +import React, { Suspense } from 'react'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; -import { Agent, agent, type JsonValue } from '@agent-bundle/runtime'; +import { Agent, agent } from '@agent-bundle/runtime'; import { z } from 'zod'; +import { CuratorDocument } from '../components/curator-document.js'; +import { LibraryAnalysis } from '../components/library-analysis.js'; +import { DataList } from '../components/primitives.js'; import type { LibraryAuditReceipt } from '../library.js'; import { defaultDiscoveryOperations, discoveryOperations } from '../operations/discovery.js'; @@ -41,20 +44,22 @@ export default async function LibraryAudit({ input, signal }: CliRouteProps - - {[ - `## Library audit`, - '', - `Audited **${String(summary.files)}** files (${String(summary.bytes)} bytes) across **${String(total)}** sources.`, - '', - `- metadata issues: **${String(issues)}**`, - `- duplicate candidates: **${String(receipt.duplicateCandidates.length)}**`, - `- multipart candidates: **${String(receipt.multipartCandidates.length)}**`, - '', - receipt.reviewNote, - ].join('\n')} - - + + ## Library audit + + }> + + + ); } diff --git a/examples/audiobook-curator/src/components/library-analysis.tsx b/examples/audiobook-curator/src/components/library-analysis.tsx new file mode 100644 index 000000000..0e5e441f4 --- /dev/null +++ b/examples/audiobook-curator/src/components/library-analysis.tsx @@ -0,0 +1,92 @@ +import { stat } from 'node:fs/promises'; + +import { Agent } from '@agent-bundle/runtime'; +import React from 'react'; + +import type { LibraryAuditReceipt } from '../library.ts'; +import { Callout, DataList } from './primitives.tsx'; + +export interface LibraryAnalysisProps { + readonly receipt: LibraryAuditReceipt; + readonly signal: AbortSignal; +} + +interface MeasuredFile { + readonly bytes?: number; + readonly error?: string; + readonly path: string; +} + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : 'File metadata is unavailable.'; + +const measureFiles = async ( + files: readonly string[], + signal: AbortSignal, +): Promise => Promise.all(files.map(async (path) => { + signal.throwIfAborted(); + try { + const metadata = await stat(path); + signal.throwIfAborted(); + return metadata.isFile() + ? { bytes: metadata.size, path } + : { error: 'Path is no longer a regular file.', path }; + } catch (error) { + signal.throwIfAborted(); + return { error: errorMessage(error), path }; + } +})); + +export const LibraryAnalysis = async ({ receipt, signal }: LibraryAnalysisProps) => { + const duplicateGroups = receipt.duplicateCandidates.slice(0, 10); + const measuredGroups = await Promise.all(duplicateGroups.map(async (group) => ({ + group, + measured: await measureFiles(group.files, signal), + }))); + signal.throwIfAborted(); + + return ( + <> + {measuredGroups.map(({ group, measured }) => { + const available = measured.flatMap((file) => file.bytes === undefined ? [] : [file.bytes]); + const unavailable = measured.filter((file) => file.error !== undefined); + const reclaimableBytes = available.length < 2 + ? 0 + : available.reduce((total, bytes) => total + bytes, 0) - Math.max(...available); + return ( + + {`### Duplicate analysis: ${group.identityKey}`} + + {unavailable.length > 0 + ? ( + + {`Could not measure ${String(unavailable.length)} candidate files: ${unavailable.map((file) => `${file.path} (${file.error})`).join(', ')}. Reclaimable bytes include only files that remain measurable.`} + + ) + : null} + + {`Duplicate candidate group ${group.identityKey}: ${group.files.join(', ')}. ${receipt.reviewNote}`} + + + ); + })} + {receipt.multipartCandidates.slice(0, 10).map((group) => ( + + {`Multipart candidate group ${group.identityKey}: ${group.files.map((file) => `part ${String(file.part)} ${file.path}`).join(', ')}. ${receipt.reviewNote}`} + + ))} + {receipt.duplicateCandidates.length === 0 && receipt.multipartCandidates.length === 0 + ? ( + <> + No duplicate or multipart candidate groups were found. + {receipt.reviewNote} + + ) + : null} + + ); +}; diff --git a/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx b/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx index 33ca08578..a73c4f347 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx @@ -1,8 +1,11 @@ -import React from 'react'; +import { Agent, agent } from '@agent-bundle/runtime'; +import React, { Suspense } from 'react'; import type { ToolRouteProps } from 'agent-bundle'; +import { AudiobookCard } from '../../../components/audiobook-card.js'; import { CuratorDocument } from '../../../components/curator-document.js'; -import { LibraryShelf } from '../../../components/library-shelf.js'; +import { LibraryAnalysis } from '../../../components/library-analysis.js'; +import { DataList } from '../../../components/primitives.js'; import type { LibraryAuditReceipt } from '../../../library.js'; import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; @@ -14,12 +17,36 @@ export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { const receipt = await operation.handler(input, { signal }) as LibraryAuditReceipt; + const context = await agent(); + await context.progress.report({ + completed: 0, + message: 'Analyzing duplicate and multipart groups', + total: 1, + }); return ( - + + {receipt.files.slice(0, 20).map((file) => ( + + ))} + {receipt.files.length > 20 + ? {`_+${String(receipt.files.length - 20)} more files retained in the structured receipt._`} + : null} + }> + + ); } diff --git a/examples/audiobook-curator/tests/route-unit/cli-dispatch.test.ts b/examples/audiobook-curator/tests/route-unit/cli-dispatch.test.ts index 72044edbf..92a16e28a 100644 --- a/examples/audiobook-curator/tests/route-unit/cli-dispatch.test.ts +++ b/examples/audiobook-curator/tests/route-unit/cli-dispatch.test.ts @@ -9,7 +9,7 @@ import inspectRoute, { inputSchema as inspectInputSchema, resultSchema as inspectResultSchema, } from '../../src/cli/inspect.ts'; -import { resultSchema as inventoryResultSchema } from '../../src/cli/inventory.ts'; +import { resultSchema as inventoryResultSchema } from '../../src/cli/inventory.tsx'; import { resultSchema as libraryAuditResultSchema } from '../../src/cli/library-audit.tsx'; import { inputSchema as convertAudiobookInputSchema } from '../../src/mcp/curator/tools/convert_audiobook.tsx'; import { resultSchema as inspectSourcesResultSchema } from '../../src/mcp/curator/tools/inspect_sources.tsx'; @@ -46,15 +46,20 @@ const invokeLibraryAudit = async ( }; const libraryAuditMarkdown = [ + 'Audited 0 files (0 bytes) across 1 sources.', + '', '## Library audit', '', - 'Audited **0** files (0 bytes) across **1** sources.', + '- **Files:** 0', + '- **Total bytes:** 0', + '- **Sources:** 1', + '- **Metadata issues:** 0', + '- **Duplicate candidates:** 0', + '- **Multipart candidates:** 0', '', - '- metadata issues: **0**', - '- duplicate candidates: **0**', - '- multipart candidates: **0**', + 'No duplicate or multipart candidate groups were found.', '', - 'Duplicate and multipart groups are review candidates, never deletion instructions.', + '> Duplicate and multipart groups are review candidates, never deletion instructions.', '', ].join('\n'); @@ -63,7 +68,7 @@ afterEach(async () => { }); describe('audiobook-curator at the CLI dispatch proof level', () => { - describe('plain commands', () => { + describe('custom commands', () => { it('emits the inspect receipt as one canonical JSON line with direct-operation byte parity', async () => { const { library } = await temporaryLibrary(); const run = await invokeCli(['inspect', library, '--max-files', '1']); @@ -84,7 +89,7 @@ describe('audiobook-curator at the CLI dispatch proof level', () => { it('uses a successful inventory receipt exit code as the process exit code', async () => { const { library, report } = await temporaryLibrary(); - const run = await invokeCli(['inventory', library, '--report', report, '--strict']); + const run = await invokeCli(['inventory', library, '--report', report, '--strict', '--json']); const receipt = inventoryResultSchema.parse(cliJson(run)); expect(receipt).toMatchObject({ @@ -105,7 +110,7 @@ describe('audiobook-curator at the CLI dispatch proof level', () => { // Fail the media probe before an external executable can run. process.env['PATH'] = directory; try { - return await invokeCli(['inventory', library, '--report', report, '--strict']); + return await invokeCli(['inventory', library, '--report', report, '--strict', '--json']); } finally { if (previousPath === undefined) delete process.env['PATH']; else process.env['PATH'] = previousPath; diff --git a/examples/audiobook-curator/tests/route-unit/streaming.test.ts b/examples/audiobook-curator/tests/route-unit/streaming.test.ts new file mode 100644 index 000000000..951e34ec9 --- /dev/null +++ b/examples/audiobook-curator/tests/route-unit/streaming.test.ts @@ -0,0 +1,81 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; +import { + createTargetCapabilityFixture, + expectDocument, + projectTargetCapabilities, + renderRouteEvents, +} from 'agent-bundle/test'; + +const documentText = (value: unknown): string => JSON.stringify(value); + +it('streams library analysis after the audit shell while preserving the canonical receipt', async () => { + const directory = await mkdtemp(join(tmpdir(), 'curator-route-unit-streaming-')); + try { + const library = join(directory, 'library'); + await mkdir(library, { recursive: true }); + await writeFile(join(library, 'Shared title.mp3'), 'tiny'); + await writeFile(join(library, 'Shared title.flac'), 'somewhat larger'); + + const rendered = await renderRouteEvents('tool:curator/audit_library', { + input: { concurrency: 1, sources: [library] }, + }); + const intermediateDocuments = rendered.events.flatMap((event) => { + switch (event.type) { + case 'shell': + case 'replace': + return [event.document]; + case 'complete': + case 'error': + case 'progress': + return []; + default: { + const unhandled: never = event; + throw new Error(`Unhandled render event: ${JSON.stringify(unhandled)}`); + } + } + }); + + expect(intermediateDocuments.some((document) => + documentText(document).includes('Analyzing duplicate and multipart groups') + && documentText(document).includes('"kind":"progress"'))).toBe(true); + expectDocument(rendered) + .toContainMarkdown('**Reclaimable bytes:** 4') + .toContainContext('Duplicate candidate group') + .toHaveValue(rendered.result); + expect(rendered.result).toEqual(rendered.document.value); + expect(rendered.result).toMatchObject({ + duplicateCandidates: [{ files: [join(library, 'Shared title.flac'), join(library, 'Shared title.mp3')] }], + operation: 'library-audit', + summary: { files: 2 }, + }); + + const completeIndex = rendered.events.findIndex((event) => event.type === 'complete'); + const progressIndex = rendered.events.findIndex((event) => event.type === 'progress'); + const projected = await projectTargetCapabilities( + rendered, + createTargetCapabilityFixture({ + audio: false, + image: false, + progress: true, + resource: false, + richContentFallback: 'text', + }), + ); + + expect(progressIndex).toBeGreaterThanOrEqual(0); + expect(progressIndex).toBeLessThan(completeIndex); + expect(projected.progress.length).toBeGreaterThanOrEqual(1); + expect(projected.progress[0]).toMatchObject({ + message: 'Analyzing duplicate and multipart groups', + progress: 0, + progressToken: 'agent-bundle-target-capability-fixture', + }); + expect(projected.structuredContent).toEqual(rendered.document.value); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); From 2fd88c4187dc5c72d146412e037c4746bc48dbce Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 20:46:08 +0000 Subject: [PATCH 5/6] feat(examples/audiobook-curator): render five evidence-facing CLI commands from the shared components (stage 3b) --- .../{audible-search.ts => audible-search.tsx} | 16 ++++- .../src/cli/{audit.ts => audit.tsx} | 16 ++++- .../src/cli/{convert.ts => convert.tsx} | 18 ++++- .../src/cli/{inventory.ts => inventory.tsx} | 14 +++- .../src/cli/{select.ts => select.tsx} | 15 +++- .../tests/application.test.ts | 2 +- examples/audiobook-curator/tests/cli.test.ts | 7 +- .../tests/route-unit/cli-rendered.test.ts | 72 +++++++++++++++++++ 8 files changed, 151 insertions(+), 9 deletions(-) rename examples/audiobook-curator/src/cli/{audible-search.ts => audible-search.tsx} (75%) rename examples/audiobook-curator/src/cli/{audit.ts => audit.tsx} (54%) rename examples/audiobook-curator/src/cli/{convert.ts => convert.tsx} (60%) rename examples/audiobook-curator/src/cli/{inventory.ts => inventory.tsx} (57%) rename examples/audiobook-curator/src/cli/{select.ts => select.tsx} (51%) create mode 100644 examples/audiobook-curator/tests/route-unit/cli-rendered.test.ts diff --git a/examples/audiobook-curator/src/cli/audible-search.ts b/examples/audiobook-curator/src/cli/audible-search.tsx similarity index 75% rename from examples/audiobook-curator/src/cli/audible-search.ts rename to examples/audiobook-curator/src/cli/audible-search.tsx index f420d50ab..7afa33014 100644 --- a/examples/audiobook-curator/src/cli/audible-search.ts +++ b/examples/audiobook-curator/src/cli/audible-search.tsx @@ -1,6 +1,10 @@ +import React from 'react'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; +import type { AudibleSearchReceipt } from '../audible.js'; +import { CandidateRanking } from '../components/candidate-ranking.js'; +import { CuratorDocument } from '../components/curator-document.js'; import { audibleOperations, audibleRegionList, defaultAudibleOperations } from '../operations/audible.js'; const operation = audibleOperations(defaultAudibleOperations).audibleSearch; @@ -27,7 +31,7 @@ export const inputSchema = z.object({ export const resultSchema = operation.resultSchema; export default async function audibleSearch({ input, signal }: CliRouteProps) { - return operation.handler({ + const receipt = await operation.handler({ ...(input.attempts === undefined ? {} : { attempts: input.attempts }), ...(input.author === undefined ? {} : { author: input.author }), ...(input.duration === undefined ? {} : { durationSeconds: input.duration }), @@ -36,5 +40,13 @@ export default async function audibleSearch({ input, signal }: CliRouteProps + + + ); } diff --git a/examples/audiobook-curator/src/cli/audit.ts b/examples/audiobook-curator/src/cli/audit.tsx similarity index 54% rename from examples/audiobook-curator/src/cli/audit.ts rename to examples/audiobook-curator/src/cli/audit.tsx index b68599bff..24660864d 100644 --- a/examples/audiobook-curator/src/cli/audit.ts +++ b/examples/audiobook-curator/src/cli/audit.tsx @@ -1,6 +1,11 @@ +import React from 'react'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; +import { ChapterOutline } from '../components/chapter-outline.js'; +import { CuratorDocument } from '../components/curator-document.js'; +import { IntegrityReport } from '../components/integrity-report.js'; +import type { IntegrityAuditReceipt } from '../integrity-audit.js'; import { defaultOutputOperations, outputOperations } from '../operations/output.js'; const operation = outputOperations(defaultOutputOperations).audit; @@ -20,5 +25,14 @@ export const inputSchema = z.object({ export const resultSchema = operation.resultSchema; export default async function audit({ input, signal }: CliRouteProps) { - return operation.handler(input, { signal }); + const receipt = await operation.handler(input, { signal }) as IntegrityAuditReceipt; + return ( + + + + + ); } diff --git a/examples/audiobook-curator/src/cli/convert.ts b/examples/audiobook-curator/src/cli/convert.tsx similarity index 60% rename from examples/audiobook-curator/src/cli/convert.ts rename to examples/audiobook-curator/src/cli/convert.tsx index c13e18895..ca1c819b3 100644 --- a/examples/audiobook-curator/src/cli/convert.ts +++ b/examples/audiobook-curator/src/cli/convert.tsx @@ -1,6 +1,12 @@ +import React from 'react'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; +import { ChapterOutline } from '../components/chapter-outline.js'; +import { CuratorDocument } from '../components/curator-document.js'; +import { IntegrityReport } from '../components/integrity-report.js'; +import { MutationReceipt } from '../components/mutation-receipt.js'; +import type { ConvertReceipt } from '../conversion.js'; import { defaultOutputOperations, outputOperations } from '../operations/output.js'; const operation = outputOperations(defaultOutputOperations).convert; @@ -32,5 +38,15 @@ export const inputSchema = z.object({ export const resultSchema = operation.resultSchema; export default async function convert({ input, signal }: CliRouteProps) { - return operation.handler(input, { signal }); + const receipt = await operation.handler(input, { signal }) as ConvertReceipt; + const headline = receipt.status === 'planned' + ? `Planned ${receipt.audioMode} output at ${receipt.output}; sources remain unchanged.` + : `Converted and verified ${receipt.output}; sources remain unchanged.`; + return ( + + + + + + ); } diff --git a/examples/audiobook-curator/src/cli/inventory.ts b/examples/audiobook-curator/src/cli/inventory.tsx similarity index 57% rename from examples/audiobook-curator/src/cli/inventory.ts rename to examples/audiobook-curator/src/cli/inventory.tsx index 4bed49fe5..fed8071c2 100644 --- a/examples/audiobook-curator/src/cli/inventory.ts +++ b/examples/audiobook-curator/src/cli/inventory.tsx @@ -1,6 +1,10 @@ +import React from 'react'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; +import { CuratorDocument } from '../components/curator-document.js'; +import { LibraryShelf } from '../components/library-shelf.js'; +import type { InventoryReceipt } from '../library.js'; import { defaultDiscoveryOperations, discoveryOperations } from '../operations/discovery.js'; const operation = discoveryOperations(defaultDiscoveryOperations).inventory; @@ -20,5 +24,13 @@ export const inputSchema = z.object({ export const resultSchema = operation.resultSchema; export default async function inventory({ input, signal }: CliRouteProps) { - return operation.handler(input, { signal }); + const receipt = await operation.handler(input, { signal }) as InventoryReceipt; + return ( + + + + ); } diff --git a/examples/audiobook-curator/src/cli/select.ts b/examples/audiobook-curator/src/cli/select.tsx similarity index 51% rename from examples/audiobook-curator/src/cli/select.ts rename to examples/audiobook-curator/src/cli/select.tsx index 73f2dbd0f..9e4c120e3 100644 --- a/examples/audiobook-curator/src/cli/select.ts +++ b/examples/audiobook-curator/src/cli/select.tsx @@ -1,6 +1,10 @@ +import React from 'react'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; +import { CuratorDocument } from '../components/curator-document.js'; +import { LibraryShelf } from '../components/library-shelf.js'; +import type { SelectionReceipt } from '../library.js'; import { defaultDiscoveryOperations, discoveryOperations } from '../operations/discovery.js'; const operation = discoveryOperations(defaultDiscoveryOperations).select; @@ -17,5 +21,14 @@ export const inputSchema = z.object({ export const resultSchema = operation.resultSchema; export default async function select({ input, signal }: CliRouteProps) { - return operation.handler(input, { signal }); + const receipt = await operation.handler(input, { signal }) as SelectionReceipt; + const reviewCount = receipt.selections.filter((selection) => selection.reviewRequired).length; + return ( + + + + ); } diff --git a/examples/audiobook-curator/tests/application.test.ts b/examples/audiobook-curator/tests/application.test.ts index e471dec11..fb1d95453 100644 --- a/examples/audiobook-curator/tests/application.test.ts +++ b/examples/audiobook-curator/tests/application.test.ts @@ -56,7 +56,7 @@ describe('audiobook curator filesystem application', () => { toolNames.map((tool) => `curator ${tool}`), ); expect(customCommands.filter((command) => command.rendered).map((command) => command.path.join(' '))) - .toEqual(['library-audit', 'shelf']); + .toEqual(['audible-search', 'audit', 'convert', 'inventory', 'library-audit', 'select', 'shelf']); expect(projectedCommands.every((command) => command.rendered)).toBe(true); }); }); diff --git a/examples/audiobook-curator/tests/cli.test.ts b/examples/audiobook-curator/tests/cli.test.ts index a08336afe..358374d7c 100644 --- a/examples/audiobook-curator/tests/cli.test.ts +++ b/examples/audiobook-curator/tests/cli.test.ts @@ -67,7 +67,7 @@ describe('audiobook-curator routed CLI', () => { // inventory --report FILE [--strict] const inventory = byName.get('inventory')!; - expect(inventory).toMatchObject({ exitCode: 'result', rendered: false }); + expect(inventory).toMatchObject({ exitCode: 'result', rendered: true }); expect(inventory.options.map((option) => [option.option, option.required, option.positional ?? null])).toEqual([ ['report', true, null], ['source', true, 0], @@ -87,6 +87,7 @@ describe('audiobook-curator routed CLI', () => { // convert keeps its full named-option surface, including kebab-case // projections of camelCase keys (--audio-bitrate, --forge-aac-encoder). const convert = byName.get('convert')!; + expect(convert).toMatchObject({ rendered: true }); expect(convert.options.map((option) => option.option).sort()).toEqual([ 'apply', 'artwork', 'audio-bitrate', 'audio-codec', 'author', 'engine', 'forge-aac-encoder', 'forge-cli', 'jobs', 'language', 'narrator', @@ -111,7 +112,9 @@ describe('audiobook-curator routed CLI', () => { expect(audibleSearch.options.map((option) => option.option)).toEqual([ 'attempts', 'author', 'duration', 'limit', 'narrator', 'regions', 'report', 'title', ]); - expect(audibleSearch).toMatchObject({ exitCode: 'result' }); + expect(audibleSearch).toMatchObject({ exitCode: 'result', rendered: true }); + expect(byName.get('audit')).toMatchObject({ exitCode: 'result', rendered: true }); + expect(byName.get('select')).toMatchObject({ rendered: true }); // audible-cache keeps --cache-dir. expect(byName.get('audible-cache')!.options.some((option) => option.option === 'cache-dir')).toBe(true); diff --git a/examples/audiobook-curator/tests/route-unit/cli-rendered.test.ts b/examples/audiobook-curator/tests/route-unit/cli-rendered.test.ts new file mode 100644 index 000000000..dc648ecb6 --- /dev/null +++ b/examples/audiobook-curator/tests/route-unit/cli-rendered.test.ts @@ -0,0 +1,72 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; +import { expectDocument, renderRoute } from 'agent-bundle/test'; + +it('renders the inventory CLI document with its canonical receipt', async () => { + const directory = await mkdtemp(join(tmpdir(), 'curator-cli-rendered-inventory-')); + try { + const source = join(directory, 'library'); + const report = join(directory, 'inventory.json'); + await mkdir(source); + + const rendered = await renderRoute('cli:inventory', { + input: { report, source }, + }); + const canonicalReceipt = JSON.parse(await readFile(report, 'utf8')) as unknown; + + expectDocument(rendered) + .toHaveStatus('success') + .toContainText('Inventoried 0 media files with 0 retained errors.') + .toContainMarkdown('**Files:** 0') + .toHaveValue(canonicalReceipt); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +it('renders the audit CLI integrity report and chapter outline with its canonical receipt', async () => { + const directory = await mkdtemp(join(tmpdir(), 'curator-cli-rendered-audit-')); + const previousPath = process.env['PATH']; + try { + const bin = join(directory, 'bin'); + const file = join(directory, 'book.m4b'); + const receipt = join(directory, 'audit.json'); + await mkdir(bin); + await writeFile(file, 'book'); + await writeFile(join(bin, 'ffprobe'), `#!/usr/bin/env node +process.stdout.write(JSON.stringify({ + chapters: [{ end_time: '10', start_time: '0', tags: { title: 'Book' } }], + format: { duration: '10', format_name: 'mov', tags: { title: 'Book' } }, + streams: [{ codec_name: 'aac', codec_type: 'audio', disposition: {}, sample_rate: '44100' }], +})); +`); + await writeFile(join(bin, 'ffmpeg'), `#!/usr/bin/env node +process.stdout.write('SHA256=${'b'.repeat(64)}\\n'); +`); + await Promise.all([ + chmod(join(bin, 'ffprobe'), 0o755), + chmod(join(bin, 'ffmpeg'), 0o755), + ]); + process.env['PATH'] = `${bin}:${previousPath ?? ''}`; + + const rendered = await renderRoute('cli:audit', { + input: { file, receipt }, + }); + const canonicalReceipt = JSON.parse(await readFile(receipt, 'utf8')) as unknown; + + expectDocument(rendered) + .toHaveStatus('success') + .toContainText(`Audited 4 bytes with SHA-256`) + .toContainMarkdown('**Audit status:** verified') + .toContainMarkdown('Chapter outline (1)') + .toContainContext('Verified: hashes, probe facts, chapter structure') + .toHaveValue(canonicalReceipt); + } finally { + if (previousPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = previousPath; + await rm(directory, { force: true, recursive: true }); + } +}); From 8a70d8a908d69696dad6745b81d1ef6def68e1af Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 21:05:57 +0000 Subject: [PATCH 6/6] docs(examples/audiobook-curator): rewrite the README as the composed-application tour and update flagship Workbench pins (stage 4) --- examples/audiobook-curator/README.md | 194 ++++++++++++------ .../workbench/tests/examples-real.e2e.test.ts | 20 +- 2 files changed, 144 insertions(+), 70 deletions(-) diff --git a/examples/audiobook-curator/README.md b/examples/audiobook-curator/README.md index 791d3cfcc..2c1a74c75 100644 --- a/examples/audiobook-curator/README.md +++ b/examples/audiobook-curator/README.md @@ -1,22 +1,23 @@ # Audiobook Curator -From the repository root, launch this example with: +This example is a complete TypeScript recreation of the original +`audiobook-curator` and a worked tour of an agent-bundle application assembled +from React Server Components, request context, durable state, MCP routes, and +CLI routes. It produces one generated stdio MCP server, an installable CLI, one +Skill, and native Claude Code and Codex plugin artifacts. It has no hooks and +does not call the old Python curator. + +From the repository root, launch the Workbench with: ```bash pnpm example:audiobook ``` -A complete TypeScript recreation of the original `audiobook-curator`, built in -framework mode: `agent-bundle.config.ts` plus file conventions declare the -structure, and filesystem route modules produce one generated stdio MCP server, while a compatibility CLI remains globally installable, one Skill, and native Claude Code and Codex plugin -artifacts. JSX appears only where something is rendered — the MCP result -receipts. It has no hooks and does not call the old Python curator. - The package requires Node 22.19+, `ffprobe`, and `ffmpeg`. Optional features call -the foreign tools that actually provide the evidence: Audiobook Forge, -Audiolocate in a selected Python environment, and `whisper-cli` with a selected -model. Local media processes have no wall-clock deadline; caller cancellation -and bounded stdout/stderr remain enforced. +the foreign tools that provide their evidence: Audiobook Forge, Audiolocate in +a selected Python environment, and `whisper-cli` with a selected model. Local +media processes have no wall-clock deadline; caller cancellation and bounded +stdout/stderr remain enforced. ## Workspace use @@ -39,46 +40,113 @@ audiobook-curator --help Choose any writable directory already on `PATH` in place of `~/.local/bin`. This is a direct workspace link; it does not pack or install a tarball. -One `agent-bundle build` produces everything: complete Claude and Codex -outputs beneath `artifact/` (each host's plugin metadata, Skill, bundled CLI -script, and lifecycle-wrapped MCP server) plus the npm package build beneath -`dist/` (`dist/bin/audiobook-curator.js` for `package.json` `bin`, -`dist/index.js` and declarations for `exports`). The example uses only public -`agent-bundle` and `@agent-bundle/runtime` exports with `workspace:*` -dependencies. - -## Route model - -The MCP application is the route tree under `src/mcp/curator/`: fifteen tool -modules plus one resource and one prompt. Every executable route exports static -`config`, `inputSchema`, `resultSchema`, and one async default Server Component -that executes the domain operation and renders `Agent.*`. The compiler derives -the `curator` server, lifecycle entry, warm Flight worker, and MCP registrations; -there is no `src/application.ts`, operation-array registry, handwritten -`src/mcp/curator.ts`, or per-operation server selector. - -The routed CLI under `src/cli/` shares the generated command graph with all -fifteen MCP tools projected as `audiobook-curator curator `. Projected -tools accept one optional `--input ''`; tools explicitly annotated -read-only run directly, while every mutation-capable tool requires `--yes`. - -## Source layout - -- `agent-bundle.config.ts` — plugin identity, selected targets, and the bundled - CLI script; MCP needs no declaration. -- `src/mcp/curator/tools/` — one single-file route per MCP tool. -- `src/mcp/curator/resources/catalog.tsx` and `prompts/curate.tsx` — the routed - resource and prompt proofs. -- `src/operations/` — CLI-only compatibility command data and shared schemas; MCP - metadata and server strings do not live here. -- Domain logic remains in `src/` over `foundation.ts` and `media-process.ts`; - `result.tsx` renders route receipts as Agent Documents. -- `src/cli.ts` and `src/index.ts` keep the package bin/library conventions. +One `agent-bundle build` produces everything: complete Claude and Codex outputs +beneath `artifact/` (each host's plugin metadata, Skill, bundled CLI script, and +lifecycle-wrapped MCP server) plus the npm package beneath `dist/` +(`dist/bin/audiobook-curator.js` for `package.json` `bin`, and `dist/index.js` +plus declarations for `exports`). The example uses only public `agent-bundle` +and `@agent-bundle/runtime` exports with `workspace:*` dependencies. + +## Application tour + +### The route tree is the application + +`agent-bundle.config.ts` declares the plugin identity, Node runtime, Claude and +Codex targets, and MCP-to-CLI projection. File conventions discover the rest. +The MCP tree under `src/mcp/curator/` contains 16 tool routes, one catalog +resource, and one curation prompt. Each executable route exports static +`config`, `inputSchema`, and `resultSchema` values plus an async default Server +Component. The compiler derives the `curator` server, lifecycle entry, warm +Flight worker, and MCP registrations; there is no `src/application.ts`, +operation-array registry, handwritten `src/mcp/curator.ts`, or per-operation +server selector. + +The routed CLI under `src/cli/` contains 16 authored commands. The +`routes.mcpCommands` setting projects all 16 MCP tools as +`audiobook-curator curator `, giving the compiled graph 32 CLI commands. +Projected tools accept one optional `--input ''`; tools annotated +read-only run directly, while mutation-capable tools require `--yes`. + +### `src/components/` is the shared presentation library + +The route modules perform domain work and compose these report components +instead of maintaining separate MCP and CLI presenters: + +| Component | MCP composition | Rendered authored CLI composition | +| --- | --- | --- | +| `CuratorDocument` and its `CuratorReceipt` union | Wrap the structured receipt and headline for 15 receipt-bearing tools | Wrap `inventory`, `select`, `audible-search`, `convert`, `audit`, and `library-audit` | +| `DataList`, `Field`, and `Callout` | Provide report primitives throughout the component library and directly in the catalog resource, curate prompt, cache route, and library audit | Provide the same primitives through the shared components and directly in `library-audit` | +| `AudiobookCard` | Renders files in `audit_library` and under shelf and ranking views | Reached through `LibraryShelf` and `CandidateRanking` | +| `LibraryShelf` | Composes `inventory_sources`, `inspect_sources`, and `select_sources` | Composes `inventory` and `select` | +| `CandidateRanking` | Composes `search_audible`, `select_audible_edition`, and `identify_audible_sample` | Composes `audible-search` | +| `EvidenceTrail` | Composes acoustic identification, acoustic verification, and Whisper verification | No authored rendered counterpart; those compatibility commands remain plain `.ts` routes | +| `MutationReceipt` | Composes prepare, conversion, metadata, and chapter mutation tools | Composes `convert` | +| `ChapterOutline` | Composes integrity audit, conversion, and chapter application tools | Composes `audit` and `convert` | +| `IntegrityReport` | Composes integrity audit, conversion, metadata, and chapter application tools | Composes `audit` and `convert` | +| `CurationShelf` | Composes shelf review, Audible edition selection, and metadata/chapter application | Composes `shelf` | +| `LibraryAnalysis` | Resolves the asynchronous duplicate and multipart analysis in `audit_library` | Resolves the same analysis in `library-audit` | + +The catalog resource at `src/mcp/curator/resources/catalog.tsx` and the prompt at +`src/mcp/curator/prompts/curate.tsx` are compositions too: both return their +protocol result through `Agent.Result` and use the same report primitives as +the tools. + +### `src/providers/library.ts` supplies request context + +The conventional `library` provider probes `ffmpeg -version` and +`ffprobe -version` concurrently for each request and publishes the probe time, +tool availability and versions, and the `discover → identify → curate → verify` +workflow stages. The catalog resource reads +`(await agent()).providers.library`, validates the value, and renders either the +live request context or an explicit unavailable state. Tool availability is +therefore observed at request time rather than assumed during the build. + +### `src/state.ts` mounts the durable curation shelf + +The conventional state module defines the workspace-durable +`audiobook-curator/shelf` state and three events: `editionSelected`, +`mutationApplied`, and `shelfCleared`. `select_audible_edition` dispatches the +selection event; `apply_audiobook_metadata` and `apply_audiobook_chapters` +dispatch mutation records and render the updated shelf. The read-only +`review_curation_shelf` MCP tool and rendered `shelf` CLI command expose the +same mounted state. If state is not mounted, both surfaces return an empty +structured shelf and render an explicit unavailable notice. + +### Suspense becomes MCP progress + +`audit_library` first reports progress through the request's +`context.progress`, then places the asynchronous `LibraryAnalysis` component +behind React `Suspense`. While that component re-stats duplicate candidates and +calculates reclaimable bytes, its fallback is an `Agent.Progress` document +node. The generated MCP projector streams the progress state and then replaces +it with the completed analysis without changing the final structured +`LibraryAuditReceipt`. The rendered `library-audit` CLI route composes the same +analysis and fallback. + +### CLI routes have rendered and plain modes + +Seven authored `.tsx` commands render Agent Documents: +`inventory`, `select`, `audible-search`, `convert`, `audit`, `library-audit`, +and `shelf`. Interactive terminals can update reported progress in place; +piped output is one final Markdown document. Nine compatibility commands remain +plain `.ts` routes: `acoustic-identify`, `acoustic-verify`, `apply-chapters`, +`apply-metadata`, `audible-cache`, `audible-select`, `inspect`, `prepare`, and +`whisper-verify`. + +The 16 projected MCP commands render the same tool components as their MCP +counterparts. Across plain and rendered commands, `--json` selects machine +output and emits one result-schema-validated JSON value followed by a newline. +For rendered commands that value is the canonical final `Agent.Result` value, +not the Markdown presentation or an intermediate Suspense fallback, so existing +receipt consumers do not change when a command becomes rendered. + +`src/operations/` owns shared operation handlers and schemas; +`src/cli-command.ts` defines their small typed definition helper. Domain logic +remains in `src/` over `foundation.ts` and `media-process.ts`, while +`src/index.ts` remains the package library entry. ## Complete workflow -The original thirteen commands are present: - - `inventory`, `library-audit`, and `select` retain probe failures, duplicate and multipart evidence, and reviewed source-quality decisions. - `convert` plans by default and explicitly applies FFmpeg or optional Audiobook @@ -98,25 +166,25 @@ The original thirteen commands are present: - `audit` records probe facts, chapter defects, source chapter mapping, file and encoded-audio hashes, and optional full-decode evidence. -`inspect` and `prepare` remain as small supplemental local operations. Every -operation also has an MCP tool with the same implementation and result renderer; -run `audiobook-curator --help` for exact CLI forms. +`inspect` and `prepare` remain small supplemental local operations. `shelf` +reviews the durable curation state. Every domain operation also has an MCP tool +with the same implementation; run `audiobook-curator --help` for exact CLI +forms. ## Run the MCP server -From this example's directory, run the built `curator` server in the -foreground on stdio: +From this example's directory, run the built `curator` server in the foreground +on stdio: ```bash cd examples/audiobook-curator pnpm exec agent-bundle mcp run --server curator --target claude ``` -The command resolves the generated entry from the Claude target's MCP -manifest, building a temporary artifact first; pass `--artifact artifact` to -reuse the `pnpm build` output instead. Closing stdin exits 0 and Ctrl-C -exits 130, and per-server state persists under -`.agent-bundle/mcp-run/claude/curator`. +The command resolves the generated entry from the Claude target's MCP manifest, +building a temporary artifact first; pass `--artifact artifact` to reuse the +`pnpm build` output instead. Closing stdin exits 0 and Ctrl-C exits 130, and +per-server state persists under `.agent-bundle/mcp-run/claude/curator`. ## Safety @@ -133,9 +201,9 @@ The completion contract and real-volume checklist are in ## Maintainer notes -This example is the reference consumer of the framework-owned package build -("one config, agent-bundle owns the build"): `agent-bundle.config.ts` declares -the structure directly, and the `src/cli.ts` / `src/index.ts` conventions -provide the npm bin and library outputs under `dist/`. See -[`docs/entry-conventions.md`](../../docs/entry-conventions.md) +This example is the reference consumer of the framework-owned package build: +one `agent-bundle.config.ts` declares the structure, conventional +`src/mcp/**`, `src/cli/**`, `src/providers/**`, and `src/state.ts` modules supply +the application surfaces, and agent-bundle owns the generated package and host +artifacts. See [`docs/entry-conventions.md`](../../docs/entry-conventions.md) for the contract. diff --git a/packages/workbench/tests/examples-real.e2e.test.ts b/packages/workbench/tests/examples-real.e2e.test.ts index b63531611..a142aac34 100644 --- a/packages/workbench/tests/examples-real.e2e.test.ts +++ b/packages/workbench/tests/examples-real.e2e.test.ts @@ -595,9 +595,10 @@ e2e('renders the flagship compiled route catalog by server and kind in real Chro } await expect(page.locator('.route-group-heading').filter({ hasText: 'curator · Tools' })).toContainText('generated', { timeout: browserTimeout }); const tools = page.getByRole('region', { name: 'curator · Tools' }); - await expect(tools.locator('tbody tr')).toHaveCount(15, { timeout: browserTimeout }); + await expect(tools.locator('tbody tr')).toHaveCount(16, { timeout: browserTimeout }); await expect(tools).toContainText('tool:curator/convert_audiobook', { timeout: browserTimeout }); await expect(tools).toContainText('src/mcp/curator/tools/convert_audiobook.tsx', { timeout: browserTimeout }); + await expect(tools).toContainText('tool:curator/review_curation_shelf', { timeout: browserTimeout }); // The extracted config is summarized, never inlined as nested JSON. await expect(tools).toContainText('annotations: 2 keys', { timeout: browserTimeout }); @@ -630,7 +631,7 @@ e2e('renders the flagship compiled route catalog by server and kind in real Chro await expect(page.locator('.route-provenance').first()).toHaveText('conventional', { timeout: browserTimeout }); // The generated CLI is a project surface rather than a server one. It - // contains the 15 authored CLI routes plus one projected command for each + // contains the 16 authored CLI routes plus one projected command for each // MCP tool, preserving the tool route IDs rather than duplicating either // category. Each command carries the argv projection compiled from its // input schema. @@ -653,6 +654,7 @@ e2e('renders the flagship compiled route catalog by server and kind in real Chro 'cli:library-audit', 'cli:prepare', 'cli:select', + 'cli:shelf', 'cli:whisper-verify', ]); expect(projectedMcpRouteIds).toEqual(await tools.locator('.route-id').allTextContents()); @@ -660,6 +662,7 @@ e2e('renders the flagship compiled route catalog by server and kind in real Chro expect(cliRouteIds).toHaveLength(authoredCliRouteIds.length + projectedMcpRouteIds.length); await expect(cli).toContainText('cli:library-audit', { timeout: browserTimeout }); await expect(cli).toContainText('src/cli/library-audit.tsx', { timeout: browserTimeout }); + await expect(cli).toContainText('src/cli/shelf.tsx', { timeout: browserTimeout }); await expect(cli.locator('.route-command').filter({ hasText: 'library-audit' })) .toHaveText('library-audit [--concurrency ] --report [--strict]', { timeout: browserTimeout }); const inspectCli = cli.getByRole('row').filter({ hasText: 'cli:inspect' }); @@ -674,14 +677,17 @@ e2e('renders the flagship compiled route catalog by server and kind in real Chro { timeout: browserTimeout }, ); - // 17 MCP routes plus 15 authored and 15 projected CLI routes, and nothing - // invented: the curator declares no conventional event routes, scripts, - // or context providers. + // 18 MCP routes plus 16 authored and 16 projected CLI routes, and nothing + // invented: the curator declares no conventional event routes or scripts + // and discovers one conventional context provider. await expect(page.getByRole('region', { name: 'Route graph identity' }).locator('dd').first()) - .toHaveText('47', { timeout: browserTimeout }); + .toHaveText('50', { timeout: browserTimeout }); await expect(page.getByRole('heading', { name: 'Event routes', exact: true })).toHaveCount(0); await expect(page.getByRole('heading', { name: 'Scripts', exact: true })).toHaveCount(0); - await expect(page.getByRole('heading', { name: 'Context providers', exact: true })).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'Context providers', exact: true })).toBeVisible({ timeout: browserTimeout }); + const providers = page.getByRole('region', { name: 'Context providers' }); + const libraryProvider = providers.getByRole('row').filter({ hasText: 'provider:library' }); + await expect(libraryProvider).toContainText('src/providers/library.ts', { timeout: browserTimeout }); await expect(page.locator('.route-diagnostics')).toHaveCount(0); await captureExampleState(page, 'audiobook-curator', 'routes-catalog-by-server');