diff --git a/examples/audiobook-curator/README.md b/examples/audiobook-curator/README.md index e4d37c88d..7cb7aac7a 100644 --- a/examples/audiobook-curator/README.md +++ b/examples/audiobook-curator/README.md @@ -75,16 +75,16 @@ 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` | +| `DataList`, `Field`, `Callout`, and `FileList` | Provide atomic report fields, prose callouts, and file-list blocks 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` | +| `FileCard` and `EditionCard`, fed by `view-models` | Render file and edition models in `audit_library`, shelf, and ranking views | Reached through the receipt-specific shelves and ranking components | +| `InspectionShelf`, `InventoryShelf`, `AuditShelf`, and `SelectionShelf` | Compose receipt-specific inspection, inventory, audit, and selection reports; the inspection, inventory, and selection shelves are used directly by their MCP routes | `InventoryShelf` and `SelectionShelf` compose `inventory` and `select` | +| `SearchRanking`, `IdentifyRanking`, and `SelectionRanking` | Render the statically typed ranking for `search_audible`, `identify_audible_sample`, and `select_audible_edition` | `SearchRanking` composes `audible-search` | +| `AcousticTrail`, `IdentifyTrail`, and `WhisperTrail` | Render the statically typed evidence for acoustic verification, acoustic identification, and Whisper verification | No authored rendered counterpart; those compatibility commands remain plain `.ts` routes | +| `MetadataMutation`, `ChapterMutation`, `ConversionMutation`, and `PrepareMutation` | Render each statically typed metadata, chapter, conversion, or preparation mutation | `ConversionMutation` composes `convert` | +| `ChapterOutline` with normalized `chapters` props | Composes integrity audit, conversion, and chapter application tools through receipt-specific mappers | Composes `audit` and `convert` | +| `IntegrityAuditReport`, `MetadataIntegrityReport`, `ChapterIntegrityReport`, and `ConversionIntegrityReport` | Render each statically typed integrity report | `IntegrityAuditReport` and `ConversionIntegrityReport` compose `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` | +| `LibraryAnalysis` and `CandidateGroupCallout` | Resolve asynchronous duplicate and multipart analysis in `audit_library` with shared atomic candidate prose and file lists | Resolve the same analysis and candidate-group presentation 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 diff --git a/examples/audiobook-curator/src/audible.ts b/examples/audiobook-curator/src/audible.ts index 8ede9a35d..a4fee8823 100644 --- a/examples/audiobook-curator/src/audible.ts +++ b/examples/audiobook-curator/src/audible.ts @@ -1,6 +1,8 @@ import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, extname, join, resolve } from 'node:path'; +import type { JsonObject } from '@agent-bundle/runtime'; + import { CuratorError, audibleHosts, @@ -21,14 +23,14 @@ export interface CuratorHttpOptions { export type CuratorHttpClient = (url: string, options?: CuratorHttpOptions) => Promise; -export interface AudibleQuery { +export type AudibleQuery = { readonly author?: string; readonly durationSeconds?: number; readonly narrator?: string; readonly title: string; -} +}; -export interface AudibleCandidateEvidence { +export type AudibleCandidateEvidence = { readonly authorMatch: boolean; readonly durationDifferencePercent?: number; readonly language?: string; @@ -38,12 +40,12 @@ export interface AudibleCandidateEvidence { readonly strictIdentityMatch: boolean; readonly titleMatch: boolean; readonly unabridged: boolean; -} +}; -export interface AudibleCandidate extends Record { +export type AudibleCandidate = JsonObject & { readonly evidence: AudibleCandidateEvidence; readonly region: AudibleRegion; -} +}; export interface AudibleSearchInput extends AudibleQuery { readonly attempts?: number; @@ -52,7 +54,7 @@ export interface AudibleSearchInput extends AudibleQuery { readonly report?: string; } -export interface AudibleSearchReceipt { +export type AudibleSearchReceipt = { readonly candidates: readonly AudibleCandidate[]; readonly errors: readonly { readonly error: string; readonly region: AudibleRegion }[]; readonly exitCode: 0 | 1; @@ -62,9 +64,9 @@ export interface AudibleSearchReceipt { readonly operation: 'audible-search'; readonly query: AudibleQuery; readonly reviewNote: string; -} +}; -export interface AudibleSelectionReceipt { +export type AudibleSelectionReceipt = { readonly candidateNumber: number; readonly candidateReport?: string; readonly generatedAt: string; @@ -73,7 +75,7 @@ export interface AudibleSelectionReceipt { readonly operation: 'audible-select'; readonly reviewNote?: string; readonly selected: AudibleCandidate; -} +}; export interface AudibleCacheInput { readonly asin: string; @@ -83,7 +85,7 @@ export interface AudibleCacheInput { readonly region?: AudibleRegion; } -export interface AudibleCacheReceipt { +export type AudibleCacheReceipt = { readonly artwork?: string; readonly asin: string; readonly chapterError?: string; @@ -99,15 +101,15 @@ export interface AudibleCacheReceipt { readonly chapters: string; readonly product: string; }; -} +}; export interface AudibleDependencies { readonly http?: CuratorHttpClient; readonly signal?: AbortSignal; } -const objects = (value: unknown): Record[] => Array.isArray(value) - ? value.filter((row): row is Record => row !== null && typeof row === 'object' && !Array.isArray(row)) +const objects = (value: unknown): JsonObject[] => Array.isArray(value) + ? value.filter((row): row is JsonObject => row !== null && typeof row === 'object' && !Array.isArray(row)) : []; export const audibleCandidateEvidence = ( diff --git a/examples/audiobook-curator/src/cli/audible-search.tsx b/examples/audiobook-curator/src/cli/audible-search.tsx index 7afa33014..71cf99cb7 100644 --- a/examples/audiobook-curator/src/cli/audible-search.tsx +++ b/examples/audiobook-curator/src/cli/audible-search.tsx @@ -3,8 +3,9 @@ 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 { SearchRanking } from '../components/candidate-ranking.js'; import { CuratorDocument } from '../components/curator-document.js'; +import { audibleSearchHeadline } from '../components/headlines.js'; import { audibleOperations, audibleRegionList, defaultAudibleOperations } from '../operations/audible.js'; const operation = audibleOperations(defaultAudibleOperations).audibleSearch; @@ -43,10 +44,10 @@ export default async function audibleSearch({ input, signal }: CliRouteProps - + ); } diff --git a/examples/audiobook-curator/src/cli/audit.tsx b/examples/audiobook-curator/src/cli/audit.tsx index 24660864d..523bdd7de 100644 --- a/examples/audiobook-curator/src/cli/audit.tsx +++ b/examples/audiobook-curator/src/cli/audit.tsx @@ -2,9 +2,10 @@ import React from 'react'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { ChapterOutline } from '../components/chapter-outline.js'; +import { ChapterOutline, chaptersFromAuditReceipt } from '../components/chapter-outline.js'; import { CuratorDocument } from '../components/curator-document.js'; -import { IntegrityReport } from '../components/integrity-report.js'; +import { integrityAuditHeadline } from '../components/headlines.js'; +import { IntegrityAuditReport } from '../components/integrity-report.js'; import type { IntegrityAuditReceipt } from '../integrity-audit.js'; import { defaultOutputOperations, outputOperations } from '../operations/output.js'; @@ -28,11 +29,11 @@ export default async function audit({ input, signal }: CliRouteProps - - + + ); } diff --git a/examples/audiobook-curator/src/cli/convert.tsx b/examples/audiobook-curator/src/cli/convert.tsx index ca1c819b3..b23330132 100644 --- a/examples/audiobook-curator/src/cli/convert.tsx +++ b/examples/audiobook-curator/src/cli/convert.tsx @@ -2,10 +2,11 @@ import React from 'react'; import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; import { z } from 'zod'; -import { ChapterOutline } from '../components/chapter-outline.js'; +import { ChapterOutline, chaptersFromConvertReceipt } 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 { convertHeadline } from '../components/headlines.js'; +import { ConversionIntegrityReport } from '../components/integrity-report.js'; +import { ConversionMutation } from '../components/mutation-receipt.js'; import type { ConvertReceipt } from '../conversion.js'; import { defaultOutputOperations, outputOperations } from '../operations/output.js'; @@ -39,14 +40,11 @@ export const resultSchema = operation.resultSchema; export default async function convert({ input, signal }: CliRouteProps) { 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.tsx b/examples/audiobook-curator/src/cli/inventory.tsx index fed8071c2..f0aefc6d7 100644 --- a/examples/audiobook-curator/src/cli/inventory.tsx +++ b/examples/audiobook-curator/src/cli/inventory.tsx @@ -3,7 +3,8 @@ 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 { inventoryHeadline } from '../components/headlines.js'; +import { InventoryShelf } from '../components/library-shelf.js'; import type { InventoryReceipt } from '../library.js'; import { defaultDiscoveryOperations, discoveryOperations } from '../operations/discovery.js'; @@ -27,10 +28,10 @@ export default async function inventory({ input, signal }: CliRouteProps - + ); } diff --git a/examples/audiobook-curator/src/cli/select.tsx b/examples/audiobook-curator/src/cli/select.tsx index 9e4c120e3..48311bddb 100644 --- a/examples/audiobook-curator/src/cli/select.tsx +++ b/examples/audiobook-curator/src/cli/select.tsx @@ -3,7 +3,8 @@ 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 { selectionHeadline } from '../components/headlines.js'; +import { SelectionShelf } from '../components/library-shelf.js'; import type { SelectionReceipt } from '../library.js'; import { defaultDiscoveryOperations, discoveryOperations } from '../operations/discovery.js'; @@ -22,13 +23,12 @@ export const resultSchema = operation.resultSchema; export default async function select({ input, signal }: CliRouteProps) { 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/cli/shelf.tsx b/examples/audiobook-curator/src/cli/shelf.tsx index a9418c150..eff40d663 100644 --- a/examples/audiobook-curator/src/cli/shelf.tsx +++ b/examples/audiobook-curator/src/cli/shelf.tsx @@ -1,4 +1,4 @@ -import { Agent, agent, type JsonValue } from '@agent-bundle/runtime'; +import { Agent, agent } from '@agent-bundle/runtime'; import type { CliRouteConfig } from 'agent-bundle'; import React from 'react'; import { z } from 'zod'; @@ -25,7 +25,7 @@ export default async function Shelf() { const state = (await agent()).state; if (state === undefined) { return ( - + Persisted curation shelf unavailable. @@ -33,7 +33,7 @@ export default async function Shelf() { } const shelf = CurationShelfStateSchema.parse((await state.read()).state); return ( - + Persisted curation shelf ready. diff --git a/examples/audiobook-curator/src/components/audiobook-card.tsx b/examples/audiobook-curator/src/components/audiobook-card.tsx index dfbc0e11c..0c7054ec0 100644 --- a/examples/audiobook-curator/src/components/audiobook-card.tsx +++ b/examples/audiobook-curator/src/components/audiobook-card.tsx @@ -1,36 +1,10 @@ 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'; +import type { EditionCardProps, FileCardProps } from './view-models.ts'; -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(', '); -}; +export type { EditionCardProps, FileCardProps } from './view-models.ts'; const seconds = (value: number): string => { const rounded = Math.max(0, Math.round(value)); @@ -42,61 +16,52 @@ const seconds = (value: number): string => { : `${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(' / '); +interface CardCoreProps { + readonly fields: readonly Field[]; + readonly title: string; +} + +const CardCore = ({ fields, title }: CardCoreProps) => ( + <> + {`### ${title}`} + + +); + +export const FileCard = ({ + author, + durationSeconds, + format, + narrator, + path, + title, +}: FileCardProps) => { const fields: Field[] = [ - { label: 'File', value: file.path }, + { label: 'File', value: 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 }]), + ...(durationSeconds === undefined ? [] : [{ label: 'Duration', value: seconds(durationSeconds) }]), + ...(format === undefined ? [] : [{ label: 'Format', value: format }]), ]; - return ( - <> - {`### ${title}`} - - - ); + return ; }; -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; +export const EditionCard = ({ + asin, + author, + durationSeconds, + format, + narrator, + region, + title, +}: EditionCardProps) => { 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)! }]), + ...(durationSeconds === undefined ? [] : [{ label: 'Duration', value: seconds(durationSeconds) }]), + ...(format === undefined ? [] : [{ label: 'Format', value: format }]), + ...(region === undefined ? [] : [{ label: 'Region', value: region }]), + ...(asin === undefined ? [] : [{ label: 'ASIN', value: 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)}`); - } - } + return ; }; diff --git a/examples/audiobook-curator/src/components/candidate-group-callout.tsx b/examples/audiobook-curator/src/components/candidate-group-callout.tsx new file mode 100644 index 000000000..1dccdcaba --- /dev/null +++ b/examples/audiobook-curator/src/components/candidate-group-callout.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { Callout, FileList } from './primitives.tsx'; + +export interface CandidateGroupCalloutProps { + readonly files: readonly string[]; + readonly identityKey: string; + readonly kind: 'duplicate' | 'multipart'; + readonly reviewNote: string; +} + +const candidateGroupLabel = { + duplicate: 'Duplicate candidate group', + multipart: 'Multipart candidate group', +} as const; + +export const CandidateGroupCallout = ({ + files, + identityKey, + kind, + reviewNote, +}: CandidateGroupCalloutProps) => ( + <> + + {`${candidateGroupLabel[kind]} ${identityKey}. ${reviewNote}`} + + + +); diff --git a/examples/audiobook-curator/src/components/candidate-ranking.tsx b/examples/audiobook-curator/src/components/candidate-ranking.tsx index f29b6b719..dc410c627 100644 --- a/examples/audiobook-curator/src/components/candidate-ranking.tsx +++ b/examples/audiobook-curator/src/components/candidate-ranking.tsx @@ -3,18 +3,25 @@ import React from 'react'; import type { AudibleSearchReceipt, AudibleSelectionReceipt } from '../audible.ts'; import type { AcousticIdentifyReceipt } from '../evidence.ts'; -import { AudiobookCard } from './audiobook-card.tsx'; +import { EditionCard } from './audiobook-card.tsx'; import { Callout, DataList } from './primitives.tsx'; +import { editionCardModel } from './view-models.ts'; -type RankingReceipt = AcousticIdentifyReceipt | AudibleSearchReceipt | AudibleSelectionReceipt; +export interface SearchRankingProps { + readonly receipt: AudibleSearchReceipt; +} + +export interface IdentifyRankingProps { + readonly receipt: AcousticIdentifyReceipt; +} -export interface CandidateRankingProps { - readonly receipt: RankingReceipt; +export interface SelectionRankingProps { + readonly receipt: AudibleSelectionReceipt; } const maximumCandidates = 10; -const searchRanking = (receipt: AudibleSearchReceipt) => ( +export const SearchRanking = ({ receipt }: SearchRankingProps) => ( <> ( {`## 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.errors.map((row, index) => ( + {`${row.region}: ${row.error}`} + ))} {receipt.reviewNote} ); -const identifyRanking = (receipt: AcousticIdentifyReceipt) => ( +export const IdentifyRanking = ({ receipt }: IdentifyRankingProps) => ( <> ( {`## Attempt ${String(index + 1)} · score ${String(attempt.score ?? 'unavailable')} · ${String(attempt.region ?? 'unknown region')} · ${String(attempt.status ?? 'unknown status')}`} - + ))} {receipt.attempts.length > maximumCandidates @@ -61,29 +68,14 @@ const identifyRanking = (receipt: AcousticIdentifyReceipt) => ( ); -const selectionRanking = (receipt: AudibleSelectionReceipt) => ( +export const SelectionRanking = ({ receipt }: SelectionRankingProps) => ( <> - + {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 index 5c4152590..7786fc39f 100644 --- a/examples/audiobook-curator/src/components/chapter-outline.tsx +++ b/examples/audiobook-curator/src/components/chapter-outline.tsx @@ -5,13 +5,11 @@ 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; + readonly chapters: readonly DisplayChapter[]; } -interface DisplayChapter { +export interface DisplayChapter { readonly endSeconds: number; readonly number: number; readonly startSeconds: number; @@ -28,35 +26,26 @@ const timestamp = (seconds: number): string => { 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')} - - ); -}; +export const chaptersFromApplyReceipt = (receipt: ChapterReceipt): readonly DisplayChapter[] => + receipt.chapters.map((chapter, index) => ({ ...chapter, number: index + 1 })); + +export const chaptersFromConvertReceipt = (receipt: ConvertReceipt): readonly DisplayChapter[] => + receipt.expectedChapters; + +export const chaptersFromAuditReceipt = (receipt: IntegrityAuditReceipt): readonly DisplayChapter[] => + receipt.chapters; + +export const ChapterOutline = ({ chapters }: ChapterOutlineProps) => ( + + {[ + `## 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 index 7ae01fb5a..4d95bd1db 100644 --- a/examples/audiobook-curator/src/components/curator-document.tsx +++ b/examples/audiobook-curator/src/components/curator-document.tsx @@ -1,4 +1,4 @@ -import { Agent, type JsonValue } from '@agent-bundle/runtime'; +import { Agent } from '@agent-bundle/runtime'; import React, { type ReactNode } from 'react'; import type { AudibleCacheReceipt, AudibleSearchReceipt, AudibleSelectionReceipt } from '../audible.ts'; @@ -33,7 +33,7 @@ export interface CuratorDocumentProps { } 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 index 654c7eb93..8cddfd18f 100644 --- a/examples/audiobook-curator/src/components/evidence-trail.tsx +++ b/examples/audiobook-curator/src/components/evidence-trail.tsx @@ -4,13 +4,19 @@ 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 AcousticTrailProps { + readonly receipt: AcousticReceipt; +} + +export interface IdentifyTrailProps { + readonly receipt: AcousticIdentifyReceipt; +} -export interface EvidenceTrailProps { - readonly receipt: EvidenceReceipt; +export interface WhisperTrailProps { + readonly receipt: WhisperReceipt; } -const acousticTrail = (receipt: AcousticReceipt) => ( +export const AcousticTrail = ({ receipt }: AcousticTrailProps) => ( <> ( ); -const identifyTrail = (receipt: AcousticIdentifyReceipt) => ( +export const IdentifyTrail = ({ receipt }: IdentifyTrailProps) => ( <> {[ @@ -57,7 +63,7 @@ const identifyTrail = (receipt: AcousticIdentifyReceipt) => ( ); -const whisperTrail = (receipt: WhisperReceipt) => ( +export const WhisperTrail = ({ receipt }: WhisperTrailProps) => ( <> ( ); - -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/headlines.ts b/examples/audiobook-curator/src/components/headlines.ts new file mode 100644 index 000000000..c22140df4 --- /dev/null +++ b/examples/audiobook-curator/src/components/headlines.ts @@ -0,0 +1,23 @@ +import type { AudibleSearchReceipt } from '../audible.ts'; +import type { ConvertReceipt } from '../conversion.ts'; +import type { IntegrityAuditReceipt } from '../integrity-audit.ts'; +import type { InventoryReceipt, SelectionReceipt } from '../library.ts'; + +export const inventoryHeadline = (receipt: InventoryReceipt): string => + `Inventoried ${receipt.summary.files} media files with ${receipt.summary.errors} retained errors.`; + +export const selectionHeadline = (receipt: SelectionReceipt): string => { + const reviewCount = receipt.selections.filter((selection) => selection.reviewRequired).length; + return `Selected ${receipt.selections.length} source groups; ${reviewCount} require review.`; +}; + +export const audibleSearchHeadline = (receipt: AudibleSearchReceipt): string => + `Ranked ${receipt.candidates.length} Audible candidates across reviewed regions; human selection is required.`; + +export const integrityAuditHeadline = (receipt: IntegrityAuditReceipt): string => + `Audited ${receipt.bytes} bytes with SHA-256 ${receipt.sha256}; status is ${receipt.status}.`; + +export const convertHeadline = (receipt: ConvertReceipt): string => + receipt.status === 'planned' + ? `Planned ${receipt.audioMode} output at ${receipt.output}; sources remain unchanged.` + : `Converted and verified ${receipt.output}; sources remain unchanged.`; diff --git a/examples/audiobook-curator/src/components/integrity-report.tsx b/examples/audiobook-curator/src/components/integrity-report.tsx index db6ee88d1..165a0a677 100644 --- a/examples/audiobook-curator/src/components/integrity-report.tsx +++ b/examples/audiobook-curator/src/components/integrity-report.tsx @@ -5,13 +5,23 @@ 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 IntegrityAuditReportProps { + readonly receipt: IntegrityAuditReceipt; +} + +export interface MetadataIntegrityReportProps { + readonly receipt: MetadataReceipt; +} -export interface IntegrityReportProps { - readonly receipt: IntegrityReceipt; +export interface ChapterIntegrityReportProps { + readonly receipt: ChapterReceipt; } -const auditReport = (receipt: IntegrityAuditReceipt) => { +export interface ConversionIntegrityReportProps { + readonly receipt: ConvertReceipt; +} + +export const IntegrityAuditReport = ({ receipt }: IntegrityAuditReportProps) => { const fields: Field[] = [ { label: 'Audit status', value: receipt.status }, { label: 'File', value: receipt.file }, @@ -38,7 +48,7 @@ const auditReport = (receipt: IntegrityAuditReceipt) => { ); }; -const metadataReport = (receipt: MetadataReceipt) => ( +export const MetadataIntegrityReport = ({ receipt }: MetadataIntegrityReportProps) => ( <> ( ); -const chapterReport = (receipt: ChapterReceipt) => ( +export const ChapterIntegrityReport = ({ receipt }: ChapterIntegrityReportProps) => ( <> ( ); -const conversionReport = (receipt: ConvertReceipt) => ( +export const ConversionIntegrityReport = ({ receipt }: ConversionIntegrityReportProps) => ( <> ( ); - -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-analysis.tsx b/examples/audiobook-curator/src/components/library-analysis.tsx index 0e5e441f4..abbcc10b5 100644 --- a/examples/audiobook-curator/src/components/library-analysis.tsx +++ b/examples/audiobook-curator/src/components/library-analysis.tsx @@ -4,7 +4,8 @@ import { Agent } from '@agent-bundle/runtime'; import React from 'react'; import type { LibraryAuditReceipt } from '../library.ts'; -import { Callout, DataList } from './primitives.tsx'; +import { CandidateGroupCallout } from './candidate-group-callout.tsx'; +import { Callout, DataList, FileList } from './primitives.tsx'; export interface LibraryAnalysisProps { readonly receipt: LibraryAuditReceipt; @@ -63,21 +64,31 @@ export const LibraryAnalysis = async ({ receipt, signal }: LibraryAnalysisProps) ]} /> {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.`} - + <> + + {`Could not measure ${String(unavailable.length)} candidate files. Reclaimable bytes include only files that remain measurable.`} + + `${file.path} (${file.error})`)} /> + ) : 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}`} - + `part ${String(file.part)} ${file.path}`)} + identityKey={group.identityKey} + key={`${group.directory}/${group.identityKey}`} + kind="multipart" + reviewNote={receipt.reviewNote} + /> ))} {receipt.duplicateCandidates.length === 0 && receipt.multipartCandidates.length === 0 ? ( diff --git a/examples/audiobook-curator/src/components/library-shelf.tsx b/examples/audiobook-curator/src/components/library-shelf.tsx index 3747a8aa6..d4b996df0 100644 --- a/examples/audiobook-curator/src/components/library-shelf.tsx +++ b/examples/audiobook-curator/src/components/library-shelf.tsx @@ -3,22 +3,26 @@ 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'; +import { FileCard } from './audiobook-card.tsx'; +import { CandidateGroupCallout } from './candidate-group-callout.tsx'; +import { Callout, DataList, FileList, type Field } from './primitives.tsx'; +import { fileCardModel } from './view-models.ts'; -type ShelfReceipt = InspectionReceipt | InventoryReceipt | LibraryAuditReceipt | SelectionReceipt; - -export interface LibraryShelfProps { - readonly receipt: ShelfReceipt; +export interface InspectionShelfProps { + readonly receipt: InspectionReceipt; } const maximumCards = 20; -const remaining = (count: number): React.ReactNode => count > maximumCards +interface RemainingFilesProps { + readonly count: number; +} + +const RemainingFiles = ({ count }: RemainingFilesProps) => count > maximumCards ? {`_+${String(count - maximumCards)} more files retained in the structured receipt._`} : null; -const inspectionShelf = (receipt: InspectionReceipt) => ( +export const InspectionShelf = ({ receipt }: InspectionShelfProps) => ( <> ( { label: 'Root', value: receipt.root }, ]} /> {receipt.files.slice(0, maximumCards).map((file) => ( - + ))} - {remaining(receipt.files.length)} + ); -const inventoryShelf = (receipt: InventoryReceipt) => ( +export interface InventoryShelfProps { + readonly receipt: InventoryReceipt; +} + +export const InventoryShelf = ({ receipt }: InventoryShelfProps) => ( <> ( { label: 'Source', value: receipt.source }, ]} /> {receipt.files.slice(0, maximumCards).map((file) => ( - + + ))} + + {receipt.errors.map((row, index) => ( + {`${row.path}: ${row.error}`} ))} - {remaining(receipt.files.length)} - {receipt.errors.length > 0 - ? {receipt.errors.map((row) => `${row.path}: ${row.error}`).join('; ')} - : null} ); -const auditShelf = (receipt: LibraryAuditReceipt) => { +export interface AuditShelfProps { + readonly receipt: LibraryAuditReceipt; +} + +export const AuditShelf = ({ receipt }: AuditShelfProps) => { const summaryFields: Field[] = [ { label: 'Files', value: receipt.summary.files }, { label: 'Total bytes', value: receipt.summary.bytes }, @@ -66,18 +78,26 @@ const auditShelf = (receipt: LibraryAuditReceipt) => { <> {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}`} - + `part ${String(file.part)} ${file.path}`)} + identityKey={group.identityKey} + key={`${group.directory}/${group.identityKey}`} + kind="multipart" + reviewNote={receipt.reviewNote} + /> ))} {receipt.duplicateCandidates.length === 0 && receipt.multipartCandidates.length === 0 ? {receipt.reviewNote} @@ -86,7 +106,11 @@ const auditShelf = (receipt: LibraryAuditReceipt) => { ); }; -const selectionShelf = (receipt: SelectionReceipt) => ( +export interface SelectionShelfProps { + readonly receipt: SelectionReceipt; +} + +export const SelectionShelf = ({ receipt }: SelectionShelfProps) => ( <> ( {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')} - + + {`Selected because: ${selection.reason}.`} + {selection.alternates.length === 0 + ? Alternates: none. + : ( + <> + Alternates: + file.path)} /> + + )} + {`Duration spread: ${String(selection.durationSpreadSeconds)} seconds.`} {selection.reviewRequired ? {selection.reviewReason ?? 'The source group requires human review.'} : null} @@ -114,20 +141,3 @@ const selectionShelf = (receipt: SelectionReceipt) => ( : 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 index 5acac1d25..8d043a38c 100644 --- a/examples/audiobook-curator/src/components/mutation-receipt.tsx +++ b/examples/audiobook-curator/src/components/mutation-receipt.tsx @@ -5,13 +5,23 @@ 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 MetadataMutationProps { + readonly receipt: MetadataReceipt; +} + +export interface ChapterMutationProps { + readonly receipt: ChapterReceipt; +} -export interface MutationReceiptProps { - readonly receipt: PlanFirstReceipt; +export interface ConversionMutationProps { + readonly receipt: ConvertReceipt; } -const metadataMutation = (receipt: MetadataReceipt) => ( +export interface PrepareMutationProps { + readonly receipt: PrepareReceipt; +} + +export const MetadataMutation = ({ receipt }: MetadataMutationProps) => ( <> ( ); -const chapterMutation = (receipt: ChapterReceipt) => ( +export const ChapterMutation = ({ receipt }: ChapterMutationProps) => ( <> ( ); -const conversionMutation = (receipt: ConvertReceipt) => ( +export const ConversionMutation = ({ receipt }: ConversionMutationProps) => ( <> ( ); -const prepareMutation = (receipt: PrepareReceipt) => ( +export const PrepareMutation = ({ receipt }: PrepareMutationProps) => ( <> ( ); - -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 index fb8fad215..0d5922eaa 100644 --- a/examples/audiobook-curator/src/components/primitives.tsx +++ b/examples/audiobook-curator/src/components/primitives.tsx @@ -16,6 +16,14 @@ export const DataList = ({ fields }: DataListProps) => ( ); +export interface FileListProps { + readonly files: readonly string[]; +} + +export const FileList = ({ files }: FileListProps) => ( + {files.map((file) => `- ${file}`).join('\n')} +); + export interface CalloutProps { readonly children: string; readonly tone: 'error' | 'review' | 'warning'; diff --git a/examples/audiobook-curator/src/components/view-models.ts b/examples/audiobook-curator/src/components/view-models.ts new file mode 100644 index 000000000..65c4a78ee --- /dev/null +++ b/examples/audiobook-curator/src/components/view-models.ts @@ -0,0 +1,133 @@ +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'; + +export interface FileCardModel { + readonly author?: string; + readonly durationSeconds?: number; + readonly format?: string; + readonly narrator?: string; + readonly path: string; + readonly title: string; +} + +export type FileCardProps = FileCardModel; + +export interface EditionCardModel { + readonly asin?: string; + readonly author?: string; + readonly durationSeconds?: number; + readonly format?: string; + readonly narrator?: string; + readonly region?: string; + readonly title: string; +} + +export type EditionCardProps = EditionCardModel; + +export type FileCardSource = + | InspectionReceipt['files'][number] + | LibraryAuditFile + | MediaRecord; + +export type EditionCardSource = + | AcousticIdentifyReceipt['attempts'][number] + | AudibleCandidate; + +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 record = (value: unknown): Readonly> | undefined => + value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Readonly> + : 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()]; + const contributor = record(entry); + if (contributor === undefined) return []; + const name = text(contributor.name ?? contributor.display_name); + return name === undefined ? [] : [name]; + }); + return names.length === 0 ? undefined : names.join(', '); +}; + +const normalizedFileCard = ( + file: Readonly<{ + durationSeconds?: number; + format?: string; + path: string; + relativePath?: string; + tags?: Readonly>; + }>, +): FileCardModel => { + const tags = file.tags ?? {}; + const title = text(tags.title ?? tags.album) ?? file.relativePath ?? file.path; + const author = text(tags.artist ?? tags.album_artist ?? tags.author); + const narrator = text(tags.composer); + return { + ...(author === undefined ? {} : { author }), + ...(file.durationSeconds === undefined ? {} : { durationSeconds: file.durationSeconds }), + ...(file.format === undefined || file.format === '' ? {} : { format: file.format }), + ...(narrator === undefined ? {} : { narrator }), + path: file.path, + title, + }; +}; + +const inspectionFileCardModel = ( + file: InspectionReceipt['files'][number], +): FileCardModel => normalizedFileCard(file); + +const libraryAuditFileCardModel = ( + file: LibraryAuditFile, +): FileCardModel => normalizedFileCard({ + ...(file.durationSeconds === undefined ? {} : { durationSeconds: file.durationSeconds }), + format: [file.codec, file.extension].filter((value) => value !== undefined && value !== '').join(' / '), + path: file.path, + relativePath: file.relativePath, + ...(file.tags === undefined ? {} : { tags: file.tags }), +}); + +const mediaFileCardModel = ( + file: MediaRecord, +): FileCardModel => normalizedFileCard({ + durationSeconds: file.durationSeconds, + format: [file.codec, file.extension].filter((value) => value !== '').join(' / '), + path: file.path, + relativePath: file.relativePath, + tags: file.tags, +}); + +export const fileCardModel = (file: FileCardSource): FileCardModel => { + if ('format' in file) return inspectionFileCardModel(file); + if ('error' in file) return libraryAuditFileCardModel(file); + return mediaFileCardModel(file); +}; + +export const editionCardModel = (edition: EditionCardSource): EditionCardModel => { + 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 format = text(edition.format_type); + const region = text(edition.region); + const asin = text(edition.asin); + return { + ...(asin === undefined ? {} : { asin }), + ...(author === undefined ? {} : { author }), + ...(runtimeMinutes === undefined ? {} : { durationSeconds: runtimeMinutes * 60 }), + ...(format === undefined ? {} : { format }), + ...(narrator === undefined ? {} : { narrator }), + ...(region === undefined ? {} : { region }), + title, + }; +}; diff --git a/examples/audiobook-curator/src/conversion.ts b/examples/audiobook-curator/src/conversion.ts index 808c18ce4..0b88e5220 100644 --- a/examples/audiobook-curator/src/conversion.ts +++ b/examples/audiobook-curator/src/conversion.ts @@ -36,12 +36,12 @@ import { } from './library.ts'; import { runMediaProcess, type MediaProcess } from './media-process.ts'; -export interface ChapterRow { +export type ChapterRow = { readonly endSeconds: number; readonly number: number; readonly startSeconds: number; readonly title: string; -} +}; export interface ConvertInput { readonly apply?: boolean; @@ -63,12 +63,12 @@ export interface ConvertInput { readonly year?: string; } -export interface ConvertReceipt { +export type ConvertReceipt = { readonly apply: boolean; readonly audioMode: string; readonly audioSha256?: string; readonly durationDeltaSeconds?: number; - readonly embeddedMetadata: Readonly>; + readonly embeddedMetadata: Readonly>; readonly engine: 'audiobook-forge' | 'ffmpeg'; readonly expectedChapterCount: number; readonly expectedChapters: readonly ChapterRow[]; @@ -85,7 +85,7 @@ export interface ConvertReceipt { readonly probe?: MediaRecord; readonly sourcesPreserved: true; readonly status: 'converted-verified' | 'planned'; -} +}; export interface ConversionDependencies extends LibraryDependencies { readonly cpuCount?: number; diff --git a/examples/audiobook-curator/src/curator-core.ts b/examples/audiobook-curator/src/curator-core.ts index e37621273..95024536e 100644 --- a/examples/audiobook-curator/src/curator-core.ts +++ b/examples/audiobook-curator/src/curator-core.ts @@ -23,26 +23,26 @@ export interface CuratorDependencies { readonly signal?: AbortSignal; } -export interface AudioProbe { +export type AudioProbe = { readonly channels?: number; readonly codec: string; readonly durationSeconds: number; readonly format: string; readonly sampleRate?: number; readonly tags: Readonly>; -} +}; -export interface InspectedAudioFile extends AudioProbe { +export type InspectedAudioFile = AudioProbe & { readonly bytes: number; readonly path: string; -} +}; -export interface InspectionReceipt { +export type InspectionReceipt = { readonly files: readonly InspectedAudioFile[]; readonly operation: 'inspect'; readonly root: string; readonly totalBytes: number; -} +}; export interface PrepareInput { readonly apply?: boolean; @@ -51,13 +51,13 @@ export interface PrepareInput { readonly source: string; } -export interface PrepareReceipt { +export type PrepareReceipt = { readonly applied: boolean; readonly operation: 'prepare'; readonly output: string; readonly probe: AudioProbe; readonly source: string; -} +}; const dependencies = (options: CuratorDependencies) => ({ ffmpeg: options.ffmpeg ?? 'ffmpeg', diff --git a/examples/audiobook-curator/src/evidence.ts b/examples/audiobook-curator/src/evidence.ts index 11cad1d99..1286bec99 100644 --- a/examples/audiobook-curator/src/evidence.ts +++ b/examples/audiobook-curator/src/evidence.ts @@ -2,6 +2,8 @@ import { lstat, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; +import type { JsonObject, JsonValue } from '@agent-bundle/runtime'; + import { defaultCuratorHttpClient, requestWithAttempts, @@ -22,7 +24,7 @@ export type AcousticMatcher = ( source: string, sample: string, options: AcousticMatchOptions, -) => Promise>>; +) => Promise; export interface AcousticVerifyInput { readonly audiolocatePython?: string; @@ -36,23 +38,23 @@ export interface AcousticVerifyInput { readonly verbose?: boolean; } -export interface AcousticReceipt { +export type AcousticReceipt = { readonly asin: string; - readonly audible: Readonly>; + readonly audible: JsonObject; readonly exitCode: 0 | 2; readonly file: string; - readonly fingerprint: Readonly>; + readonly fingerprint: JsonObject; readonly generatedAt: string; readonly mutation: false; readonly operation: 'audiolocate'; readonly region: AudibleRegion; readonly verifiedRecording: boolean; -} +}; export interface AcousticIdentifyInput { readonly all?: boolean; readonly attempts?: number; - readonly candidates: readonly Readonly>[]; + readonly candidates: readonly JsonObject[]; readonly candidatesReport: string; readonly chunkSeconds?: number; readonly file: string; @@ -61,20 +63,31 @@ export interface AcousticIdentifyInput { readonly verbose?: boolean; } -export interface AcousticIdentifyReceipt { - readonly attempts: readonly Readonly>[]; +export type AcousticIdentifyReceipt = { + readonly attempts: readonly JsonObject[]; readonly candidatesReport: string; readonly exitCode: 0 | 2; readonly file: string; readonly generatedAt: string; - readonly identified?: Readonly<{ readonly asin: string; readonly region: string; readonly title?: unknown }>; + readonly identified?: Readonly<{ readonly asin: string; readonly region: string; readonly title?: JsonValue }>; readonly mutation: false; readonly operation: 'acoustic-identify'; readonly reviewNote: string; readonly stopOnMatch: boolean; readonly top: number; readonly verifiedRecording: boolean; -} +}; + +type AcousticIdentifyAttempt = { + readonly asin?: string; + readonly fingerprint?: JsonObject; + readonly reason?: string; + readonly region: AudibleRegion; + readonly sampleUrl?: JsonValue; + readonly score?: JsonValue; + readonly status: 'error' | 'matched' | 'no-match' | 'skipped'; + readonly title?: JsonValue; +}; export interface WhisperInput { readonly author?: string; @@ -90,15 +103,15 @@ export interface WhisperInput { readonly windowSeconds?: number; } -export interface WhisperWindow { +export type WhisperWindow = { readonly index: number; readonly sampleSeconds: number; readonly startSeconds: number; readonly text: string; readonly usable: boolean; -} +}; -export interface WhisperReceipt { +export type WhisperReceipt = { readonly exitCode: 0 | 2; readonly expectedAuthor?: string; readonly expectedTitle?: string; @@ -113,7 +126,7 @@ export interface WhisperReceipt { readonly status: 'insufficient-spoken-windows' | 'transcript-ready'; readonly usableWindows: number; readonly windows: readonly WhisperWindow[]; -} +}; export interface EvidenceDependencies extends LibraryDependencies { readonly audiolocatePython?: string; @@ -135,7 +148,9 @@ const pythonMatcher = (python: string, process: MediaProcess): AcousticMatcher = const result = await process(python, ['-c', script, source, sample, String(options.chunkSeconds), options.verbose ? '1' : '0'], { signal: options.signal }); const line = result.stdout.split(/\r?\n/u).findLast((candidate) => candidate.startsWith(resultMarker)); if (line === undefined) throw new CuratorError('Audiolocate emitted no structured result.'); - return Object.freeze(asRecord(JSON.parse(line.slice(resultMarker.length)))); + const parsed = JSON.parse(line.slice(resultMarker.length)) as JsonObject; + asRecord(parsed); + return Object.freeze(parsed); } catch (error) { throw new CuratorError(`Audiolocate is optional; install it for ${python}, or inject an acoustic matcher. ${error instanceof Error ? error.message : ''}`.trim()); } @@ -149,7 +164,7 @@ const productUrl = (region: AudibleRegion, asin: string): string => { const sampleMatch = async ( input: AcousticVerifyInput, dependencies: EvidenceDependencies, -): Promise<{ audible: Readonly>; fingerprint: Readonly> }> => { +): Promise<{ audible: JsonObject; fingerprint: JsonObject }> => { const region = input.region ?? 'us'; const attempts = Math.max(1, Math.min(input.attempts ?? 4, 10)); const http = dependencies.http ?? defaultCuratorHttpClient; @@ -179,7 +194,7 @@ const sampleMatch = async ( signal: dependencies.signal, verbose: input.verbose === true, }); - return { audible: Object.freeze(audible), fingerprint }; + return { audible: Object.freeze(audible) as JsonObject, fingerprint }; } finally { await rm(work, { force: true, recursive: true }); } }; @@ -229,12 +244,17 @@ export const identifyAudibleSample = async ( }); const top = Math.max(1, Math.min(input.top ?? 3, 10)); const selected = input.all === true ? unique : unique.slice(0, top); - const attempts: Record[] = []; - let identified: { asin: string; region: string; title?: unknown } | undefined; + const attempts: AcousticIdentifyAttempt[] = []; + let identified: { asin: string; region: string; title?: JsonValue } | undefined; for (const candidate of selected) { const asin = String(candidate.asin ?? ''); const region = String(candidate.region ?? 'us') as AudibleRegion; - const base = { asin: asin || undefined, region, score: asRecord(candidate.evidence).score, title: candidate.title }; + const base = { + asin: asin || undefined, + region, + score: asRecord(candidate.evidence).score as JsonValue | undefined, + title: candidate.title, + }; if (asin === '') { attempts.push({ ...base, reason: 'candidate has no ASIN', status: 'skipped' }); continue; diff --git a/examples/audiobook-curator/src/integrity-audit.ts b/examples/audiobook-curator/src/integrity-audit.ts index 202ddb73a..7d464dd66 100644 --- a/examples/audiobook-curator/src/integrity-audit.ts +++ b/examples/audiobook-curator/src/integrity-audit.ts @@ -13,7 +13,7 @@ export interface IntegrityAuditInput { readonly receipt?: string; } -export interface IntegrityAuditReceipt { +export type IntegrityAuditReceipt = { readonly audioSha256: string; readonly bytes: number; readonly chapterIssues: readonly string[]; @@ -32,7 +32,7 @@ export interface IntegrityAuditReceipt { readonly status: 'not-requested' | 'review-required' | 'verified'; }>; readonly status: 'review-required' | 'verified'; -} +}; export interface IntegrityAuditDependencies extends LibraryDependencies { readonly ffmpeg?: string; diff --git a/examples/audiobook-curator/src/library.ts b/examples/audiobook-curator/src/library.ts index 7fbd60249..b45dc56c4 100644 --- a/examples/audiobook-curator/src/library.ts +++ b/examples/audiobook-curator/src/library.ts @@ -9,7 +9,7 @@ const maximumFiles = 4096; export type MediaDetails = Record; -export interface MediaRecord { +export type MediaRecord = { readonly artworkStreams?: number; readonly bitDepth?: number; readonly bitRate?: number; @@ -25,9 +25,9 @@ export interface MediaRecord { readonly sampleFormat?: string; readonly sampleRate: number; readonly tags: Readonly>; -} +}; -export interface InventoryReceipt { +export type InventoryReceipt = { readonly errors: readonly { readonly error: string; readonly path: string }[]; readonly exitCode: 0 | 1; readonly files: readonly MediaRecord[]; @@ -41,9 +41,9 @@ export interface InventoryReceipt { errors: number; files: number; }>; -} +}; -export interface LibraryAuditFile extends Partial { +export type LibraryAuditFile = Partial & { readonly bytes: number; readonly error: string | null; readonly extension: string; @@ -56,9 +56,9 @@ export interface LibraryAuditFile extends Partial { }>; readonly path: string; readonly relativePath: string; -} +}; -export interface LibraryAuditReceipt { +export type LibraryAuditReceipt = { readonly duplicateCandidates: readonly { readonly files: readonly string[]; readonly identityKey: string }[]; readonly exitCode: 0 | 1; readonly files: readonly LibraryAuditFile[]; @@ -73,9 +73,9 @@ export interface LibraryAuditReceipt { readonly reviewNote: string; readonly sources: readonly string[]; readonly summary: Readonly>; -} +}; -export interface SelectionRow { +export type SelectionRow = { readonly alternates: readonly MediaRecord[]; readonly durationSpreadSeconds: number; readonly identityKey: string; @@ -83,15 +83,15 @@ export interface SelectionRow { readonly reviewReason: string | null; readonly reviewRequired: boolean; readonly selected: MediaRecord; -} +}; -export interface SelectionReceipt { +export type SelectionReceipt = { readonly generatedAt: string; readonly inventory?: string; readonly mutation: false; readonly operation: 'quality-selection'; readonly selections: readonly SelectionRow[]; -} +}; export interface LibraryDependencies { readonly ffprobe?: string; 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 0b88b9e3f..a6195da7b 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 @@ -2,18 +2,21 @@ import { agent } from '@agent-bundle/runtime'; import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { ChapterOutline } from '../../../components/chapter-outline.js'; +import { ChapterOutline, chaptersFromApplyReceipt } 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 { ChapterIntegrityReport } from '../../../components/integrity-report.js'; +import { ChapterMutation } 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; -export const config = {"annotations":{"destructiveHint":true,"readOnlyHint":false},"description":"Plan or explicitly apply verified chapter rows while preserving all non-chapter media state."}; +export const config = { + annotations: { destructiveHint: true, readOnlyHint: false }, + description: 'Plan or explicitly apply verified chapter rows while preserving all non-chapter media state.', +}; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; @@ -39,9 +42,9 @@ export default async function Route({ input, signal }: ToolRouteProps - - - + + + {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 8fcbff9d8..4596788df 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 @@ -4,15 +4,18 @@ 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 { MetadataIntegrityReport } from '../../../components/integrity-report.js'; +import { MetadataMutation } 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; -export const config = {"annotations":{"destructiveHint":true,"readOnlyHint":false},"description":"Plan or explicitly apply verified catalog metadata and artwork while preserving every audio stream."}; +export const config = { + annotations: { destructiveHint: true, readOnlyHint: false }, + description: 'Plan or explicitly apply verified catalog metadata and artwork while preserving every audio stream.', +}; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; @@ -38,8 +41,8 @@ export default async function Route({ input, signal }: ToolRouteProps - - + + {shelf} ); 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 1da09d5cd..c34856f28 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx @@ -1,15 +1,19 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { ChapterOutline } from '../../../components/chapter-outline.js'; +import { ChapterOutline, chaptersFromAuditReceipt } from '../../../components/chapter-outline.js'; import { CuratorDocument } from '../../../components/curator-document.js'; -import { IntegrityReport } from '../../../components/integrity-report.js'; +import { integrityAuditHeadline } from '../../../components/headlines.js'; +import { IntegrityAuditReport } from '../../../components/integrity-report.js'; import type { IntegrityAuditReceipt } from '../../../integrity-audit.js'; import { defaultOutputOperations, outputOperations } from '../../../operations/output.js'; const operation = outputOperations(defaultOutputOperations).audit; -export const config = {"annotations":{"readOnlyHint":false},"description":"Validate chapter structure, optional conversion mapping, file/audio hashes, probe facts, and optional full decode."}; +export const config = { + annotations: { readOnlyHint: false }, + description: 'Validate chapter structure, optional conversion mapping, file/audio hashes, probe facts, and optional full decode.', +}; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; @@ -17,11 +21,11 @@ export default async function Route({ input, signal }: ToolRouteProps - - + + ); } 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 a73c4f347..861eec11e 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx @@ -2,16 +2,20 @@ 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 { FileCard } from '../../../components/audiobook-card.js'; import { CuratorDocument } from '../../../components/curator-document.js'; import { LibraryAnalysis } from '../../../components/library-analysis.js'; import { DataList } from '../../../components/primitives.js'; +import { fileCardModel } from '../../../components/view-models.js'; import type { LibraryAuditReceipt } from '../../../library.js'; import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; const operation = discoveryOperations(defaultDiscoveryOperations).libraryAudit; -export const config = {"annotations":{"readOnlyHint":false},"description":"Audit audiobook library metadata, duplicates, and multipart evidence without deletion advice."}; +export const config = { + annotations: { readOnlyHint: false }, + description: 'Audit audiobook library metadata, duplicates, and multipart evidence without deletion advice.', +}; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; @@ -39,7 +43,7 @@ export default async function Route({ input, signal }: ToolRouteProps {receipt.files.slice(0, 20).map((file) => ( - + ))} {receipt.files.length > 20 ? {`_+${String(receipt.files.length - 20)} more files retained in the structured receipt._`} 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 5b90eff00..ba78bbfd1 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 @@ -8,7 +8,10 @@ import { defaultAudibleOperations, audibleOperations } from '../../../operations const operation = audibleOperations(defaultAudibleOperations).audibleCache; -export const config = {"annotations":{"openWorldHint":true,"readOnlyHint":false},"description":"Cache a reviewed Audible edition and retained source evidence."}; +export const config = { + annotations: { openWorldHint: true, readOnlyHint: false }, + description: 'Cache a reviewed Audible edition and retained source evidence.', +}; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; 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 9615be027..67508cf4b 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/convert_audiobook.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/convert_audiobook.tsx @@ -1,29 +1,30 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { ChapterOutline } from '../../../components/chapter-outline.js'; +import { ChapterOutline, chaptersFromConvertReceipt } 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 { convertHeadline } from '../../../components/headlines.js'; +import { ConversionIntegrityReport } from '../../../components/integrity-report.js'; +import { ConversionMutation } from '../../../components/mutation-receipt.js'; import type { ConvertReceipt } from '../../../conversion.js'; import { defaultOutputOperations, outputOperations } from '../../../operations/output.js'; const operation = outputOperations(defaultOutputOperations).convert; -export const config = {"annotations":{"destructiveHint":true,"readOnlyHint":false},"description":"Plan or explicitly apply a verified FFmpeg or Audiobook Forge conversion while preserving sources."}; +export const config = { + annotations: { destructiveHint: true, readOnlyHint: false }, + description: 'Plan or explicitly apply a verified FFmpeg or Audiobook Forge conversion while preserving sources.', +}; 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 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 bec5e66a2..f11911ce3 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,15 +1,18 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; -import { CandidateRanking } from '../../../components/candidate-ranking.js'; +import { IdentifyRanking } from '../../../components/candidate-ranking.js'; import { CuratorDocument } from '../../../components/curator-document.js'; -import { EvidenceTrail } from '../../../components/evidence-trail.js'; +import { IdentifyTrail } from '../../../components/evidence-trail.js'; import type { AcousticIdentifyReceipt } from '../../../evidence.js'; import { defaultEvidenceOperations, evidenceOperations } from '../../../operations/evidence.js'; const operation = evidenceOperations(defaultEvidenceOperations).acousticIdentify; -export const config = {"annotations":{"openWorldHint":true,"readOnlyHint":false},"description":"Try ranked Audible candidates, retaining skips/errors and stopping at the first acoustic match by default."}; +export const config = { + annotations: { openWorldHint: true, readOnlyHint: false }, + description: 'Try ranked Audible candidates, retaining skips/errors and stopping at the first acoustic match by default.', +}; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; @@ -20,8 +23,8 @@ export default async function Route({ input, signal }: ToolRouteProps - - + + ); } 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 c55ac863c..fe77b1d3e 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/inspect_sources.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/inspect_sources.tsx @@ -2,13 +2,16 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import { CuratorDocument } from '../../../components/curator-document.js'; -import { LibraryShelf } from '../../../components/library-shelf.js'; +import { InspectionShelf } from '../../../components/library-shelf.js'; import type { InspectionReceipt } from '../../../curator-core.js'; import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; const operation = discoveryOperations(defaultDiscoveryOperations).inspect; -export const config = {"annotations":{"readOnlyHint":true},"description":"Inspect a bounded directory tree and report supported audiobook media without changing it."}; +export const config = { + annotations: { readOnlyHint: true }, + description: 'Inspect a bounded directory tree and report supported audiobook media without changing it.', +}; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; @@ -19,7 +22,7 @@ export default async function Route({ input, signal }: ToolRouteProps - + ); } 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 da62a737e..941bac277 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx @@ -3,13 +3,17 @@ import type { ToolRouteProps } from 'agent-bundle'; import { z } from 'zod'; import { CuratorDocument } from '../../../components/curator-document.js'; -import { LibraryShelf } from '../../../components/library-shelf.js'; +import { inventoryHeadline } from '../../../components/headlines.js'; +import { InventoryShelf } from '../../../components/library-shelf.js'; import type { InventoryReceipt } from '../../../library.js'; import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; const operation = discoveryOperations(defaultDiscoveryOperations).inventory; -export const config = {"annotations":{"readOnlyHint":false},"description":"Inventory source audio with retained per-file probe evidence."}; +export const config = { + annotations: { readOnlyHint: false }, + description: 'Inventory source audio with retained per-file probe evidence.', +}; export const inputSchema = z.object({ source: z.string().min(1).max(4096).describe('Source audio path to inventory.'), report: z.string().min(1).max(4096).optional().describe('Optional report destination.'), @@ -21,10 +25,10 @@ export default async function Route({ input, signal }: ToolRouteProps - + ); } 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 a649ffa9f..31649aa77 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/prepare_audiobook.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/prepare_audiobook.tsx @@ -2,13 +2,16 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import { CuratorDocument } from '../../../components/curator-document.js'; -import { MutationReceipt } from '../../../components/mutation-receipt.js'; +import { PrepareMutation } from '../../../components/mutation-receipt.js'; import type { PrepareReceipt } from '../../../curator-core.js'; import { defaultOutputOperations, outputOperations } from '../../../operations/output.js'; const operation = outputOperations(defaultOutputOperations).prepare; -export const config = {"annotations":{"destructiveHint":true,"readOnlyHint":false},"description":"Plan an M4B output, or apply the plan only when apply is explicitly true."}; +export const config = { + annotations: { destructiveHint: true, readOnlyHint: false }, + description: 'Plan an M4B output, or apply the plan only when apply is explicitly true.', +}; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; @@ -19,7 +22,7 @@ export default async function Route({ input, signal }: ToolRouteProps - + ); } 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 index 6193755f9..fda5ca75a 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/review_curation_shelf.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/review_curation_shelf.tsx @@ -1,4 +1,4 @@ -import { Agent, agent, type JsonValue } from '@agent-bundle/runtime'; +import { Agent, agent } from '@agent-bundle/runtime'; import React from 'react'; import { z } from 'zod'; @@ -25,7 +25,7 @@ export default async function ReviewCurationShelf() { const state = (await agent()).state; if (state === undefined) { return ( - + Persisted curation shelf unavailable. @@ -33,7 +33,7 @@ export default async function ReviewCurationShelf() { } const shelf = CurationShelfStateSchema.parse((await state.read()).state); return ( - + Persisted curation shelf ready. 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 e065fe0af..c14ab086e 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx @@ -2,13 +2,17 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import type { AudibleSearchReceipt } from '../../../audible.js'; -import { CandidateRanking } from '../../../components/candidate-ranking.js'; +import { SearchRanking } from '../../../components/candidate-ranking.js'; import { CuratorDocument } from '../../../components/curator-document.js'; +import { audibleSearchHeadline } from '../../../components/headlines.js'; import { defaultAudibleOperations, audibleOperations } from '../../../operations/audible.js'; const operation = audibleOperations(defaultAudibleOperations).audibleSearch; -export const config = {"annotations":{"openWorldHint":true,"readOnlyHint":false},"description":"Search Audible regions and return ranked identity evidence requiring human review."}; +export const config = { + annotations: { openWorldHint: true, readOnlyHint: false }, + description: 'Search Audible regions and return ranked identity evidence requiring human review.', +}; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; @@ -16,10 +20,10 @@ export default async function Route({ input, signal }: ToolRouteProps - + ); } 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 657f007f7..f17ffebfe 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 @@ -3,7 +3,7 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import type { AudibleSelectionReceipt } from '../../../audible.js'; -import { CandidateRanking } from '../../../components/candidate-ranking.js'; +import { SelectionRanking } 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'; @@ -11,7 +11,10 @@ import { CurationShelfStateSchema } from '../../../state.js'; const operation = audibleOperations(defaultAudibleOperations).audibleSelect; -export const config = {"annotations":{"readOnlyHint":false},"description":"Record an explicit human-reviewed Audible edition choice from a candidate report."}; +export const config = { + annotations: { readOnlyHint: false }, + description: 'Record an explicit human-reviewed Audible edition choice from a candidate report.', +}; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; @@ -38,7 +41,7 @@ export default async function Route({ input, signal }: ToolRouteProps - + {shelf} ); 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 0eb594214..1f80aeb4f 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/select_sources.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/select_sources.tsx @@ -2,25 +2,28 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import { CuratorDocument } from '../../../components/curator-document.js'; -import { LibraryShelf } from '../../../components/library-shelf.js'; +import { selectionHeadline } from '../../../components/headlines.js'; +import { SelectionShelf } from '../../../components/library-shelf.js'; import type { SelectionReceipt } from '../../../library.js'; import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; const operation = discoveryOperations(defaultDiscoveryOperations).select; -export const config = {"annotations":{"readOnlyHint":false},"description":"Select strongest source encodings while retaining alternates and duration review evidence."}; +export const config = { + annotations: { readOnlyHint: false }, + description: 'Select strongest source encodings while retaining alternates and duration review evidence.', +}; 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 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 78818e01e..63e301ff0 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 @@ -2,13 +2,16 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import { CuratorDocument } from '../../../components/curator-document.js'; -import { EvidenceTrail } from '../../../components/evidence-trail.js'; +import { AcousticTrail } from '../../../components/evidence-trail.js'; import type { AcousticReceipt } from '../../../evidence.js'; import { defaultEvidenceOperations, evidenceOperations } from '../../../operations/evidence.js'; const operation = evidenceOperations(defaultEvidenceOperations).acousticVerify; -export const config = {"annotations":{"openWorldHint":true,"readOnlyHint":false},"description":"Compare a bounded Audible sample with local audio through an optional Audiolocate Python capability."}; +export const config = { + annotations: { openWorldHint: true, readOnlyHint: false }, + description: 'Compare a bounded Audible sample with local audio through an optional Audiolocate Python capability.', +}; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; @@ -19,7 +22,7 @@ export default async function Route({ input, signal }: ToolRouteProps - + ); } 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 e71608b62..8d2a91912 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 @@ -2,13 +2,16 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; import { CuratorDocument } from '../../../components/curator-document.js'; -import { EvidenceTrail } from '../../../components/evidence-trail.js'; +import { WhisperTrail } from '../../../components/evidence-trail.js'; import type { WhisperReceipt } from '../../../evidence.js'; import { defaultEvidenceOperations, evidenceOperations } from '../../../operations/evidence.js'; const operation = evidenceOperations(defaultEvidenceOperations).whisperVerify; -export const config = {"annotations":{"readOnlyHint":false},"description":"Extract and transcribe distributed PCM windows for human language, story, and narrator review."}; +export const config = { + annotations: { readOnlyHint: false }, + description: 'Extract and transcribe distributed PCM windows for human language, story, and narrator review.', +}; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; @@ -19,7 +22,7 @@ export default async function Route({ input, signal }: ToolRouteProps - + ); } diff --git a/examples/audiobook-curator/src/media-mutation.ts b/examples/audiobook-curator/src/media-mutation.ts index 4fbd69bdf..763a2d074 100644 --- a/examples/audiobook-curator/src/media-mutation.ts +++ b/examples/audiobook-curator/src/media-mutation.ts @@ -37,7 +37,7 @@ export interface ChapterInput { readonly receipt?: string; } -export interface MetadataReceipt { +export type MetadataReceipt = { readonly apply: boolean; readonly artwork?: string; readonly artworkStreamsAfter?: number; @@ -51,7 +51,7 @@ export interface MetadataReceipt { readonly chapterCountBefore: number; readonly file: string; readonly generatedAt: string; - readonly metadata: Readonly>; + readonly metadata: Readonly>; readonly mutation: boolean; readonly operation: 'apply-metadata'; readonly product: string; @@ -59,9 +59,9 @@ export interface MetadataReceipt { readonly status: 'applied-verified' | 'planned'; readonly streamCountAfter?: number; readonly verifiedMetadataKeys?: readonly string[]; -} +}; -export interface ChapterReceipt { +export type ChapterReceipt = { readonly apply: boolean; readonly audioSha256After?: string; readonly audioSha256Before: string; @@ -80,7 +80,7 @@ export interface ChapterReceipt { readonly sha256After?: string; readonly status: 'applied-verified' | 'planned'; readonly verifiedBoundaries?: true; -} +}; export interface MediaMutationDependencies extends LibraryDependencies { readonly ffmpeg?: string; diff --git a/examples/audiobook-curator/src/operations/evidence.ts b/examples/audiobook-curator/src/operations/evidence.ts index be88cd45c..d4578924d 100644 --- a/examples/audiobook-curator/src/operations/evidence.ts +++ b/examples/audiobook-curator/src/operations/evidence.ts @@ -2,6 +2,8 @@ * Acoustic and transcript identity-evidence operations: `acoustic-verify`, * `acoustic-identify`, and `whisper-verify`, backed by `../evidence.ts`. */ +import type { JsonObject } from '@agent-bundle/runtime'; + import { defineCliCommand, type CliCommandContext } from '../cli-command.js'; import { z } from 'zod'; @@ -30,7 +32,8 @@ export interface EvidenceOperations { export const defaultEvidenceOperations: Required = { acousticIdentify: async (input, options) => { const payload = await readJson(input.candidates); - const rows = z.object({ candidates: z.array(z.record(z.string(), z.unknown())).max(500) }).passthrough().parse(payload).candidates; + const rows = z.object({ candidates: z.array(z.record(z.string(), z.unknown())).max(500) }) + .passthrough().parse(payload).candidates as JsonObject[]; return identifyAudibleSample({ ...input, candidates: rows, diff --git a/examples/audiobook-curator/tests/component-foundations.test.tsx b/examples/audiobook-curator/tests/component-foundations.test.tsx new file mode 100644 index 000000000..b32d70e9d --- /dev/null +++ b/examples/audiobook-curator/tests/component-foundations.test.tsx @@ -0,0 +1,196 @@ +import { expect, it } from '@rstest/core'; +import React from 'react'; + +import type { AudibleCandidate, AudibleSearchReceipt } from '../src/audible.js'; +import { EditionCard, FileCard } from '../src/components/audiobook-card.js'; +import { + audibleSearchHeadline, + convertHeadline, + integrityAuditHeadline, + inventoryHeadline, + selectionHeadline, +} from '../src/components/headlines.js'; +import { Callout, FileList } from '../src/components/primitives.js'; +import { editionCardModel, fileCardModel } from '../src/components/view-models.js'; +import type { ConvertReceipt } from '../src/conversion.js'; +import type { InspectionReceipt } from '../src/curator-core.js'; +import type { IntegrityAuditReceipt } from '../src/integrity-audit.js'; +import type { InventoryReceipt, MediaRecord, SelectionReceipt } from '../src/library.js'; + +const media: MediaRecord = { + bytes: 12, + chapters: 0, + codec: 'aac', + durationSeconds: 90, + extension: '.m4b', + path: '/library/book.m4b', + relativePath: 'book.m4b', + sampleRate: 44_100, + tags: { + album_artist: 'Ursula Author', + composer: 'Nora Narrator', + title: 'A Book', + }, +}; + +it('normalizes each file-card source before rendering', () => { + const inspected: InspectionReceipt['files'][number] = { + bytes: 10, + codec: 'mp3', + durationSeconds: 65, + format: 'mp3', + path: '/library/inspection.mp3', + tags: { album: 'Inspected Book', artist: 'A. Writer' }, + }; + + expect(fileCardModel(inspected)).toEqual({ + author: 'A. Writer', + durationSeconds: 65, + format: 'mp3', + path: '/library/inspection.mp3', + title: 'Inspected Book', + }); + expect(fileCardModel(media)).toEqual({ + author: 'Ursula Author', + durationSeconds: 90, + format: 'aac / .m4b', + narrator: 'Nora Narrator', + path: '/library/book.m4b', + title: 'A Book', + }); +}); + +it('normalizes catalog contributors and edition metadata before rendering', () => { + const edition: AudibleCandidate = { + asin: 123, + authors: [{ display_name: 'A. Writer' }], + evidence: { + authorMatch: true, + languageMatch: true, + narratorMatch: true, + score: 100, + strictIdentityMatch: true, + titleMatch: true, + unabridged: true, + }, + format_type: 'Unabridged', + narrators: ['N. Reader'], + region: 'us', + runtime_length_min: 2, + title: 'Catalog Book', + }; + + expect(editionCardModel(edition)).toEqual({ + asin: '123', + author: 'A. Writer', + durationSeconds: 120, + format: 'Unabridged', + narrator: 'N. Reader', + region: 'us', + title: 'Catalog Book', + }); +}); + +it('exposes capitalized cards and composable primitives', () => { + const fileModel = fileCardModel(media); + const editionModel = editionCardModel({ + authors: [], + narrators: [], + title: 'Edition', + }); + const fileCard = ; + const editionCard = ; + const callout = Review this edition.; + const fileList = ; + + expect(fileCard.type).toBe(FileCard); + expect(editionCard.type).toBe(EditionCard); + expect(callout.props.children).toBe('Review this edition.'); + expect(fileList.type).toBe(FileList); +}); + +it('builds shared receipt headlines without changing route text', () => { + const inventory: InventoryReceipt = { + errors: [], + exitCode: 0, + files: [], + generatedAt: '2026-09-02T00:00:00.000Z', + mutation: false, + operation: 'inventory', + source: '/library', + summary: { bytes: 0, durationSeconds: 0, errors: 2, files: 7 }, + }; + const selection: SelectionReceipt = { + generatedAt: '2026-09-02T00:00:00.000Z', + mutation: false, + operation: 'quality-selection', + selections: [ + { + alternates: [], + durationSpreadSeconds: 0, + identityKey: 'book', + reason: 'best source', + reviewReason: 'check', + reviewRequired: true, + selected: media, + }, + ], + }; + const search: AudibleSearchReceipt = { + candidates: [], + errors: [], + exitCode: 0, + generatedAt: '2026-09-02T00:00:00.000Z', + humanReviewRequired: true, + mutation: false, + operation: 'audible-search', + query: { title: 'Book' }, + reviewNote: 'Review.', + }; + const audit: IntegrityAuditReceipt = { + audioSha256: 'b'.repeat(64), + bytes: 42, + chapterIssues: [], + chapters: [], + exitCode: 0, + file: media.path, + fullDecode: 'not-requested', + generatedAt: '2026-09-02T00:00:00.000Z', + mutation: false, + operation: 'audit', + probe: media, + sha256: 'a'.repeat(64), + sourceChapterMapping: { issues: [], status: 'not-requested' }, + status: 'verified', + }; + const conversion: ConvertReceipt = { + apply: false, + audioMode: 'copy', + embeddedMetadata: {}, + engine: 'ffmpeg', + expectedChapterCount: 0, + expectedChapters: [], + expectedDurationSeconds: 90, + filenamePolicy: 'fixed', + generatedAt: '2026-09-02T00:00:00.000Z', + inputs: [media.path], + jobs: 1, + mutation: true, + operation: 'convert', + output: '/output/book.m4b', + sourcesPreserved: true, + status: 'planned', + }; + + expect(inventoryHeadline(inventory)).toBe('Inventoried 7 media files with 2 retained errors.'); + expect(selectionHeadline(selection)).toBe('Selected 1 source groups; 1 require review.'); + expect(audibleSearchHeadline(search)).toBe( + 'Ranked 0 Audible candidates across reviewed regions; human selection is required.', + ); + expect(integrityAuditHeadline(audit)).toBe( + `Audited 42 bytes with SHA-256 ${'a'.repeat(64)}; status is verified.`, + ); + expect(convertHeadline(conversion)).toBe( + 'Planned copy output at /output/book.m4b; sources remain unchanged.', + ); +}); diff --git a/examples/audiobook-curator/tests/route-unit/streaming.test.ts b/examples/audiobook-curator/tests/route-unit/streaming.test.ts index 951e34ec9..744cba476 100644 --- a/examples/audiobook-curator/tests/route-unit/streaming.test.ts +++ b/examples/audiobook-curator/tests/route-unit/streaming.test.ts @@ -44,6 +44,7 @@ it('streams library analysis after the audit shell while preserving the canonica && documentText(document).includes('"kind":"progress"'))).toBe(true); expectDocument(rendered) .toContainMarkdown('**Reclaimable bytes:** 4') + .toContainMarkdown(`- ${join(library, 'Shared title.flac')}\n- ${join(library, 'Shared title.mp3')}`) .toContainContext('Duplicate candidate group') .toHaveValue(rendered.result); expect(rendered.result).toEqual(rendered.document.value);