From f4ee765db989aea52c7d050c8348bbcc14b926ef Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 30 Aug 2026 19:18:04 +0000 Subject: [PATCH 1/3] refactor(examples/audiobook-curator): split the operation catalog into feature modules application.tsx was an 808-line monolith mixing the argv toolkit, shared zod fragments, sixteen operation declarations, their default executors, and the bundle tree. Move the catalog into src/operations/ feature modules (evidence, media-mutation, audible, discovery, output) plus shared cli-arguments.ts and schemas.ts, leaving application.tsx as composition only. Code is moved verbatim; operation order, schemas, and behavior are unchanged. --- .../audiobook-curator/src/application.tsx | 818 ++---------------- .../src/operations/audible.tsx | 171 ++++ .../src/operations/cli-arguments.ts | 63 ++ .../src/operations/discovery.tsx | 209 +++++ .../src/operations/evidence.tsx | 167 ++++ .../src/operations/media-mutation.tsx | 98 +++ .../src/operations/output.tsx | 176 ++++ .../src/operations/schemas.ts | 34 + 8 files changed, 969 insertions(+), 767 deletions(-) create mode 100644 examples/audiobook-curator/src/operations/audible.tsx create mode 100644 examples/audiobook-curator/src/operations/cli-arguments.ts create mode 100644 examples/audiobook-curator/src/operations/discovery.tsx create mode 100644 examples/audiobook-curator/src/operations/evidence.tsx create mode 100644 examples/audiobook-curator/src/operations/media-mutation.tsx create mode 100644 examples/audiobook-curator/src/operations/output.tsx create mode 100644 examples/audiobook-curator/src/operations/schemas.ts diff --git a/examples/audiobook-curator/src/application.tsx b/examples/audiobook-curator/src/application.tsx index 102ef1a4a..6c7a1a157 100644 --- a/examples/audiobook-curator/src/application.tsx +++ b/examples/audiobook-curator/src/application.tsx @@ -1,792 +1,76 @@ +/** + * The audiobook-curator application: one `defineRscAgentBundle` tree that + * composes the Skill, the CLI Script, the MCP server, and the operation + * catalog. The operations themselves live in feature modules under + * `./operations/`; this file only merges their defaults and declares the + * bundle. + */ import { AgentBundle, McpServer, Operation, Script, Skill, - defineOperation, defineRscAgentBundle, - type RscOperationContext, } from '@agent-bundle/rsc-runtime/plugin'; import React from 'react'; -import { z } from 'zod'; import { - cacheAudibleEdition, - searchAudible, - selectAudibleEdition, - type AudibleCacheInput, - type AudibleCacheReceipt, - type AudibleSearchInput, - type AudibleSearchReceipt, - type AudibleSelectionReceipt, - type AudibleRegion, -} from './audible.ts'; + audibleOperations, + defaultAudibleOperations, + type AudibleOperations, +} from './operations/audible.tsx'; import { - inspectSources, - prepareAudiobook, - type InspectionReceipt, - type PrepareInput, - type PrepareReceipt, -} from './curator-core.ts'; + defaultDiscoveryOperations, + discoveryOperations, + type DiscoveryOperations, +} from './operations/discovery.tsx'; import { - auditAudiobookIntegrity, - type IntegrityAuditInput, - type IntegrityAuditReceipt, -} from './integrity-audit.ts'; -import { convertAudiobook, type ConvertInput, type ConvertReceipt } from './conversion.ts'; -import { readJson, writeReceipt } from './foundation.ts'; + defaultEvidenceOperations, + evidenceOperations, + type EvidenceOperations, +} from './operations/evidence.tsx'; import { - auditLibrary, - createInventory, - selectInventorySources, - type InventoryReceipt, - type LibraryAuditReceipt, - type SelectionReceipt, -} from './library.ts'; -import { CuratorResult } from './result.tsx'; + defaultMediaMutationOperations, + mediaMutationOperations, + type MediaMutationOperations, +} from './operations/media-mutation.tsx'; import { - identifyAudibleSample, - verifyAudibleSample, - verifyWithWhisper, - type AcousticIdentifyReceipt, - type AcousticReceipt, - type AcousticVerifyInput, - type WhisperInput, - type WhisperReceipt, -} from './evidence.ts'; -import { - applyAudiobookChapters, - applyAudiobookMetadata, - type ChapterInput, - type ChapterReceipt, - type MetadataInput, - type MetadataReceipt, -} from './media-mutation.ts'; - -export interface AudiobookCuratorOperations { - readonly acousticIdentify?: ( - input: { readonly all?: boolean; readonly attempts?: number; readonly candidates: string; readonly chunkSeconds?: number; readonly file: string; readonly receipt?: string; readonly top?: number; readonly verbose?: boolean }, - options: RscOperationContext, - ) => Promise; - readonly acousticVerify?: (input: AcousticVerifyInput, options: RscOperationContext) => Promise; - readonly audibleCache?: (input: AudibleCacheInput, options: RscOperationContext) => Promise; - readonly audibleSearch?: (input: AudibleSearchInput, options: RscOperationContext) => Promise; - readonly audibleSelect?: ( - input: { readonly candidate: number; readonly candidates: string; readonly note?: string; readonly receipt?: string }, - options: RscOperationContext, - ) => Promise; - readonly audit: (input: IntegrityAuditInput, options: RscOperationContext) => Promise; - readonly applyChapters?: (input: ChapterInput, options: RscOperationContext) => Promise; - readonly applyMetadata?: (input: MetadataInput, options: RscOperationContext) => Promise; - readonly convert?: (input: ConvertInput, options: RscOperationContext) => Promise; - readonly inspect: ( - input: { readonly maxFiles?: number; readonly root: string }, - options: RscOperationContext, - ) => Promise; - readonly inventory?: ( - input: { readonly report?: string; readonly source: string; readonly strict?: boolean }, - options: RscOperationContext, - ) => Promise; - readonly libraryAudit?: ( - input: { readonly concurrency?: number; readonly report?: string; readonly sources: readonly string[]; readonly strict?: boolean }, - options: RscOperationContext, - ) => Promise; - readonly prepare: (input: PrepareInput, options: RscOperationContext) => Promise; - readonly select?: ( - input: { readonly inventory: string; readonly report?: string }, - options: RscOperationContext, - ) => Promise; - readonly whisperVerify?: (input: WhisperInput, options: RscOperationContext) => Promise; -} - -const defaultOperations: 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; - return identifyAudibleSample({ - ...input, - candidates: rows, - candidatesReport: input.candidates, - }, options); - }, - acousticVerify: (input, options) => verifyAudibleSample(input, options), - audibleCache: (input, options) => cacheAudibleEdition(input, options), - audibleSearch: (input, options) => searchAudible(input, options), - audibleSelect: async (input) => { - const report = audibleSearchResultSchema.parse(await readJson(input.candidates)); - const receipt = selectAudibleEdition(report, { - candidate: input.candidate, - candidateReport: input.candidates, - ...(input.note === undefined ? {} : { note: input.note }), - }); - if (input.receipt !== undefined) await writeReceipt(input.receipt, receipt, [input.candidates]); - return receipt; - }, - applyChapters: (input, options) => applyAudiobookChapters(input, options), - applyMetadata: (input, options) => applyAudiobookMetadata(input, options), - audit: (input, options) => auditAudiobookIntegrity(input, options), - convert: (input, options) => convertAudiobook(input, options), - inspect: (input, options) => inspectSources(input, options), - inventory: async (input, options) => { - const receipt = await createInventory(input, options); - if (input.report !== undefined) await writeReceipt(input.report, receipt, [input.source]); - return receipt; - }, - libraryAudit: async (input, options) => { - const receipt = await auditLibrary(input, options); - if (input.report !== undefined) await writeReceipt(input.report, receipt, input.sources); - return receipt; - }, - prepare: (input, options) => prepareAudiobook(input, options), - select: async (input) => { - const inventory = inventoryResultSchema.parse(await readJson(input.inventory)); - const receipt = selectInventorySources(inventory, input.inventory); - if (input.report !== undefined) await writeReceipt(input.report, receipt, [input.inventory]); - return receipt; - }, - whisperVerify: (input, options) => verifyWithWhisper(input, options), -}; - -const pathSchema = z.string().min(1).max(4096); -const audibleRegions = ['au', 'ca', 'de', 'es', 'fr', 'in', 'it', 'jp', 'uk', 'us'] as const; -const tagsSchema = z.record(z.string().max(128), z.string().max(4096)); -const probeShape = { - channels: z.number().nonnegative().optional(), - codec: z.string(), - durationSeconds: z.number().nonnegative(), - format: z.string(), - sampleRate: z.number().nonnegative().optional(), - tags: tagsSchema, -}; -const probeSchema = z.object(probeShape).strict(); -const inspectedFileSchema = z.object({ - ...probeShape, - bytes: z.number().int().nonnegative(), - path: pathSchema, -}).strict(); -const inspectInputSchema = z.object({ - maxFiles: z.number().int().min(1).max(256).optional(), - root: pathSchema, -}).strict(); -const inspectResultSchema = z.object({ - files: z.array(inspectedFileSchema).max(256), - operation: z.literal('inspect'), - root: pathSchema, - totalBytes: z.number().int().nonnegative(), -}).strict(); -const prepareInputSchema = z.object({ - apply: z.boolean().optional(), - outputName: z.string().min(5).max(204).optional(), - outputRoot: pathSchema, - source: pathSchema, -}).strict(); -const prepareResultSchema = z.object({ - applied: z.boolean(), - operation: z.literal('prepare'), - output: pathSchema, - probe: probeSchema, - source: pathSchema, -}).strict(); -const parityReceiptSchema = ( - operation: T['operation'], -): z.ZodType => z.object({ - generatedAt: z.string().min(1), - mutation: z.boolean(), - operation: z.literal(operation), -}).catchall(z.json()) as unknown as z.ZodType; -const inventoryResultSchema = parityReceiptSchema('inventory'); -const libraryResultSchema = parityReceiptSchema('library-audit'); -const selectionResultSchema = parityReceiptSchema('quality-selection'); -const convertResultSchema = parityReceiptSchema('convert'); -const audibleEvidenceSchema = z.object({ - authorMatch: z.boolean(), durationDifferencePercent: z.number().nonnegative().optional(), language: z.string().optional(), - languageMatch: z.boolean(), narratorMatch: z.boolean(), score: z.number(), strictIdentityMatch: z.boolean(), - titleMatch: z.boolean(), unabridged: z.boolean(), -}).strict(); -const audibleCandidateSchema = z.object({ evidence: audibleEvidenceSchema, region: z.enum(audibleRegions) }).passthrough(); -const audibleSearchResultSchema: z.ZodType = z.object({ - candidates: z.array(audibleCandidateSchema).max(500), - errors: z.array(z.object({ error: z.string().max(4096), region: audibleCandidateSchema.shape.region }).strict()).max(10), - exitCode: z.union([z.literal(0), z.literal(1)]), generatedAt: z.string(), humanReviewRequired: z.literal(true), - mutation: z.literal(false), operation: z.literal('audible-search'), - query: z.object({ author: z.string().optional(), durationSeconds: z.number().positive().optional(), narrator: z.string().optional(), title: z.string() }).strict(), - reviewNote: z.string(), -}).strict() as z.ZodType; -const audibleSelectResultSchema = parityReceiptSchema('audible-select'); -const audibleCacheResultSchema = parityReceiptSchema('audible-cache'); -const metadataResultSchema = parityReceiptSchema('apply-metadata'); -const chaptersResultSchema = parityReceiptSchema('apply-chapters'); -const acousticResultSchema = parityReceiptSchema('audiolocate'); -const acousticIdentifyResultSchema = parityReceiptSchema('acoustic-identify'); -const whisperResultSchema = parityReceiptSchema('whisper-identity'); -const auditResultSchema = parityReceiptSchema('audit'); - -const optionValue = (args: readonly string[], option: string): string | undefined => { - const index = args.indexOf(option); - if (index === -1) return undefined; - const value = args[index + 1]; - if (value === undefined || value.startsWith('--')) throw new Error(`${option} requires a value.`); - return value; -}; - -const assertOptions = (args: readonly string[], flags: ReadonlySet, valued: ReadonlySet): void => { - for (let index = 0; index < args.length; index += 1) { - const argument = args[index]!; - if (!argument.startsWith('--')) continue; - if (flags.has(argument)) continue; - if (valued.has(argument)) { - index += 1; - if (args[index] === undefined || args[index]!.startsWith('--')) throw new Error(`${argument} requires a value.`); - continue; - } - throw new Error(`Unknown option: ${argument}`); - } -}; - -const positionalArguments = (args: readonly string[], valued: ReadonlySet): readonly string[] => { - const positional: string[] = []; - for (let index = 0; index < args.length; index += 1) { - if (valued.has(args[index]!)) { - index += 1; - } else if (!args[index]!.startsWith('--')) { - positional.push(args[index]!); - } - } - return positional; -}; - -const onePath = (args: readonly string[], valued: ReadonlySet, command: string): string => { - const positional = positionalArguments(args, valued); - if (positional.length !== 1) throw new Error(`${command} requires exactly one path.`); - return positional[0]!; -}; - -const requiredOption = (args: readonly string[], option: string, command: string): string => { - const value = optionValue(args, option); - if (value === undefined) throw new Error(`${command} requires ${option}.`); - return value; -}; - -const optionChoice = ( - args: readonly string[], - option: string, - choices: readonly T[], -): T | undefined => { - const value = optionValue(args, option); - if (value === undefined) return undefined; - if (!choices.includes(value as T)) throw new Error(`${option} must be one of: ${choices.join(', ')}.`); - return value as T; -}; + defaultOutputOperations, + outputOperations, + type OutputOperations, +} from './operations/output.tsx'; -const audibleRegionList = (value: string): readonly AudibleRegion[] => value.split(',').map((region) => { - const candidate = region.trim().toLowerCase(); - if (!audibleRegions.includes(candidate as AudibleRegion)) throw new Error(`Unsupported Audible region: ${candidate}.`); - return candidate as AudibleRegion; -}); +/** + * Injection surface for tests and embedders: every operation executor can be + * replaced while the CLI/MCP projections and schemas stay identical. + */ +export type AudiobookCuratorOperations = + & AudibleOperations + & DiscoveryOperations + & EvidenceOperations + & MediaMutationOperations + & OutputOperations; -const createOperations = (operations: Required) => Object.freeze([ - defineOperation({ - cli: { - exitCode: (receipt) => receipt.exitCode, - name: 'acoustic-verify', - parse: (args) => { - const valued = new Set(['--asin', '--attempts', '--audiolocate-python', '--chunk-seconds', '--file', '--receipt', '--region', '--sample-url']); - assertOptions(args, new Set(['--verbose']), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('acoustic-verify accepts only named options.'); - const attempts = optionValue(args, '--attempts'); - const chunks = optionValue(args, '--chunk-seconds'); - return { - asin: requiredOption(args, '--asin', 'acoustic-verify'), - ...(attempts === undefined ? {} : { attempts: Number(attempts) }), - ...(optionValue(args, '--audiolocate-python') === undefined ? {} : { audiolocatePython: optionValue(args, '--audiolocate-python') }), - ...(chunks === undefined ? {} : { chunkSeconds: Number(chunks) }), - file: requiredOption(args, '--file', 'acoustic-verify'), - receipt: requiredOption(args, '--receipt', 'acoustic-verify'), - ...(optionChoice(args, '--region', audibleRegions) === undefined ? {} : { region: optionChoice(args, '--region', audibleRegions) }), - ...(optionValue(args, '--sample-url') === undefined ? {} : { sampleUrl: optionValue(args, '--sample-url') }), - ...(args.includes('--verbose') ? { verbose: true } : {}), - }; - }, - summary: 'Compare one bounded Audible sample with local audio through optional Audiolocate.', - usage: 'acoustic-verify --file FILE --asin ASIN --region REGION --receipt FILE [--audiolocate-python PATH]', - }, - execute: operations.acousticVerify, - id: 'acoustic-verify', - inputSchema: z.object({ - asin: z.string().min(1).max(64), attempts: z.number().int().min(1).max(10).optional(), audiolocatePython: pathSchema.optional(), - chunkSeconds: z.number().int().min(1).max(86_400).optional(), file: pathSchema, receipt: pathSchema.optional(), - region: z.enum(audibleRegions).optional(), sampleUrl: z.url().optional(), verbose: z.boolean().optional(), - }).strict(), - mcp: { description: 'Compare a bounded Audible sample with local audio through an optional Audiolocate Python capability.', name: 'verify_audible_sample', openWorld: true, readOnly: false, server: 'curator' }, - render: (receipt) => , - resultSchema: acousticResultSchema, - }), - defineOperation({ - cli: { - exitCode: (receipt) => receipt.exitCode, - name: 'acoustic-identify', - parse: (args) => { - const valued = new Set(['--attempts', '--candidates', '--chunk-seconds', '--file', '--receipt', '--top']); - assertOptions(args, new Set(['--all', '--verbose']), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('acoustic-identify accepts only named options.'); - const attempts = optionValue(args, '--attempts'); - const chunks = optionValue(args, '--chunk-seconds'); - const top = optionValue(args, '--top'); - return { - ...(args.includes('--all') ? { all: true } : {}), - ...(attempts === undefined ? {} : { attempts: Number(attempts) }), - candidates: requiredOption(args, '--candidates', 'acoustic-identify'), - ...(chunks === undefined ? {} : { chunkSeconds: Number(chunks) }), - file: requiredOption(args, '--file', 'acoustic-identify'), - receipt: requiredOption(args, '--receipt', 'acoustic-identify'), - ...(top === undefined ? {} : { top: Number(top) }), - ...(args.includes('--verbose') ? { verbose: true } : {}), - }; - }, - summary: 'Try score-ranked, deduplicated Audible candidates and retain per-candidate evidence.', - usage: 'acoustic-identify --file FILE --candidates FILE --receipt FILE [--top N] [--all]', - }, - execute: operations.acousticIdentify, - id: 'acoustic-identify', - inputSchema: z.object({ - all: z.boolean().optional(), attempts: z.number().int().min(1).max(10).optional(), candidates: pathSchema, - chunkSeconds: z.number().int().min(1).max(86_400).optional(), file: pathSchema, receipt: pathSchema.optional(), - top: z.number().int().min(1).max(10).optional(), verbose: z.boolean().optional(), - }).strict(), - mcp: { description: 'Try ranked Audible candidates, retaining skips/errors and stopping at the first acoustic match by default.', name: 'identify_audible_sample', openWorld: true, readOnly: false, server: 'curator' }, - render: (receipt) => , - resultSchema: acousticIdentifyResultSchema, - }), - defineOperation({ - cli: { - exitCode: (receipt) => receipt.exitCode, - name: 'whisper-verify', - parse: (args) => { - const valued = new Set(['--author', '--file', '--language', '--max-windows', '--minimum-chars', '--model', '--receipt', '--threads', '--title', '--whisper-cli', '--window-seconds']); - assertOptions(args, new Set(), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('whisper-verify accepts only named options.'); - return { - ...(optionValue(args, '--author') === undefined ? {} : { author: optionValue(args, '--author') }), - file: requiredOption(args, '--file', 'whisper-verify'), - ...(optionValue(args, '--language') === undefined ? {} : { language: optionValue(args, '--language') }), - ...(optionValue(args, '--max-windows') === undefined ? {} : { maxWindows: Number(optionValue(args, '--max-windows')) }), - ...(optionValue(args, '--minimum-chars') === undefined ? {} : { minimumChars: Number(optionValue(args, '--minimum-chars')) }), - model: requiredOption(args, '--model', 'whisper-verify'), - receipt: requiredOption(args, '--receipt', 'whisper-verify'), - ...(optionValue(args, '--threads') === undefined ? {} : { threads: Number(optionValue(args, '--threads')) }), - ...(optionValue(args, '--title') === undefined ? {} : { title: optionValue(args, '--title') }), - ...(optionValue(args, '--whisper-cli') === undefined ? {} : { whisperCli: optionValue(args, '--whisper-cli') }), - ...(optionValue(args, '--window-seconds') === undefined ? {} : { windowSeconds: Number(optionValue(args, '--window-seconds')) }), - }; - }, - summary: 'Transcribe distributed audiobook windows for human language and identity review.', - usage: 'whisper-verify --file FILE --model FILE --receipt FILE [--language CODE] [--max-windows N]', - }, - execute: operations.whisperVerify, - id: 'whisper-verify', - inputSchema: z.object({ - author: z.string().max(512).optional(), file: pathSchema, language: z.string().min(1).max(64).optional(), - maxWindows: z.number().int().min(5).max(11).optional(), minimumChars: z.number().int().min(1).max(16_384).optional(), - model: pathSchema, receipt: pathSchema.optional(), threads: z.number().int().min(1).max(256).optional(), title: z.string().max(1024).optional(), - whisperCli: pathSchema.optional(), windowSeconds: z.number().int().min(1).max(3600).optional(), - }).strict(), - mcp: { description: 'Extract and transcribe distributed PCM windows for human language, story, and narrator review.', name: 'verify_with_whisper', readOnly: false, server: 'curator' }, - render: (receipt) => , - resultSchema: whisperResultSchema, - }), - defineOperation({ - cli: { - name: 'apply-metadata', - parse: (args) => { - const valued = new Set(['--artwork', '--author', '--file', '--language', '--narrator', '--product', '--receipt', '--title', '--year']); - assertOptions(args, new Set(['--apply']), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('apply-metadata accepts only named options.'); - return { - ...(args.includes('--apply') ? { apply: true } : {}), - ...(optionValue(args, '--artwork') === undefined ? {} : { artwork: optionValue(args, '--artwork') }), - ...(optionValue(args, '--author') === undefined ? {} : { author: optionValue(args, '--author') }), - file: requiredOption(args, '--file', 'apply-metadata'), - ...(optionValue(args, '--language') === undefined ? {} : { language: optionValue(args, '--language') }), - ...(optionValue(args, '--narrator') === undefined ? {} : { narrator: optionValue(args, '--narrator') }), - product: requiredOption(args, '--product', 'apply-metadata'), - receipt: requiredOption(args, '--receipt', 'apply-metadata'), - ...(optionValue(args, '--title') === undefined ? {} : { title: optionValue(args, '--title') }), - ...(optionValue(args, '--year') === undefined ? {} : { year: optionValue(args, '--year') }), - }; - }, - summary: 'Plan or apply verified Audible metadata and artwork without changing encoded audio.', - usage: 'apply-metadata --file FILE --product FILE --receipt FILE [--artwork FILE] [--language CODE] [--apply]', - }, - execute: operations.applyMetadata, - id: 'apply-metadata', - inputSchema: z.object({ - apply: z.boolean().optional(), artwork: pathSchema.optional(), author: z.string().max(512).optional(), file: pathSchema, - language: z.string().min(1).max(64).optional(), narrator: z.string().max(512).optional(), product: pathSchema, - receipt: pathSchema.optional(), title: z.string().max(1024).optional(), year: z.string().max(64).optional(), - }).strict(), - mcp: { description: 'Plan or explicitly apply verified catalog metadata and artwork while preserving every audio stream.', destructive: true, name: 'apply_audiobook_metadata', readOnly: false, server: 'curator' }, - render: (receipt) => , - resultSchema: metadataResultSchema, - }), - defineOperation({ - cli: { - name: 'apply-chapters', - parse: (args) => { - const valued = new Set(['--chapters', '--file', '--receipt']); - assertOptions(args, new Set(['--apply']), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('apply-chapters accepts only named options.'); - return { - ...(args.includes('--apply') ? { apply: true } : {}), - chapters: requiredOption(args, '--chapters', 'apply-chapters'), - file: requiredOption(args, '--file', 'apply-chapters'), - receipt: requiredOption(args, '--receipt', 'apply-chapters'), - }; - }, - summary: 'Plan or apply verified generic or Audible chapter rows without changing encoded audio.', - usage: 'apply-chapters --file FILE --chapters FILE --receipt FILE [--apply]', - }, - execute: operations.applyChapters, - id: 'apply-chapters', - inputSchema: z.object({ apply: z.boolean().optional(), chapters: pathSchema, file: pathSchema, receipt: pathSchema.optional() }).strict(), - mcp: { description: 'Plan or explicitly apply verified chapter rows while preserving all non-chapter media state.', destructive: true, name: 'apply_audiobook_chapters', readOnly: false, server: 'curator' }, - render: (receipt) => , - resultSchema: chaptersResultSchema, - }), - defineOperation({ - cli: { - exitCode: (receipt) => receipt.exitCode, - name: 'audible-search', - parse: (args) => { - const valued = new Set(['--attempts', '--author', '--duration', '--limit', '--narrator', '--regions', '--report', '--title']); - assertOptions(args, new Set(), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('audible-search accepts only named options.'); - const attempts = optionValue(args, '--attempts'); - const duration = optionValue(args, '--duration'); - const limit = optionValue(args, '--limit'); - const regions = optionValue(args, '--regions'); - return { - ...(attempts === undefined ? {} : { attempts: Number(attempts) }), - ...(optionValue(args, '--author') === undefined ? {} : { author: optionValue(args, '--author') }), - ...(duration === undefined ? {} : { durationSeconds: Number(duration) }), - ...(limit === undefined ? {} : { limit: Number(limit) }), - ...(optionValue(args, '--narrator') === undefined ? {} : { narrator: optionValue(args, '--narrator') }), - ...(regions === undefined ? {} : { regions: audibleRegionList(regions) }), - report: requiredOption(args, '--report', 'audible-search'), - title: requiredOption(args, '--title', 'audible-search'), - }; - }, - summary: 'Search and rank Audible identity candidates across reviewed regions.', - usage: 'audible-search --title TITLE --report FILE [--author AUTHOR] [--narrator NARRATOR] [--duration SECONDS] [--regions LIST]', - }, - execute: operations.audibleSearch, - id: 'audible-search', - inputSchema: z.object({ - attempts: z.number().int().min(1).max(10).optional(), author: z.string().min(1).max(512).optional(), - durationSeconds: z.number().positive().optional(), limit: z.number().int().min(1).max(50).optional(), - narrator: z.string().min(1).max(512).optional(), regions: z.array(audibleCandidateSchema.shape.region).min(1).max(10).optional(), - report: pathSchema.optional(), title: z.string().min(1).max(1024), - }).strict(), - mcp: { description: 'Search Audible regions and return ranked identity evidence requiring human review.', name: 'search_audible', openWorld: true, readOnly: false, server: 'curator' }, - render: (receipt) => , - resultSchema: audibleSearchResultSchema, - }), - defineOperation({ - cli: { - name: 'audible-select', - parse: (args) => { - const valued = new Set(['--candidate', '--candidates', '--note', '--receipt']); - assertOptions(args, new Set(), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('audible-select accepts only named options.'); - return { - candidate: Number(requiredOption(args, '--candidate', 'audible-select')), - candidates: requiredOption(args, '--candidates', 'audible-select'), - ...(optionValue(args, '--note') === undefined ? {} : { note: optionValue(args, '--note') }), - receipt: requiredOption(args, '--receipt', 'audible-select'), - }; - }, - summary: 'Record one explicit human-reviewed Audible edition choice.', - usage: 'audible-select --candidates FILE --candidate N --receipt FILE [--note NOTE]', - }, - execute: operations.audibleSelect, - id: 'audible-select', - inputSchema: z.object({ candidate: z.number().int().min(1).max(500), candidates: pathSchema, note: z.string().max(4096).optional(), receipt: pathSchema.optional() }).strict(), - mcp: { description: 'Record an explicit human-reviewed Audible edition choice from a candidate report.', name: 'select_audible_edition', readOnly: false, server: 'curator' }, - render: (receipt) => , - resultSchema: audibleSelectResultSchema, - }), - defineOperation({ - cli: { - name: 'audible-cache', - parse: (args) => { - const valued = new Set(['--asin', '--attempts', '--cache-dir', '--receipt', '--region']); - assertOptions(args, new Set(), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('audible-cache accepts only named options.'); - const attempts = optionValue(args, '--attempts'); - return { - asin: requiredOption(args, '--asin', 'audible-cache'), - ...(attempts === undefined ? {} : { attempts: Number(attempts) }), - cacheDirectory: requiredOption(args, '--cache-dir', 'audible-cache'), - receipt: requiredOption(args, '--receipt', 'audible-cache'), - ...(optionChoice(args, '--region', audibleRegions) === undefined ? {} : { region: optionChoice(args, '--region', audibleRegions) }), - }; - }, - summary: 'Cache one reviewed Audible product, chapters, artwork, and source URLs.', - usage: 'audible-cache --asin ASIN --region REGION --cache-dir DIR --receipt FILE', - }, - execute: operations.audibleCache, - id: 'audible-cache', - inputSchema: z.object({ - asin: z.string().min(1).max(64), attempts: z.number().int().min(1).max(10).optional(), cacheDirectory: pathSchema, - receipt: pathSchema.optional(), region: audibleCandidateSchema.shape.region.optional(), - }).strict(), - mcp: { description: 'Cache a reviewed Audible edition and retained source evidence.', name: 'cache_audible_edition', openWorld: true, readOnly: false, server: 'curator' }, - render: (receipt) => , - resultSchema: audibleCacheResultSchema, - }), - defineOperation({ - cli: { - name: 'inspect', - parse: (args) => { - const valued = new Set(['--max-files']); - assertOptions(args, new Set(), valued); - const maximum = optionValue(args, '--max-files'); - return { - ...(maximum === undefined ? {} : { maxFiles: Number(maximum) }), - root: onePath(args, valued, 'inspect'), - }; - }, - summary: 'Inspect a bounded audiobook source tree without changing it.', - usage: 'inspect [--max-files N] ', - }, - execute: operations.inspect, - id: 'inspect', - inputSchema: inspectInputSchema, - mcp: { - description: 'Inspect a bounded directory tree and report supported audiobook media without changing it.', - name: 'inspect_sources', - readOnly: true, - server: 'curator', - }, - render: (receipt) => , - resultSchema: inspectResultSchema, - }), - defineOperation({ - cli: { - exitCode: (receipt) => receipt.exitCode, - name: 'inventory', - parse: (args) => { - const valued = new Set(['--report']); - assertOptions(args, new Set(['--strict']), valued); - return { - report: requiredOption(args, '--report', 'inventory'), - source: onePath(args, valued, 'inventory'), - ...(args.includes('--strict') ? { strict: true } : {}), - }; - }, - summary: 'Probe source audio without changing it.', - usage: 'inventory --report FILE [--strict]', - }, - execute: operations.inventory, - id: 'inventory', - inputSchema: z.object({ report: pathSchema.optional(), source: pathSchema, strict: z.boolean().optional() }).strict(), - mcp: { - description: 'Inventory source audio with retained per-file probe evidence.', - name: 'inventory_sources', - readOnly: false, - server: 'curator', - }, - render: (receipt) => , - resultSchema: inventoryResultSchema, - }), - defineOperation({ - cli: { - exitCode: (receipt) => receipt.exitCode, - name: 'library-audit', - parse: (args) => { - const valued = new Set(['--concurrency', '--report']); - assertOptions(args, new Set(['--strict']), valued); - const concurrency = optionValue(args, '--concurrency'); - const sources = positionalArguments(args, valued); - if (sources.length === 0) throw new Error('library-audit requires at least one source path.'); - return { - ...(concurrency === undefined ? {} : { concurrency: Number(concurrency) }), - report: requiredOption(args, '--report', 'library-audit'), - sources, - ...(args.includes('--strict') ? { strict: true } : {}), - }; - }, - summary: 'Audit metadata, artwork, chapters, duplicate candidates, and multipart groups.', - usage: 'library-audit --report FILE [--concurrency N] [--strict]', - }, - execute: operations.libraryAudit, - id: 'library-audit', - inputSchema: z.object({ - concurrency: z.number().int().min(1).max(8).optional(), - report: pathSchema.optional(), - sources: z.array(pathSchema).min(1).max(64), - strict: z.boolean().optional(), - }).strict(), - mcp: { - description: 'Audit audiobook library metadata, duplicates, and multipart evidence without deletion advice.', - name: 'audit_library', - readOnly: false, - server: 'curator', - }, - render: (receipt) => , - resultSchema: libraryResultSchema, - }), - defineOperation({ - cli: { - name: 'select', - parse: (args) => { - const valued = new Set(['--inventory', '--report']); - assertOptions(args, new Set(), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('select accepts only named options.'); - return { - inventory: requiredOption(args, '--inventory', 'select'), - report: requiredOption(args, '--report', 'select'), - }; - }, - summary: 'Choose the strongest source among normalized collisions.', - usage: 'select --inventory FILE --report FILE', - }, - execute: operations.select, - id: 'select', - inputSchema: z.object({ inventory: pathSchema, report: pathSchema.optional() }).strict(), - mcp: { - description: 'Select strongest source encodings while retaining alternates and duration review evidence.', - name: 'select_sources', - readOnly: false, - server: 'curator', - }, - render: (receipt) => , - resultSchema: selectionResultSchema, - }), - defineOperation({ - cli: { - name: 'convert', - parse: (args) => { - const valued = new Set([ - '--artwork', '--audio-bitrate', '--audio-codec', '--author', '--engine', '--forge-aac-encoder', - '--forge-cli', '--jobs', '--language', '--narrator', '--output', '--receipt', '--selection', '--title', '--year', - ]); - assertOptions(args, new Set(['--apply', '--overwrite']), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('convert accepts only named options.'); - const audioCodec = optionChoice(args, '--audio-codec', ['aac', 'alac'] as const); - const engine = optionChoice(args, '--engine', ['audiobook-forge', 'ffmpeg'] as const); - const jobs = optionValue(args, '--jobs'); - return { - ...(args.includes('--apply') ? { apply: true } : {}), - ...(args.includes('--overwrite') ? { overwrite: true } : {}), - ...(optionValue(args, '--artwork') === undefined ? {} : { artwork: optionValue(args, '--artwork') }), - ...(optionValue(args, '--audio-bitrate') === undefined ? {} : { audioBitrate: optionValue(args, '--audio-bitrate') }), - ...(audioCodec === undefined ? {} : { audioCodec }), - author: requiredOption(args, '--author', 'convert'), - ...(engine === undefined ? {} : { engine }), - ...(optionValue(args, '--forge-aac-encoder') === undefined ? {} : { forgeAacEncoder: optionValue(args, '--forge-aac-encoder') }), - ...(optionValue(args, '--forge-cli') === undefined ? {} : { forgeCli: optionValue(args, '--forge-cli') }), - ...(jobs === undefined ? {} : { jobs: Number(jobs) }), - ...(optionValue(args, '--language') === undefined ? {} : { language: optionValue(args, '--language') }), - ...(optionValue(args, '--narrator') === undefined ? {} : { narrator: optionValue(args, '--narrator') }), - output: requiredOption(args, '--output', 'convert'), - receipt: requiredOption(args, '--receipt', 'convert'), - selection: requiredOption(args, '--selection', 'convert'), - title: requiredOption(args, '--title', 'convert'), - ...(optionValue(args, '--year') === undefined ? {} : { year: optionValue(args, '--year') }), - }; - }, - summary: 'Plan or apply a verified conversion to one chaptered M4B.', - usage: 'convert --selection FILE --output PATH --receipt FILE --title TITLE --author AUTHOR [--apply] [--overwrite]', - }, - execute: operations.convert, - id: 'convert', - inputSchema: z.object({ - apply: z.boolean().optional(), artwork: pathSchema.optional(), audioBitrate: z.string().min(2).max(32).optional(), - audioCodec: z.enum(['aac', 'alac']).optional(), author: z.string().min(1).max(512), - engine: z.enum(['audiobook-forge', 'ffmpeg']).optional(), forgeAacEncoder: z.string().min(1).max(128).optional(), - forgeCli: pathSchema.optional(), jobs: z.number().int().min(0).max(256).optional(), language: z.string().min(1).max(64).optional(), - narrator: z.string().min(1).max(512).optional(), output: pathSchema, overwrite: z.boolean().optional(), receipt: pathSchema.optional(), - selection: pathSchema, title: z.string().min(1).max(1024), year: z.string().min(1).max(64).optional(), - }).strict(), - mcp: { - description: 'Plan or explicitly apply a verified FFmpeg or Audiobook Forge conversion while preserving sources.', - destructive: true, - name: 'convert_audiobook', - readOnly: false, - server: 'curator', - }, - render: (receipt) => , - resultSchema: convertResultSchema, - }), - defineOperation({ - cli: { - name: 'prepare', - parse: (args) => { - const valued = new Set(['--name', '--output']); - assertOptions(args, new Set(['--apply']), valued); - const outputRoot = optionValue(args, '--output'); - if (outputRoot === undefined) throw new Error('prepare requires --output.'); - const outputName = optionValue(args, '--name'); - return { - ...(args.includes('--apply') ? { apply: true } : {}), - ...(outputName === undefined ? {} : { outputName }), - outputRoot, - source: onePath(args, valued, 'prepare'), - }; - }, - summary: 'Plan an M4B output or apply the plan when explicitly requested.', - usage: 'prepare [--apply] [--name FILE] --output DIR ', - }, - execute: operations.prepare, - id: 'prepare', - inputSchema: prepareInputSchema, - mcp: { - description: 'Plan an M4B output, or apply the plan only when apply is explicitly true.', - destructive: true, - name: 'prepare_audiobook', - readOnly: false, - server: 'curator', - }, - render: (receipt) => , - resultSchema: prepareResultSchema, - }), - defineOperation({ - cli: { - exitCode: (receipt) => receipt.exitCode, - name: 'audit', - parse: (args) => { - const valued = new Set(['--conversion-receipt', '--file', '--receipt']); - assertOptions(args, new Set(['--full-decode']), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('audit accepts only named options.'); - return { - ...(optionValue(args, '--conversion-receipt') === undefined ? {} : { conversionReceipt: optionValue(args, '--conversion-receipt') }), - file: requiredOption(args, '--file', 'audit'), - ...(args.includes('--full-decode') ? { fullDecode: true } : {}), - receipt: requiredOption(args, '--receipt', 'audit'), - }; - }, - summary: 'Validate metadata, chapters, source mapping, hashes, and optional complete decode.', - usage: 'audit --file FILE --receipt FILE [--conversion-receipt FILE] [--full-decode]', - }, - execute: operations.audit, - id: 'audit', - inputSchema: z.object({ conversionReceipt: pathSchema.optional(), file: pathSchema, fullDecode: z.boolean().optional(), receipt: pathSchema.optional() }).strict(), - mcp: { - description: 'Validate chapter structure, optional conversion mapping, file/audio hashes, probe facts, and optional full decode.', - name: 'audit_audiobook', - readOnly: false, - server: 'curator', - }, - render: (receipt) => , - resultSchema: auditResultSchema, - }), +const operationDefinitions = (operations: Required) => Object.freeze([ + ...evidenceOperations(operations), + ...mediaMutationOperations(operations), + ...audibleOperations(operations), + ...discoveryOperations(operations), + ...outputOperations(operations), ]); export const createAudiobookCuratorApplication = ( options: { readonly operations?: AudiobookCuratorOperations } = {}, ) => { - const definitions = createOperations({ ...defaultOperations, ...options.operations }); + const definitions = operationDefinitions({ + ...defaultAudibleOperations, + ...defaultDiscoveryOperations, + ...defaultEvidenceOperations, + ...defaultMediaMutationOperations, + ...defaultOutputOperations, + ...options.operations, + }); return defineRscAgentBundle( Promise; + readonly audibleSearch?: (input: AudibleSearchInput, options: RscOperationContext) => Promise; + readonly audibleSelect?: ( + input: { readonly candidate: number; readonly candidates: string; readonly note?: string; readonly receipt?: string }, + options: RscOperationContext, + ) => Promise; +} + +const audibleEvidenceSchema = z.object({ + authorMatch: z.boolean(), durationDifferencePercent: z.number().nonnegative().optional(), language: z.string().optional(), + languageMatch: z.boolean(), narratorMatch: z.boolean(), score: z.number(), strictIdentityMatch: z.boolean(), + titleMatch: z.boolean(), unabridged: z.boolean(), +}).strict(); +const audibleCandidateSchema = z.object({ evidence: audibleEvidenceSchema, region: audibleRegionSchema }).passthrough(); +export const audibleSearchResultSchema: z.ZodType = z.object({ + candidates: z.array(audibleCandidateSchema).max(500), + errors: z.array(z.object({ error: z.string().max(4096), region: audibleRegionSchema }).strict()).max(10), + exitCode: z.union([z.literal(0), z.literal(1)]), generatedAt: z.string(), humanReviewRequired: z.literal(true), + mutation: z.literal(false), operation: z.literal('audible-search'), + query: z.object({ author: z.string().optional(), durationSeconds: z.number().positive().optional(), narrator: z.string().optional(), title: z.string() }).strict(), + reviewNote: z.string(), +}).strict() as z.ZodType; +const audibleSelectResultSchema = parityReceiptSchema('audible-select'); +const audibleCacheResultSchema = parityReceiptSchema('audible-cache'); + +export const defaultAudibleOperations: Required = { + audibleCache: (input, options) => cacheAudibleEdition(input, options), + audibleSearch: (input, options) => searchAudible(input, options), + audibleSelect: async (input) => { + const report = audibleSearchResultSchema.parse(await readJson(input.candidates)); + const receipt = selectAudibleEdition(report, { + candidate: input.candidate, + candidateReport: input.candidates, + ...(input.note === undefined ? {} : { note: input.note }), + }); + if (input.receipt !== undefined) await writeReceipt(input.receipt, receipt, [input.candidates]); + return receipt; + }, +}; + +const audibleRegionList = (value: string): readonly AudibleRegion[] => value.split(',').map((region) => { + const candidate = region.trim().toLowerCase(); + if (!audibleRegions.includes(candidate as AudibleRegion)) throw new Error(`Unsupported Audible region: ${candidate}.`); + return candidate as AudibleRegion; +}); + +export const audibleOperations = (operations: Required) => [ + defineOperation({ + cli: { + exitCode: (receipt) => receipt.exitCode, + name: 'audible-search', + parse: (args) => { + const valued = new Set(['--attempts', '--author', '--duration', '--limit', '--narrator', '--regions', '--report', '--title']); + assertOptions(args, new Set(), valued); + if (positionalArguments(args, valued).length > 0) throw new Error('audible-search accepts only named options.'); + const attempts = optionValue(args, '--attempts'); + const duration = optionValue(args, '--duration'); + const limit = optionValue(args, '--limit'); + const regions = optionValue(args, '--regions'); + return { + ...(attempts === undefined ? {} : { attempts: Number(attempts) }), + ...(optionValue(args, '--author') === undefined ? {} : { author: optionValue(args, '--author') }), + ...(duration === undefined ? {} : { durationSeconds: Number(duration) }), + ...(limit === undefined ? {} : { limit: Number(limit) }), + ...(optionValue(args, '--narrator') === undefined ? {} : { narrator: optionValue(args, '--narrator') }), + ...(regions === undefined ? {} : { regions: audibleRegionList(regions) }), + report: requiredOption(args, '--report', 'audible-search'), + title: requiredOption(args, '--title', 'audible-search'), + }; + }, + summary: 'Search and rank Audible identity candidates across reviewed regions.', + usage: 'audible-search --title TITLE --report FILE [--author AUTHOR] [--narrator NARRATOR] [--duration SECONDS] [--regions LIST]', + }, + execute: operations.audibleSearch, + id: 'audible-search', + inputSchema: z.object({ + attempts: z.number().int().min(1).max(10).optional(), author: z.string().min(1).max(512).optional(), + durationSeconds: z.number().positive().optional(), limit: z.number().int().min(1).max(50).optional(), + narrator: z.string().min(1).max(512).optional(), regions: z.array(audibleRegionSchema).min(1).max(10).optional(), + report: pathSchema.optional(), title: z.string().min(1).max(1024), + }).strict(), + mcp: { description: 'Search Audible regions and return ranked identity evidence requiring human review.', name: 'search_audible', openWorld: true, readOnly: false, server: 'curator' }, + render: (receipt) => , + resultSchema: audibleSearchResultSchema, + }), + defineOperation({ + cli: { + name: 'audible-select', + parse: (args) => { + const valued = new Set(['--candidate', '--candidates', '--note', '--receipt']); + assertOptions(args, new Set(), valued); + if (positionalArguments(args, valued).length > 0) throw new Error('audible-select accepts only named options.'); + return { + candidate: Number(requiredOption(args, '--candidate', 'audible-select')), + candidates: requiredOption(args, '--candidates', 'audible-select'), + ...(optionValue(args, '--note') === undefined ? {} : { note: optionValue(args, '--note') }), + receipt: requiredOption(args, '--receipt', 'audible-select'), + }; + }, + summary: 'Record one explicit human-reviewed Audible edition choice.', + usage: 'audible-select --candidates FILE --candidate N --receipt FILE [--note NOTE]', + }, + execute: operations.audibleSelect, + id: 'audible-select', + inputSchema: z.object({ candidate: z.number().int().min(1).max(500), candidates: pathSchema, note: z.string().max(4096).optional(), receipt: pathSchema.optional() }).strict(), + mcp: { description: 'Record an explicit human-reviewed Audible edition choice from a candidate report.', name: 'select_audible_edition', readOnly: false, server: 'curator' }, + render: (receipt) => , + resultSchema: audibleSelectResultSchema, + }), + defineOperation({ + cli: { + name: 'audible-cache', + parse: (args) => { + const valued = new Set(['--asin', '--attempts', '--cache-dir', '--receipt', '--region']); + assertOptions(args, new Set(), valued); + if (positionalArguments(args, valued).length > 0) throw new Error('audible-cache accepts only named options.'); + const attempts = optionValue(args, '--attempts'); + return { + asin: requiredOption(args, '--asin', 'audible-cache'), + ...(attempts === undefined ? {} : { attempts: Number(attempts) }), + cacheDirectory: requiredOption(args, '--cache-dir', 'audible-cache'), + receipt: requiredOption(args, '--receipt', 'audible-cache'), + ...(optionChoice(args, '--region', audibleRegions) === undefined ? {} : { region: optionChoice(args, '--region', audibleRegions) }), + }; + }, + summary: 'Cache one reviewed Audible product, chapters, artwork, and source URLs.', + usage: 'audible-cache --asin ASIN --region REGION --cache-dir DIR --receipt FILE', + }, + execute: operations.audibleCache, + id: 'audible-cache', + inputSchema: z.object({ + asin: z.string().min(1).max(64), attempts: z.number().int().min(1).max(10).optional(), cacheDirectory: pathSchema, + receipt: pathSchema.optional(), region: audibleRegionSchema.optional(), + }).strict(), + mcp: { description: 'Cache a reviewed Audible edition and retained source evidence.', name: 'cache_audible_edition', openWorld: true, readOnly: false, server: 'curator' }, + render: (receipt) => , + resultSchema: audibleCacheResultSchema, + }), +]; diff --git a/examples/audiobook-curator/src/operations/cli-arguments.ts b/examples/audiobook-curator/src/operations/cli-arguments.ts new file mode 100644 index 000000000..5916f5782 --- /dev/null +++ b/examples/audiobook-curator/src/operations/cli-arguments.ts @@ -0,0 +1,63 @@ +/** + * Shared argv toolkit for every operation's `cli.parse` projection. The + * framework's CLI contract is a bare `(argv) => input` function, so option + * lookup, flag validation, and positional handling live here once instead of + * being repeated in each command. + */ + +export const optionValue = (args: readonly string[], option: string): string | undefined => { + const index = args.indexOf(option); + if (index === -1) return undefined; + const value = args[index + 1]; + if (value === undefined || value.startsWith('--')) throw new Error(`${option} requires a value.`); + return value; +}; + +export const assertOptions = (args: readonly string[], flags: ReadonlySet, valued: ReadonlySet): void => { + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]!; + if (!argument.startsWith('--')) continue; + if (flags.has(argument)) continue; + if (valued.has(argument)) { + index += 1; + if (args[index] === undefined || args[index]!.startsWith('--')) throw new Error(`${argument} requires a value.`); + continue; + } + throw new Error(`Unknown option: ${argument}`); + } +}; + +export const positionalArguments = (args: readonly string[], valued: ReadonlySet): readonly string[] => { + const positional: string[] = []; + for (let index = 0; index < args.length; index += 1) { + if (valued.has(args[index]!)) { + index += 1; + } else if (!args[index]!.startsWith('--')) { + positional.push(args[index]!); + } + } + return positional; +}; + +export const onePath = (args: readonly string[], valued: ReadonlySet, command: string): string => { + const positional = positionalArguments(args, valued); + if (positional.length !== 1) throw new Error(`${command} requires exactly one path.`); + return positional[0]!; +}; + +export const requiredOption = (args: readonly string[], option: string, command: string): string => { + const value = optionValue(args, option); + if (value === undefined) throw new Error(`${command} requires ${option}.`); + return value; +}; + +export const optionChoice = ( + args: readonly string[], + option: string, + choices: readonly T[], +): T | undefined => { + const value = optionValue(args, option); + if (value === undefined) return undefined; + if (!choices.includes(value as T)) throw new Error(`${option} must be one of: ${choices.join(', ')}.`); + return value as T; +}; diff --git a/examples/audiobook-curator/src/operations/discovery.tsx b/examples/audiobook-curator/src/operations/discovery.tsx new file mode 100644 index 000000000..6e573a340 --- /dev/null +++ b/examples/audiobook-curator/src/operations/discovery.tsx @@ -0,0 +1,209 @@ +/** + * Source discovery and selection operations: `inspect`, `inventory`, + * `library-audit`, and `select`, backed by `../curator-core.ts` and + * `../library.ts`. All four retain evidence and never mutate media. + */ +import { defineOperation, type RscOperationContext } from '@agent-bundle/rsc-runtime/plugin'; +import React from 'react'; +import { z } from 'zod'; + +import { inspectSources, type InspectionReceipt } from '../curator-core.ts'; +import { readJson, writeReceipt } from '../foundation.ts'; +import { + auditLibrary, + createInventory, + selectInventorySources, + type InventoryReceipt, + type LibraryAuditReceipt, + type SelectionReceipt, +} from '../library.ts'; +import { CuratorResult } from '../result.tsx'; +import { + assertOptions, + onePath, + optionValue, + positionalArguments, + requiredOption, +} from './cli-arguments.ts'; +import { parityReceiptSchema, pathSchema, probeShape } from './schemas.ts'; + +export interface DiscoveryOperations { + readonly inspect: ( + input: { readonly maxFiles?: number; readonly root: string }, + options: RscOperationContext, + ) => Promise; + readonly inventory?: ( + input: { readonly report?: string; readonly source: string; readonly strict?: boolean }, + options: RscOperationContext, + ) => Promise; + readonly libraryAudit?: ( + input: { readonly concurrency?: number; readonly report?: string; readonly sources: readonly string[]; readonly strict?: boolean }, + options: RscOperationContext, + ) => Promise; + readonly select?: ( + input: { readonly inventory: string; readonly report?: string }, + options: RscOperationContext, + ) => Promise; +} + +const inventoryResultSchema = parityReceiptSchema('inventory'); +const libraryResultSchema = parityReceiptSchema('library-audit'); +const selectionResultSchema = parityReceiptSchema('quality-selection'); + +const inspectedFileSchema = z.object({ + ...probeShape, + bytes: z.number().int().nonnegative(), + path: pathSchema, +}).strict(); +const inspectInputSchema = z.object({ + maxFiles: z.number().int().min(1).max(256).optional(), + root: pathSchema, +}).strict(); +const inspectResultSchema = z.object({ + files: z.array(inspectedFileSchema).max(256), + operation: z.literal('inspect'), + root: pathSchema, + totalBytes: z.number().int().nonnegative(), +}).strict(); + +export const defaultDiscoveryOperations: Required = { + inspect: (input, options) => inspectSources(input, options), + inventory: async (input, options) => { + const receipt = await createInventory(input, options); + if (input.report !== undefined) await writeReceipt(input.report, receipt, [input.source]); + return receipt; + }, + libraryAudit: async (input, options) => { + const receipt = await auditLibrary(input, options); + if (input.report !== undefined) await writeReceipt(input.report, receipt, input.sources); + return receipt; + }, + select: async (input) => { + const inventory = inventoryResultSchema.parse(await readJson(input.inventory)); + const receipt = selectInventorySources(inventory, input.inventory); + if (input.report !== undefined) await writeReceipt(input.report, receipt, [input.inventory]); + return receipt; + }, +}; + +export const discoveryOperations = (operations: Required) => [ + defineOperation({ + cli: { + name: 'inspect', + parse: (args) => { + const valued = new Set(['--max-files']); + assertOptions(args, new Set(), valued); + const maximum = optionValue(args, '--max-files'); + return { + ...(maximum === undefined ? {} : { maxFiles: Number(maximum) }), + root: onePath(args, valued, 'inspect'), + }; + }, + summary: 'Inspect a bounded audiobook source tree without changing it.', + usage: 'inspect [--max-files N] ', + }, + execute: operations.inspect, + id: 'inspect', + inputSchema: inspectInputSchema, + mcp: { + description: 'Inspect a bounded directory tree and report supported audiobook media without changing it.', + name: 'inspect_sources', + readOnly: true, + server: 'curator', + }, + render: (receipt) => , + resultSchema: inspectResultSchema, + }), + defineOperation({ + cli: { + exitCode: (receipt) => receipt.exitCode, + name: 'inventory', + parse: (args) => { + const valued = new Set(['--report']); + assertOptions(args, new Set(['--strict']), valued); + return { + report: requiredOption(args, '--report', 'inventory'), + source: onePath(args, valued, 'inventory'), + ...(args.includes('--strict') ? { strict: true } : {}), + }; + }, + summary: 'Probe source audio without changing it.', + usage: 'inventory --report FILE [--strict]', + }, + execute: operations.inventory, + id: 'inventory', + inputSchema: z.object({ report: pathSchema.optional(), source: pathSchema, strict: z.boolean().optional() }).strict(), + mcp: { + description: 'Inventory source audio with retained per-file probe evidence.', + name: 'inventory_sources', + readOnly: false, + server: 'curator', + }, + render: (receipt) => , + resultSchema: inventoryResultSchema, + }), + defineOperation({ + cli: { + exitCode: (receipt) => receipt.exitCode, + name: 'library-audit', + parse: (args) => { + const valued = new Set(['--concurrency', '--report']); + assertOptions(args, new Set(['--strict']), valued); + const concurrency = optionValue(args, '--concurrency'); + const sources = positionalArguments(args, valued); + if (sources.length === 0) throw new Error('library-audit requires at least one source path.'); + return { + ...(concurrency === undefined ? {} : { concurrency: Number(concurrency) }), + report: requiredOption(args, '--report', 'library-audit'), + sources, + ...(args.includes('--strict') ? { strict: true } : {}), + }; + }, + summary: 'Audit metadata, artwork, chapters, duplicate candidates, and multipart groups.', + usage: 'library-audit --report FILE [--concurrency N] [--strict]', + }, + execute: operations.libraryAudit, + id: 'library-audit', + inputSchema: z.object({ + concurrency: z.number().int().min(1).max(8).optional(), + report: pathSchema.optional(), + sources: z.array(pathSchema).min(1).max(64), + strict: z.boolean().optional(), + }).strict(), + mcp: { + description: 'Audit audiobook library metadata, duplicates, and multipart evidence without deletion advice.', + name: 'audit_library', + readOnly: false, + server: 'curator', + }, + render: (receipt) => , + resultSchema: libraryResultSchema, + }), + defineOperation({ + cli: { + name: 'select', + parse: (args) => { + const valued = new Set(['--inventory', '--report']); + assertOptions(args, new Set(), valued); + if (positionalArguments(args, valued).length > 0) throw new Error('select accepts only named options.'); + return { + inventory: requiredOption(args, '--inventory', 'select'), + report: requiredOption(args, '--report', 'select'), + }; + }, + summary: 'Choose the strongest source among normalized collisions.', + usage: 'select --inventory FILE --report FILE', + }, + execute: operations.select, + id: 'select', + inputSchema: z.object({ inventory: pathSchema, report: pathSchema.optional() }).strict(), + mcp: { + description: 'Select strongest source encodings while retaining alternates and duration review evidence.', + name: 'select_sources', + readOnly: false, + server: 'curator', + }, + render: (receipt) => , + resultSchema: selectionResultSchema, + }), +]; diff --git a/examples/audiobook-curator/src/operations/evidence.tsx b/examples/audiobook-curator/src/operations/evidence.tsx new file mode 100644 index 000000000..a48865101 --- /dev/null +++ b/examples/audiobook-curator/src/operations/evidence.tsx @@ -0,0 +1,167 @@ +/** + * Acoustic and transcript identity-evidence operations: `acoustic-verify`, + * `acoustic-identify`, and `whisper-verify`, backed by `../evidence.ts`. + */ +import { defineOperation, type RscOperationContext } from '@agent-bundle/rsc-runtime/plugin'; +import React from 'react'; +import { z } from 'zod'; + +import { + identifyAudibleSample, + verifyAudibleSample, + verifyWithWhisper, + type AcousticIdentifyReceipt, + type AcousticReceipt, + type AcousticVerifyInput, + type WhisperInput, + type WhisperReceipt, +} from '../evidence.ts'; +import { readJson } from '../foundation.ts'; +import { CuratorResult } from '../result.tsx'; +import { + assertOptions, + optionChoice, + optionValue, + positionalArguments, + requiredOption, +} from './cli-arguments.ts'; +import { audibleRegions, audibleRegionSchema, parityReceiptSchema, pathSchema } from './schemas.ts'; + +export interface EvidenceOperations { + readonly acousticIdentify?: ( + input: { readonly all?: boolean; readonly attempts?: number; readonly candidates: string; readonly chunkSeconds?: number; readonly file: string; readonly receipt?: string; readonly top?: number; readonly verbose?: boolean }, + options: RscOperationContext, + ) => Promise; + readonly acousticVerify?: (input: AcousticVerifyInput, options: RscOperationContext) => Promise; + readonly whisperVerify?: (input: WhisperInput, options: RscOperationContext) => Promise; +} + +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; + return identifyAudibleSample({ + ...input, + candidates: rows, + candidatesReport: input.candidates, + }, options); + }, + acousticVerify: (input, options) => verifyAudibleSample(input, options), + whisperVerify: (input, options) => verifyWithWhisper(input, options), +}; + +const acousticResultSchema = parityReceiptSchema('audiolocate'); +const acousticIdentifyResultSchema = parityReceiptSchema('acoustic-identify'); +const whisperResultSchema = parityReceiptSchema('whisper-identity'); + +export const evidenceOperations = (operations: Required) => [ + defineOperation({ + cli: { + exitCode: (receipt) => receipt.exitCode, + name: 'acoustic-verify', + parse: (args) => { + const valued = new Set(['--asin', '--attempts', '--audiolocate-python', '--chunk-seconds', '--file', '--receipt', '--region', '--sample-url']); + assertOptions(args, new Set(['--verbose']), valued); + if (positionalArguments(args, valued).length > 0) throw new Error('acoustic-verify accepts only named options.'); + const attempts = optionValue(args, '--attempts'); + const chunks = optionValue(args, '--chunk-seconds'); + return { + asin: requiredOption(args, '--asin', 'acoustic-verify'), + ...(attempts === undefined ? {} : { attempts: Number(attempts) }), + ...(optionValue(args, '--audiolocate-python') === undefined ? {} : { audiolocatePython: optionValue(args, '--audiolocate-python') }), + ...(chunks === undefined ? {} : { chunkSeconds: Number(chunks) }), + file: requiredOption(args, '--file', 'acoustic-verify'), + receipt: requiredOption(args, '--receipt', 'acoustic-verify'), + ...(optionChoice(args, '--region', audibleRegions) === undefined ? {} : { region: optionChoice(args, '--region', audibleRegions) }), + ...(optionValue(args, '--sample-url') === undefined ? {} : { sampleUrl: optionValue(args, '--sample-url') }), + ...(args.includes('--verbose') ? { verbose: true } : {}), + }; + }, + summary: 'Compare one bounded Audible sample with local audio through optional Audiolocate.', + usage: 'acoustic-verify --file FILE --asin ASIN --region REGION --receipt FILE [--audiolocate-python PATH]', + }, + execute: operations.acousticVerify, + id: 'acoustic-verify', + inputSchema: z.object({ + asin: z.string().min(1).max(64), attempts: z.number().int().min(1).max(10).optional(), audiolocatePython: pathSchema.optional(), + chunkSeconds: z.number().int().min(1).max(86_400).optional(), file: pathSchema, receipt: pathSchema.optional(), + region: audibleRegionSchema.optional(), sampleUrl: z.url().optional(), verbose: z.boolean().optional(), + }).strict(), + mcp: { description: 'Compare a bounded Audible sample with local audio through an optional Audiolocate Python capability.', name: 'verify_audible_sample', openWorld: true, readOnly: false, server: 'curator' }, + render: (receipt) => , + resultSchema: acousticResultSchema, + }), + defineOperation({ + cli: { + exitCode: (receipt) => receipt.exitCode, + name: 'acoustic-identify', + parse: (args) => { + const valued = new Set(['--attempts', '--candidates', '--chunk-seconds', '--file', '--receipt', '--top']); + assertOptions(args, new Set(['--all', '--verbose']), valued); + if (positionalArguments(args, valued).length > 0) throw new Error('acoustic-identify accepts only named options.'); + const attempts = optionValue(args, '--attempts'); + const chunks = optionValue(args, '--chunk-seconds'); + const top = optionValue(args, '--top'); + return { + ...(args.includes('--all') ? { all: true } : {}), + ...(attempts === undefined ? {} : { attempts: Number(attempts) }), + candidates: requiredOption(args, '--candidates', 'acoustic-identify'), + ...(chunks === undefined ? {} : { chunkSeconds: Number(chunks) }), + file: requiredOption(args, '--file', 'acoustic-identify'), + receipt: requiredOption(args, '--receipt', 'acoustic-identify'), + ...(top === undefined ? {} : { top: Number(top) }), + ...(args.includes('--verbose') ? { verbose: true } : {}), + }; + }, + summary: 'Try score-ranked, deduplicated Audible candidates and retain per-candidate evidence.', + usage: 'acoustic-identify --file FILE --candidates FILE --receipt FILE [--top N] [--all]', + }, + execute: operations.acousticIdentify, + id: 'acoustic-identify', + inputSchema: z.object({ + all: z.boolean().optional(), attempts: z.number().int().min(1).max(10).optional(), candidates: pathSchema, + chunkSeconds: z.number().int().min(1).max(86_400).optional(), file: pathSchema, receipt: pathSchema.optional(), + top: z.number().int().min(1).max(10).optional(), verbose: z.boolean().optional(), + }).strict(), + mcp: { description: 'Try ranked Audible candidates, retaining skips/errors and stopping at the first acoustic match by default.', name: 'identify_audible_sample', openWorld: true, readOnly: false, server: 'curator' }, + render: (receipt) => , + resultSchema: acousticIdentifyResultSchema, + }), + defineOperation({ + cli: { + exitCode: (receipt) => receipt.exitCode, + name: 'whisper-verify', + parse: (args) => { + const valued = new Set(['--author', '--file', '--language', '--max-windows', '--minimum-chars', '--model', '--receipt', '--threads', '--title', '--whisper-cli', '--window-seconds']); + assertOptions(args, new Set(), valued); + if (positionalArguments(args, valued).length > 0) throw new Error('whisper-verify accepts only named options.'); + return { + ...(optionValue(args, '--author') === undefined ? {} : { author: optionValue(args, '--author') }), + file: requiredOption(args, '--file', 'whisper-verify'), + ...(optionValue(args, '--language') === undefined ? {} : { language: optionValue(args, '--language') }), + ...(optionValue(args, '--max-windows') === undefined ? {} : { maxWindows: Number(optionValue(args, '--max-windows')) }), + ...(optionValue(args, '--minimum-chars') === undefined ? {} : { minimumChars: Number(optionValue(args, '--minimum-chars')) }), + model: requiredOption(args, '--model', 'whisper-verify'), + receipt: requiredOption(args, '--receipt', 'whisper-verify'), + ...(optionValue(args, '--threads') === undefined ? {} : { threads: Number(optionValue(args, '--threads')) }), + ...(optionValue(args, '--title') === undefined ? {} : { title: optionValue(args, '--title') }), + ...(optionValue(args, '--whisper-cli') === undefined ? {} : { whisperCli: optionValue(args, '--whisper-cli') }), + ...(optionValue(args, '--window-seconds') === undefined ? {} : { windowSeconds: Number(optionValue(args, '--window-seconds')) }), + }; + }, + summary: 'Transcribe distributed audiobook windows for human language and identity review.', + usage: 'whisper-verify --file FILE --model FILE --receipt FILE [--language CODE] [--max-windows N]', + }, + execute: operations.whisperVerify, + id: 'whisper-verify', + inputSchema: z.object({ + author: z.string().max(512).optional(), file: pathSchema, language: z.string().min(1).max(64).optional(), + maxWindows: z.number().int().min(5).max(11).optional(), minimumChars: z.number().int().min(1).max(16_384).optional(), + model: pathSchema, receipt: pathSchema.optional(), threads: z.number().int().min(1).max(256).optional(), title: z.string().max(1024).optional(), + whisperCli: pathSchema.optional(), windowSeconds: z.number().int().min(1).max(3600).optional(), + }).strict(), + mcp: { description: 'Extract and transcribe distributed PCM windows for human language, story, and narrator review.', name: 'verify_with_whisper', readOnly: false, server: 'curator' }, + render: (receipt) => , + resultSchema: whisperResultSchema, + }), +]; diff --git a/examples/audiobook-curator/src/operations/media-mutation.tsx b/examples/audiobook-curator/src/operations/media-mutation.tsx new file mode 100644 index 000000000..909914895 --- /dev/null +++ b/examples/audiobook-curator/src/operations/media-mutation.tsx @@ -0,0 +1,98 @@ +/** + * Plan-first derived-media repair operations: `apply-metadata` and + * `apply-chapters`, backed by `../media-mutation.ts`. + */ +import { defineOperation, type RscOperationContext } from '@agent-bundle/rsc-runtime/plugin'; +import React from 'react'; +import { z } from 'zod'; + +import { + applyAudiobookChapters, + applyAudiobookMetadata, + type ChapterInput, + type ChapterReceipt, + type MetadataInput, + type MetadataReceipt, +} from '../media-mutation.ts'; +import { CuratorResult } from '../result.tsx'; +import { + assertOptions, + optionValue, + positionalArguments, + requiredOption, +} from './cli-arguments.ts'; +import { parityReceiptSchema, pathSchema } from './schemas.ts'; + +export interface MediaMutationOperations { + readonly applyChapters?: (input: ChapterInput, options: RscOperationContext) => Promise; + readonly applyMetadata?: (input: MetadataInput, options: RscOperationContext) => Promise; +} + +export const defaultMediaMutationOperations: Required = { + applyChapters: (input, options) => applyAudiobookChapters(input, options), + applyMetadata: (input, options) => applyAudiobookMetadata(input, options), +}; + +const metadataResultSchema = parityReceiptSchema('apply-metadata'); +const chaptersResultSchema = parityReceiptSchema('apply-chapters'); + +export const mediaMutationOperations = (operations: Required) => [ + defineOperation({ + cli: { + name: 'apply-metadata', + parse: (args) => { + const valued = new Set(['--artwork', '--author', '--file', '--language', '--narrator', '--product', '--receipt', '--title', '--year']); + assertOptions(args, new Set(['--apply']), valued); + if (positionalArguments(args, valued).length > 0) throw new Error('apply-metadata accepts only named options.'); + return { + ...(args.includes('--apply') ? { apply: true } : {}), + ...(optionValue(args, '--artwork') === undefined ? {} : { artwork: optionValue(args, '--artwork') }), + ...(optionValue(args, '--author') === undefined ? {} : { author: optionValue(args, '--author') }), + file: requiredOption(args, '--file', 'apply-metadata'), + ...(optionValue(args, '--language') === undefined ? {} : { language: optionValue(args, '--language') }), + ...(optionValue(args, '--narrator') === undefined ? {} : { narrator: optionValue(args, '--narrator') }), + product: requiredOption(args, '--product', 'apply-metadata'), + receipt: requiredOption(args, '--receipt', 'apply-metadata'), + ...(optionValue(args, '--title') === undefined ? {} : { title: optionValue(args, '--title') }), + ...(optionValue(args, '--year') === undefined ? {} : { year: optionValue(args, '--year') }), + }; + }, + summary: 'Plan or apply verified Audible metadata and artwork without changing encoded audio.', + usage: 'apply-metadata --file FILE --product FILE --receipt FILE [--artwork FILE] [--language CODE] [--apply]', + }, + execute: operations.applyMetadata, + id: 'apply-metadata', + inputSchema: z.object({ + apply: z.boolean().optional(), artwork: pathSchema.optional(), author: z.string().max(512).optional(), file: pathSchema, + language: z.string().min(1).max(64).optional(), narrator: z.string().max(512).optional(), product: pathSchema, + receipt: pathSchema.optional(), title: z.string().max(1024).optional(), year: z.string().max(64).optional(), + }).strict(), + mcp: { description: 'Plan or explicitly apply verified catalog metadata and artwork while preserving every audio stream.', destructive: true, name: 'apply_audiobook_metadata', readOnly: false, server: 'curator' }, + render: (receipt) => , + resultSchema: metadataResultSchema, + }), + defineOperation({ + cli: { + name: 'apply-chapters', + parse: (args) => { + const valued = new Set(['--chapters', '--file', '--receipt']); + assertOptions(args, new Set(['--apply']), valued); + if (positionalArguments(args, valued).length > 0) throw new Error('apply-chapters accepts only named options.'); + return { + ...(args.includes('--apply') ? { apply: true } : {}), + chapters: requiredOption(args, '--chapters', 'apply-chapters'), + file: requiredOption(args, '--file', 'apply-chapters'), + receipt: requiredOption(args, '--receipt', 'apply-chapters'), + }; + }, + summary: 'Plan or apply verified generic or Audible chapter rows without changing encoded audio.', + usage: 'apply-chapters --file FILE --chapters FILE --receipt FILE [--apply]', + }, + execute: operations.applyChapters, + id: 'apply-chapters', + inputSchema: z.object({ apply: z.boolean().optional(), chapters: pathSchema, file: pathSchema, receipt: pathSchema.optional() }).strict(), + mcp: { description: 'Plan or explicitly apply verified chapter rows while preserving all non-chapter media state.', destructive: true, name: 'apply_audiobook_chapters', readOnly: false, server: 'curator' }, + render: (receipt) => , + resultSchema: chaptersResultSchema, + }), +]; diff --git a/examples/audiobook-curator/src/operations/output.tsx b/examples/audiobook-curator/src/operations/output.tsx new file mode 100644 index 000000000..726edbb0b --- /dev/null +++ b/examples/audiobook-curator/src/operations/output.tsx @@ -0,0 +1,176 @@ +/** + * Output production and verification operations: `convert`, `prepare`, and + * `audit`, backed by `../conversion.ts`, `../curator-core.ts`, and + * `../integrity-audit.ts`. Conversion and preparation plan by default and + * mutate only a derived destination; the audit never mutates. + */ +import { defineOperation, type RscOperationContext } from '@agent-bundle/rsc-runtime/plugin'; +import React from 'react'; +import { z } from 'zod'; + +import { convertAudiobook, type ConvertInput, type ConvertReceipt } from '../conversion.ts'; +import { prepareAudiobook, type PrepareInput, type PrepareReceipt } from '../curator-core.ts'; +import { + auditAudiobookIntegrity, + type IntegrityAuditInput, + type IntegrityAuditReceipt, +} from '../integrity-audit.ts'; +import { CuratorResult } from '../result.tsx'; +import { + assertOptions, + onePath, + optionChoice, + optionValue, + positionalArguments, + requiredOption, +} from './cli-arguments.ts'; +import { parityReceiptSchema, pathSchema, probeSchema } from './schemas.ts'; + +export interface OutputOperations { + readonly audit: (input: IntegrityAuditInput, options: RscOperationContext) => Promise; + readonly convert?: (input: ConvertInput, options: RscOperationContext) => Promise; + readonly prepare: (input: PrepareInput, options: RscOperationContext) => Promise; +} + +export const defaultOutputOperations: Required = { + audit: (input, options) => auditAudiobookIntegrity(input, options), + convert: (input, options) => convertAudiobook(input, options), + prepare: (input, options) => prepareAudiobook(input, options), +}; + +const convertResultSchema = parityReceiptSchema('convert'); +const auditResultSchema = parityReceiptSchema('audit'); +const prepareInputSchema = z.object({ + apply: z.boolean().optional(), + outputName: z.string().min(5).max(204).optional(), + outputRoot: pathSchema, + source: pathSchema, +}).strict(); +const prepareResultSchema = z.object({ + applied: z.boolean(), + operation: z.literal('prepare'), + output: pathSchema, + probe: probeSchema, + source: pathSchema, +}).strict(); + +export const outputOperations = (operations: Required) => [ + defineOperation({ + cli: { + name: 'convert', + parse: (args) => { + const valued = new Set([ + '--artwork', '--audio-bitrate', '--audio-codec', '--author', '--engine', '--forge-aac-encoder', + '--forge-cli', '--jobs', '--language', '--narrator', '--output', '--receipt', '--selection', '--title', '--year', + ]); + assertOptions(args, new Set(['--apply', '--overwrite']), valued); + if (positionalArguments(args, valued).length > 0) throw new Error('convert accepts only named options.'); + const audioCodec = optionChoice(args, '--audio-codec', ['aac', 'alac'] as const); + const engine = optionChoice(args, '--engine', ['audiobook-forge', 'ffmpeg'] as const); + const jobs = optionValue(args, '--jobs'); + return { + ...(args.includes('--apply') ? { apply: true } : {}), + ...(args.includes('--overwrite') ? { overwrite: true } : {}), + ...(optionValue(args, '--artwork') === undefined ? {} : { artwork: optionValue(args, '--artwork') }), + ...(optionValue(args, '--audio-bitrate') === undefined ? {} : { audioBitrate: optionValue(args, '--audio-bitrate') }), + ...(audioCodec === undefined ? {} : { audioCodec }), + author: requiredOption(args, '--author', 'convert'), + ...(engine === undefined ? {} : { engine }), + ...(optionValue(args, '--forge-aac-encoder') === undefined ? {} : { forgeAacEncoder: optionValue(args, '--forge-aac-encoder') }), + ...(optionValue(args, '--forge-cli') === undefined ? {} : { forgeCli: optionValue(args, '--forge-cli') }), + ...(jobs === undefined ? {} : { jobs: Number(jobs) }), + ...(optionValue(args, '--language') === undefined ? {} : { language: optionValue(args, '--language') }), + ...(optionValue(args, '--narrator') === undefined ? {} : { narrator: optionValue(args, '--narrator') }), + output: requiredOption(args, '--output', 'convert'), + receipt: requiredOption(args, '--receipt', 'convert'), + selection: requiredOption(args, '--selection', 'convert'), + title: requiredOption(args, '--title', 'convert'), + ...(optionValue(args, '--year') === undefined ? {} : { year: optionValue(args, '--year') }), + }; + }, + summary: 'Plan or apply a verified conversion to one chaptered M4B.', + usage: 'convert --selection FILE --output PATH --receipt FILE --title TITLE --author AUTHOR [--apply] [--overwrite]', + }, + execute: operations.convert, + id: 'convert', + inputSchema: z.object({ + apply: z.boolean().optional(), artwork: pathSchema.optional(), audioBitrate: z.string().min(2).max(32).optional(), + audioCodec: z.enum(['aac', 'alac']).optional(), author: z.string().min(1).max(512), + engine: z.enum(['audiobook-forge', 'ffmpeg']).optional(), forgeAacEncoder: z.string().min(1).max(128).optional(), + forgeCli: pathSchema.optional(), jobs: z.number().int().min(0).max(256).optional(), language: z.string().min(1).max(64).optional(), + narrator: z.string().min(1).max(512).optional(), output: pathSchema, overwrite: z.boolean().optional(), receipt: pathSchema.optional(), + selection: pathSchema, title: z.string().min(1).max(1024), year: z.string().min(1).max(64).optional(), + }).strict(), + mcp: { + description: 'Plan or explicitly apply a verified FFmpeg or Audiobook Forge conversion while preserving sources.', + destructive: true, + name: 'convert_audiobook', + readOnly: false, + server: 'curator', + }, + render: (receipt) => , + resultSchema: convertResultSchema, + }), + defineOperation({ + cli: { + name: 'prepare', + parse: (args) => { + const valued = new Set(['--name', '--output']); + assertOptions(args, new Set(['--apply']), valued); + const outputRoot = optionValue(args, '--output'); + if (outputRoot === undefined) throw new Error('prepare requires --output.'); + const outputName = optionValue(args, '--name'); + return { + ...(args.includes('--apply') ? { apply: true } : {}), + ...(outputName === undefined ? {} : { outputName }), + outputRoot, + source: onePath(args, valued, 'prepare'), + }; + }, + summary: 'Plan an M4B output or apply the plan when explicitly requested.', + usage: 'prepare [--apply] [--name FILE] --output DIR ', + }, + execute: operations.prepare, + id: 'prepare', + inputSchema: prepareInputSchema, + mcp: { + description: 'Plan an M4B output, or apply the plan only when apply is explicitly true.', + destructive: true, + name: 'prepare_audiobook', + readOnly: false, + server: 'curator', + }, + render: (receipt) => , + resultSchema: prepareResultSchema, + }), + defineOperation({ + cli: { + exitCode: (receipt) => receipt.exitCode, + name: 'audit', + parse: (args) => { + const valued = new Set(['--conversion-receipt', '--file', '--receipt']); + assertOptions(args, new Set(['--full-decode']), valued); + if (positionalArguments(args, valued).length > 0) throw new Error('audit accepts only named options.'); + return { + ...(optionValue(args, '--conversion-receipt') === undefined ? {} : { conversionReceipt: optionValue(args, '--conversion-receipt') }), + file: requiredOption(args, '--file', 'audit'), + ...(args.includes('--full-decode') ? { fullDecode: true } : {}), + receipt: requiredOption(args, '--receipt', 'audit'), + }; + }, + summary: 'Validate metadata, chapters, source mapping, hashes, and optional complete decode.', + usage: 'audit --file FILE --receipt FILE [--conversion-receipt FILE] [--full-decode]', + }, + execute: operations.audit, + id: 'audit', + inputSchema: z.object({ conversionReceipt: pathSchema.optional(), file: pathSchema, fullDecode: z.boolean().optional(), receipt: pathSchema.optional() }).strict(), + mcp: { + description: 'Validate chapter structure, optional conversion mapping, file/audio hashes, probe facts, and optional full decode.', + name: 'audit_audiobook', + readOnly: false, + server: 'curator', + }, + render: (receipt) => , + resultSchema: auditResultSchema, + }), +]; diff --git a/examples/audiobook-curator/src/operations/schemas.ts b/examples/audiobook-curator/src/operations/schemas.ts new file mode 100644 index 000000000..926e9095c --- /dev/null +++ b/examples/audiobook-curator/src/operations/schemas.ts @@ -0,0 +1,34 @@ +/** + * Schema fragments shared by more than one operation module: path and tag + * bounds, the Audible region enum, the probe receipt shape, and the loose + * parity-receipt wrapper used to validate rich domain receipts at the + * operation boundary. + */ +import { z } from 'zod'; + +export const pathSchema = z.string().min(1).max(4096); + +export const audibleRegions = ['au', 'ca', 'de', 'es', 'fr', 'in', 'it', 'jp', 'uk', 'us'] as const; + +export const audibleRegionSchema = z.enum(audibleRegions); + +export const tagsSchema = z.record(z.string().max(128), z.string().max(4096)); + +export const probeShape = { + channels: z.number().nonnegative().optional(), + codec: z.string(), + durationSeconds: z.number().nonnegative(), + format: z.string(), + sampleRate: z.number().nonnegative().optional(), + tags: tagsSchema, +}; + +export const probeSchema = z.object(probeShape).strict(); + +export const parityReceiptSchema = ( + operation: T['operation'], +): z.ZodType => z.object({ + generatedAt: z.string().min(1), + mutation: z.boolean(), + operation: z.literal(operation), +}).catchall(z.json()) as unknown as z.ZodType; From 700ab403aaa731405af2df1cf60b32551ed384f6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 30 Aug 2026 19:32:39 +0000 Subject: [PATCH 2/3] refactor(examples/audiobook-curator): deslop domain modules and remove dead code Consolidate the copy-paste helpers that had drifted across domain modules into foundation.ts: asRecord (4 copies), sha256File (3), the ffmetadata escaper (2), the fsync publish dance (4), the bounded worker pool (2), and contributor-name extraction (3). evidence.ts now reuses audible's bounded retry request instead of its own duplicate. Remove dead code: mcp-tools.tsx (an unused hand-rolled bridge duplicating createRscMcpServer), renderCuratorResult, and curator-core's auditAudiobook (superseded by integrity-audit's audit operation). The MCP render coverage moves into application.test.tsx against the real operation catalog. Collapse the double-lookup `...(optionValue(...) === undefined ? {} : ...)` argv spreads behind optionalField/numberOption, hoist the one inline test import, and give the receipt summary switch a never-checked default. --- examples/audiobook-curator/src/audible.ts | 29 +++--- examples/audiobook-curator/src/conversion.ts | 93 +++++------------- .../audiobook-curator/src/curator-core.ts | 68 +------------ examples/audiobook-curator/src/evidence.ts | 49 +++------- examples/audiobook-curator/src/foundation.ts | 95 ++++++++++++++++--- examples/audiobook-curator/src/index.ts | 4 +- .../audiobook-curator/src/integrity-audit.ts | 37 ++------ examples/audiobook-curator/src/library.ts | 22 +---- examples/audiobook-curator/src/mcp-tools.tsx | 52 ---------- .../audiobook-curator/src/media-mutation.ts | 85 +++++++---------- .../src/operations/audible.tsx | 22 ++--- .../src/operations/cli-arguments.ts | 9 ++ .../src/operations/discovery.tsx | 9 +- .../src/operations/evidence.tsx | 39 ++++---- .../src/operations/media-mutation.tsx | 13 +-- .../src/operations/output.tsx | 30 +++--- examples/audiobook-curator/src/result.tsx | 10 +- .../tests/application.test.tsx | 12 +++ .../tests/curator-core.test.ts | 17 +--- .../tests/mcp-tools.test.tsx | 68 ------------- 20 files changed, 259 insertions(+), 504 deletions(-) delete mode 100644 examples/audiobook-curator/src/mcp-tools.tsx delete mode 100644 examples/audiobook-curator/tests/mcp-tools.test.tsx diff --git a/examples/audiobook-curator/src/audible.ts b/examples/audiobook-curator/src/audible.ts index ffe63dcf9..8ede9a35d 100644 --- a/examples/audiobook-curator/src/audible.ts +++ b/examples/audiobook-curator/src/audible.ts @@ -1,10 +1,13 @@ -import { mkdir, mkdtemp, open, rename, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, extname, join, resolve } from 'node:path'; import { CuratorError, audibleHosts, + contributorNames, normalizedIdentity, + syncDirectory, + syncFile, utcNow, writeReceipt, } from './foundation.ts'; @@ -107,15 +110,13 @@ const objects = (value: unknown): Record[] => Array.isArray(val ? value.filter((row): row is Record => row !== null && typeof row === 'object' && !Array.isArray(row)) : []; -const names = (value: unknown): string[] => objects(value).flatMap((row) => typeof row.name === 'string' ? [row.name] : []); - export const audibleCandidateEvidence = ( query: AudibleQuery, product: Readonly>, ): AudibleCandidateEvidence => { const actualTitle = `${String(product.title ?? '')} ${String(product.subtitle ?? '')}`; - const authors = names(product.authors); - const narrators = names(product.narrators); + const authors = contributorNames(product.authors); + const narrators = contributorNames(product.narrators); const candidateSeconds = Number(product.runtime_length_min ?? 0) * 60; const difference = query.durationSeconds !== undefined && query.durationSeconds > 0 && candidateSeconds > 0 ? Math.abs(candidateSeconds - query.durationSeconds) / query.durationSeconds * 100 @@ -181,7 +182,7 @@ export const defaultCuratorHttpClient: CuratorHttpClient = async (url, options = return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown; }; -const request = async ( +export const requestWithAttempts = async ( http: CuratorHttpClient, url: string, attempts: number, @@ -221,7 +222,7 @@ export const searchAudible = async ( title: input.title, }); try { - const payload = await request(http, url, attempts, { signal: dependencies.signal }); + const payload = await requestWithAttempts(http, url, attempts, { signal: dependencies.signal }); const rows = objects((payload as Record | null)?.products).slice(0, limit); candidates.push(...rows.map((product) => Object.freeze({ ...product, @@ -278,13 +279,9 @@ const writeBinary = async (path: string, bytes: Buffer): Promise => { const staged = join(staging, 'payload'); try { await writeFile(staged, bytes, { mode: 0o600 }); - const file = await open(staged, 'r'); - try { await file.sync(); } finally { await file.close(); } + await syncFile(staged); await rename(staged, path); - const directory = await open(parent, 'r'); - try { await directory.sync(); } catch (error) { - if (!['EACCES', 'EINVAL'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error; - } finally { await directory.close(); } + await syncDirectory(parent); } finally { await rm(staging, { force: true, recursive: true }); } @@ -301,7 +298,7 @@ export const cacheAudibleEdition = async ( const productUrl = audibleUrl(region, `/1.0/catalog/products/${encodeURIComponent(input.asin)}`, { response_groups: 'contributors,category_ladders,media,product_desc,product_extended_attrs,sample', }); - const productPayload = await request(http, productUrl, attempts, { signal: dependencies.signal }); + const productPayload = await requestWithAttempts(http, productUrl, attempts, { signal: dependencies.signal }); const product = (productPayload as Record | null)?.product; if (product === null || typeof product !== 'object' || Array.isArray(product)) throw new CuratorError('Audible product response is invalid.'); const productRecord = product as Record; @@ -313,7 +310,7 @@ export const cacheAudibleEdition = async ( let chapterPath: string | undefined; let chapterError: string | undefined; try { - const chapters = await request(http, chapterUrl, attempts, { signal: dependencies.signal }); + const chapters = await requestWithAttempts(http, chapterUrl, attempts, { signal: dependencies.signal }); chapterPath = join(cache, 'chapters.json'); await writeReceipt(chapterPath, chapters); } catch (error) { @@ -325,7 +322,7 @@ export const cacheAudibleEdition = async ( : undefined; let artworkPath: string | undefined; if (imageUrl !== undefined) { - const bytes = await request(http, imageUrl, attempts, { binary: true, signal: dependencies.signal }); + const bytes = await requestWithAttempts(http, imageUrl, attempts, { binary: true, signal: dependencies.signal }); if (!Buffer.isBuffer(bytes)) throw new CuratorError('Audible artwork response is not binary.'); artworkPath = join(cache, extname(new URL(imageUrl).pathname).toLowerCase() === '.png' ? 'cover.png' : 'cover.jpg'); await writeBinary(artworkPath, bytes); diff --git a/examples/audiobook-curator/src/conversion.ts b/examples/audiobook-curator/src/conversion.ts index 5e1417734..808c18ce4 100644 --- a/examples/audiobook-curator/src/conversion.ts +++ b/examples/audiobook-curator/src/conversion.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto'; import { access, copyFile, @@ -6,7 +5,6 @@ import { lstat, mkdir, mkdtemp, - open, opendir, rename, rm, @@ -15,7 +13,20 @@ import { } from 'node:fs/promises'; import { basename, dirname, extname, join, relative, resolve } from 'node:path'; -import { CuratorError, naturalCompare, readJson, safeFilename, utcNow, writeReceipt } from './foundation.ts'; +import { + CuratorError, + asRecord, + escapeFfmetadata, + mapWithConcurrency, + naturalCompare, + readJson, + safeFilename, + sha256File, + syncDirectory, + syncFile, + utcNow, + writeReceipt, +} from './foundation.ts'; import { probeMediaDetails, probeMediaRecord, @@ -159,19 +170,15 @@ const chapterTitle = (path: string, root: string): string => { return parts.join(' - '); }; -const object = (value: unknown): Record => value !== null && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : {}; - export const chapterRows = (details: MediaDetails): readonly ChapterRow[] => { const chapters = Array.isArray(details.chapters) ? details.chapters : []; return Object.freeze(chapters.map((chapter, index) => { - const row = object(chapter); + const row = asRecord(chapter); return Object.freeze({ endSeconds: Number(row.end_time ?? 0), number: index + 1, startSeconds: Number(row.start_time ?? 0), - title: String(object(row.tags).title ?? '').trim(), + title: String(asRecord(row.tags).title ?? '').trim(), }); })); }; @@ -194,13 +201,6 @@ export const chapterMappingIssues = ( return Object.freeze(issues); }; -const metadataEscape = (value: unknown): string => String(value) - .replaceAll('\\', '\\\\') - .replaceAll('=', '\\=') - .replaceAll(';', '\\;') - .replaceAll('#', '\\#') - .replaceAll('\n', '\\n'); - const metadataDocument = ( inputs: readonly string[], root: string, @@ -208,11 +208,11 @@ const metadataDocument = ( durations: readonly number[], ): string => { const lines = [';FFMETADATA1']; - for (const [key, value] of Object.entries(metadata)) if (value !== undefined && value !== '') lines.push(`${key}=${metadataEscape(value)}`); + for (const [key, value] of Object.entries(metadata)) if (value !== undefined && value !== '') lines.push(`${key}=${escapeFfmetadata(value)}`); let start = 0; for (let index = 0; index < inputs.length; index += 1) { const end = start + Math.max(Math.round(durations[index]! * 1000), 1); - lines.push('[CHAPTER]', 'TIMEBASE=1/1000', `START=${start}`, `END=${end}`, `title=${metadataEscape(chapterTitle(inputs[index]!, root))}`); + lines.push('[CHAPTER]', 'TIMEBASE=1/1000', `START=${start}`, `END=${end}`, `title=${escapeFfmetadata(chapterTitle(inputs[index]!, root))}`); start = end; } return `${lines.join('\n')}\n`; @@ -223,14 +223,14 @@ const chapterMetadataDocument = ( chapters: readonly ChapterRow[], ): string => { const lines = [';FFMETADATA1']; - for (const [key, value] of Object.entries(metadata)) if (value !== undefined && value !== '') lines.push(`${key}=${metadataEscape(value)}`); + for (const [key, value] of Object.entries(metadata)) if (value !== undefined && value !== '') lines.push(`${key}=${escapeFfmetadata(value)}`); for (const chapter of chapters) { lines.push( '[CHAPTER]', 'TIMEBASE=1/1000', `START=${Math.max(0, Math.round(chapter.startSeconds * 1000))}`, `END=${Math.max(1, Math.round(chapter.endSeconds * 1000))}`, - `title=${metadataEscape(chapter.title)}`, + `title=${escapeFfmetadata(chapter.title)}`, ); } return `${lines.join('\n')}\n`; @@ -240,24 +240,6 @@ const concatDocument = (paths: readonly string[]): string => paths.map((path) => `file '${resolve(path).replaceAll("'", "'\\''")}'\n` )).join(''); -const hashFile = async (path: string): Promise => { - const handle = await open(path, 'r'); - try { - const hash = createHash('sha256'); - const buffer = Buffer.allocUnsafe(1024 * 1024); - let position = 0; - while (true) { - const result = await handle.read(buffer, 0, buffer.length, position); - if (result.bytesRead === 0) break; - hash.update(buffer.subarray(0, result.bytesRead)); - position += result.bytesRead; - } - return hash.digest('hex'); - } finally { - await handle.close(); - } -}; - const audioHash = async (path: string, process: MediaProcess, ffmpeg: string, signal?: AbortSignal): Promise => { const result = await process(ffmpeg, [ '-v', 'error', '-i', path, '-map', '0:a:0', '-c', 'copy', '-f', 'hash', '-hash', 'sha256', '-', @@ -280,17 +262,6 @@ const findM4b = async (root: string): Promise => { return results.sort(naturalCompare); }; -const parallel = async (tasks: readonly (() => Promise)[], concurrency: number): Promise => { - let cursor = 0; - await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(tasks.length, 1)) }, async () => { - while (cursor < tasks.length) { - const index = cursor; - cursor += 1; - await tasks[index]!(); - } - })); -}; - const assertConversionProperties = (records: readonly MediaRecord[], codec: 'aac' | 'alac'): void => { const uniform = uniformAudioProperties(records); const layout = String(uniform.channelLayout ?? '').toLowerCase(); @@ -429,13 +400,13 @@ export const convertAudiobook = async ( workSources.push(...found.map((path) => ({ owner: index, path }))); } const segments = workSources.map((_, index) => join(segmentRoot, `${String(index + 1).padStart(6, '0')}.m4a`)); - await parallel(segments.map((segment, index) => async () => { - const args = ['-v', 'error', '-xerror', '-i', workSources[index]!.path, '-map', '0:a:0']; + await mapWithConcurrency(workSources.map((source, index) => ({ segment: segments[index]!, source })), jobs, async ({ segment, source }) => { + const args = ['-v', 'error', '-xerror', '-i', source.path, '-map', '0:a:0']; if (codec === 'alac') args.push('-c:a', 'alac'); else args.push('-c:a', 'aac', '-b:a', input.audioBitrate ?? '128k', '-use_editlist', '0'); args.push(segment); await process(ffmpeg, args, { signal: dependencies.signal }); - }), jobs); + }); const segmentRecords = await Promise.all(segments.map((segment) => probeMediaRecord(segment, segmentRoot, dependencies))); uniformAudioProperties(segmentRecords, ['codec', 'sampleRate', 'channels', 'channelLayout', 'bitDepth', 'sampleFormat'], 'conversion segments'); const concat = join(work, 'concat.txt'); @@ -468,27 +439,15 @@ export const convertAudiobook = async ( throw new CuratorError('Single-M4B stream copy changed audio; destination left untouched.'); } await rename(temporary, output); - const outputHandle = await open(output, 'r'); - try { - await outputHandle.sync(); - } finally { - await outputHandle.close(); - } - const outputDirectory = await open(dirname(output), 'r'); - try { - await outputDirectory.sync(); - } catch (error) { - if (!['EACCES', 'EINVAL'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error; - } finally { - await outputDirectory.close(); - } + await syncFile(output); + await syncDirectory(dirname(output)); const outputMetadata = await lstat(output); const receipt = Object.freeze({ ...base, audioSha256: stagedAudioHash, durationDeltaSeconds: durationDelta, outputBytes: outputMetadata.size, - outputSha256: await hashFile(output), + outputSha256: await sha256File(output), probe: staged, status: 'converted-verified', }); diff --git a/examples/audiobook-curator/src/curator-core.ts b/examples/audiobook-curator/src/curator-core.ts index 4a3409c08..e37621273 100644 --- a/examples/audiobook-curator/src/curator-core.ts +++ b/examples/audiobook-curator/src/curator-core.ts @@ -1,24 +1,20 @@ -import { createHash } from 'node:crypto'; import { - constants, link, lstat, mkdir, mkdtemp, - open, opendir, rm, } from 'node:fs/promises'; import { basename, dirname, extname, join, resolve } from 'node:path'; +import { syncFile } from './foundation.ts'; import { runMediaProcess, type MediaProcess } from './media-process.ts'; const supportedExtensions = new Set(['.aac', '.flac', '.m4a', '.m4b', '.mp3', '.ogg', '.opus', '.wav']); const safeOutputName = /^[a-z0-9][a-z0-9._ -]{0,199}\.m4b$/iu; const maximumTraversalEntries = 4096; const maximumInventoryFiles = 256; -const hashChunkBytes = 1024 * 1024; -const readOnlyFlags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0); export interface CuratorDependencies { readonly ffmpeg?: string; @@ -63,20 +59,6 @@ export interface PrepareReceipt { readonly source: string; } -export interface AuditInput { - readonly fullDecode?: boolean; - readonly source: string; -} - -export interface AuditReceipt { - readonly bytes: number; - readonly fullDecode: boolean; - readonly operation: 'audit'; - readonly probe: AudioProbe; - readonly sha256: string; - readonly source: string; -} - const dependencies = (options: CuratorDependencies) => ({ ffmpeg: options.ffmpeg ?? 'ffmpeg', ffprobe: options.ffprobe ?? 'ffprobe', @@ -242,55 +224,9 @@ export const prepareAudiobook = async ( } throw error; } - const outputHandle = await open(output, 'r'); - try { - await outputHandle.sync(); - } finally { - await outputHandle.close(); - } + await syncFile(output); return Object.freeze({ applied: true, operation: 'prepare', output, probe: outputProbe, source }); } finally { await rm(temporaryRoot, { force: true, recursive: true }); } }; - -const hashFile = async (path: string): Promise<{ bytes: number; sha256: string }> => { - const handle = await open(path, readOnlyFlags); - try { - const before = await handle.stat(); - if (!before.isFile() || before.nlink !== 1) throw new Error('Audit source must be one regular file.'); - const hash = createHash('sha256'); - const buffer = Buffer.allocUnsafe(hashChunkBytes); - let bytes = 0; - while (true) { - const result = await handle.read(buffer, 0, buffer.byteLength, bytes); - if (result.bytesRead === 0) break; - hash.update(buffer.subarray(0, result.bytesRead)); - bytes += result.bytesRead; - } - const after = await handle.stat(); - if (after.dev !== before.dev || after.ino !== before.ino || after.size !== bytes) { - throw new Error('Audit source changed while it was being hashed.'); - } - return { bytes, sha256: hash.digest('hex') }; - } finally { - await handle.close(); - } -}; - -export const auditAudiobook = async ( - input: AuditInput, - options: CuratorDependencies = {}, -): Promise => { - const source = resolve(input.source); - await regularFileSize(source); - const probe = await probeAudio(source, options); - const hashed = await hashFile(source); - if (input.fullDecode === true) { - const selected = dependencies(options); - await selected.process(selected.ffmpeg, [ - '-nostdin', '-v', 'error', '-i', source, '-map', '0:a:0', '-f', 'null', '-', - ], { signal: selected.signal }); - } - return Object.freeze({ ...hashed, fullDecode: input.fullDecode === true, operation: 'audit', probe, source }); -}; diff --git a/examples/audiobook-curator/src/evidence.ts b/examples/audiobook-curator/src/evidence.ts index f6b935d33..11cad1d99 100644 --- a/examples/audiobook-curator/src/evidence.ts +++ b/examples/audiobook-curator/src/evidence.ts @@ -4,10 +4,11 @@ import { basename, dirname, join, resolve } from 'node:path'; import { defaultCuratorHttpClient, + requestWithAttempts, type AudibleRegion, type CuratorHttpClient, } from './audible.ts'; -import { CuratorError, audibleHosts, readJson, utcNow, writeReceipt } from './foundation.ts'; +import { CuratorError, asRecord, audibleHosts, contributorNames, readJson, utcNow, writeReceipt } from './foundation.ts'; import { probeMediaRecord, type LibraryDependencies } from './library.ts'; import { runMediaProcess, type MediaProcess } from './media-process.ts'; @@ -122,30 +123,6 @@ export interface EvidenceDependencies extends LibraryDependencies { readonly process?: MediaProcess; } -const object = (value: unknown): Record => value !== null && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : {}; - -const names = (value: unknown): string[] => Array.isArray(value) ? value.flatMap((row) => { - const name = object(row).name; - return typeof name === 'string' ? [name] : []; -}) : []; - -const boundedRequest = async ( - http: CuratorHttpClient, - url: string, - attempts: number, - binary: boolean, - signal?: AbortSignal, -): Promise => { - let failure: unknown; - for (let attempt = 0; attempt < attempts; attempt += 1) { - signal?.throwIfAborted(); - try { return await http(url, { binary, signal }); } catch (error) { failure = error; } - } - throw new CuratorError(failure instanceof Error ? failure.message : `Request failed: ${url}`); -}; - const pythonMatcher = (python: string, process: MediaProcess): AcousticMatcher => async (source, sample, options) => { const resultMarker = '__AGENT_BUNDLE_AUDIOLOCATE_RESULT__'; const script = [ @@ -158,7 +135,7 @@ 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(object(JSON.parse(line.slice(resultMarker.length)))); + return Object.freeze(asRecord(JSON.parse(line.slice(resultMarker.length)))); } catch (error) { throw new CuratorError(`Audiolocate is optional; install it for ${python}, or inject an acoustic matcher. ${error instanceof Error ? error.message : ''}`.trim()); } @@ -179,18 +156,18 @@ const sampleMatch = async ( let sampleUrl = input.sampleUrl; let audible: Record = { sampleUrl }; if (sampleUrl === undefined) { - const payload = object(await boundedRequest(http, productUrl(region, input.asin), attempts, false, dependencies.signal)); - const product = object(payload.product); + const payload = asRecord(await requestWithAttempts(http, productUrl(region, input.asin), attempts, { signal: dependencies.signal })); + const product = asRecord(payload.product); sampleUrl = typeof product.sample_url === 'string' ? product.sample_url : undefined; audible = { - authors: names(product.authors), - narrators: names(product.narrators), + authors: contributorNames(product.authors), + narrators: contributorNames(product.narrators), sampleUrl, title: product.title, }; } if (sampleUrl === undefined || sampleUrl === '') throw new CuratorError('Audible candidate has no sample URL'); - const bytes = await boundedRequest(http, sampleUrl, attempts, true, dependencies.signal); + const bytes = await requestWithAttempts(http, sampleUrl, attempts, { binary: true, signal: dependencies.signal }); if (!Buffer.isBuffer(bytes)) throw new CuratorError('Audible sample response is not binary.'); const work = await mkdtemp(join(tmpdir(), 'audiobook-curator-acoustic-')); const sample = join(work, 'sample.mp3'); @@ -240,7 +217,7 @@ export const identifyAudibleSample = async ( dependencies: EvidenceDependencies = {}, ): Promise => { const ranked = [...input.candidates].filter((candidate) => candidate !== null && typeof candidate === 'object') - .sort((left, right) => Number(object(right.evidence).score ?? 0) - Number(object(left.evidence).score ?? 0)); + .sort((left, right) => Number(asRecord(right.evidence).score ?? 0) - Number(asRecord(left.evidence).score ?? 0)); const seen = new Set(); const unique = ranked.filter((candidate) => { const asin = String(candidate.asin ?? ''); @@ -257,7 +234,7 @@ export const identifyAudibleSample = async ( 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: object(candidate.evidence).score, title: candidate.title }; + const base = { asin: asin || undefined, region, score: asRecord(candidate.evidence).score, title: candidate.title }; if (asin === '') { attempts.push({ ...base, reason: 'candidate has no ASIN', status: 'skipped' }); continue; @@ -308,10 +285,10 @@ export const identifyAudibleSample = async ( }; export const whisperText = (payload: unknown): string => { - const row = object(payload); + const row = asRecord(payload); if (typeof row.transcription === 'string') return row.transcription.trim(); - if (Array.isArray(row.transcription)) return row.transcription.map((item) => String(object(item).text ?? '')).join(' ').trim(); - return String(object(row.result).transcription ?? '').trim(); + if (Array.isArray(row.transcription)) return row.transcription.map((item) => String(asRecord(item).text ?? '')).join(' ').trim(); + return String(asRecord(row.result).transcription ?? '').trim(); }; export const whisperSamplingFractions = (maximumWindows: number): readonly number[] => Object.freeze( diff --git a/examples/audiobook-curator/src/foundation.ts b/examples/audiobook-curator/src/foundation.ts index 32795b830..3c2e7f491 100644 --- a/examples/audiobook-curator/src/foundation.ts +++ b/examples/audiobook-curator/src/foundation.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { open, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, extname, join, resolve } from 'node:path'; @@ -23,6 +24,84 @@ export class CuratorError extends Error {} export const utcNow = (): string => new Date().toISOString(); +/** Narrows an unknown value to a plain record, or returns an empty one. */ +export const asRecord = (value: unknown): Record => value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; + +/** Extracts `name` strings from catalog contributor rows such as `product.authors`. */ +export const contributorNames = (value: unknown): string[] => Array.isArray(value) + ? value.flatMap((row) => { + const name = asRecord(row).name; + return typeof name === 'string' ? [name] : []; + }) + : []; + +/** Escapes one value for an `;FFMETADATA1` document. */ +export const escapeFfmetadata = (value: string): string => value + .replaceAll('\\', '\\\\') + .replaceAll('=', '\\=') + .replaceAll(';', '\\;') + .replaceAll('#', '\\#') + .replaceAll('\n', '\\n'); + +export const sha256File = async (path: string): Promise => { + const handle = await open(path, 'r'); + try { + const hash = createHash('sha256'); + const buffer = Buffer.allocUnsafe(1024 * 1024); + let position = 0; + while (true) { + const result = await handle.read(buffer, 0, buffer.length, position); + if (result.bytesRead === 0) break; + hash.update(buffer.subarray(0, result.bytesRead)); + position += result.bytesRead; + } + return hash.digest('hex'); + } finally { + await handle.close(); + } +}; + +export const mapWithConcurrency = async ( + values: readonly T[], + concurrency: number, + operation: (value: T) => Promise, +): Promise => { + const results = new Array(values.length); + let cursor = 0; + const workers = Array.from({ length: Math.min(concurrency, Math.max(values.length, 1)) }, async () => { + while (cursor < values.length) { + const index = cursor; + cursor += 1; + results[index] = await operation(values[index]!); + } + }); + await Promise.all(workers); + return results; +}; + +export const syncFile = async (path: string): Promise => { + const file = await open(path, 'r'); + try { + await file.sync(); + } finally { + await file.close(); + } +}; + +/** Directory fsync is best-effort: some filesystems refuse it with EACCES or EINVAL. */ +export const syncDirectory = async (path: string): Promise => { + const directory = await open(path, 'r'); + try { + await directory.sync(); + } catch (error) { + if (!['EACCES', 'EINVAL'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error; + } finally { + await directory.close(); + } +}; + const naturalCollator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }); export const naturalCompare = (left: string, right: string): number => naturalCollator.compare(left, right); @@ -103,21 +182,9 @@ export const writeReceipt = async ( const staged = join(staging, 'receipt.json'); try { await writeFile(staged, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); - const file = await open(staged, 'r'); - try { - await file.sync(); - } finally { - await file.close(); - } + await syncFile(staged); await rename(staged, target); - const directory = await open(parent, 'r'); - try { - await directory.sync(); - } catch (error) { - if (!['EACCES', 'EINVAL'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error; - } finally { - await directory.close(); - } + await syncDirectory(parent); return target; } finally { await rm(staging, { force: true, recursive: true }); diff --git a/examples/audiobook-curator/src/index.ts b/examples/audiobook-curator/src/index.ts index c96635c17..68df09572 100644 --- a/examples/audiobook-curator/src/index.ts +++ b/examples/audiobook-curator/src/index.ts @@ -1,4 +1,4 @@ -export { auditAudiobook, inspectSources, prepareAudiobook } from './curator-core.js'; +export { inspectSources, prepareAudiobook } from './curator-core.js'; export { audibleCandidateEvidence, cacheAudibleEdition, @@ -22,8 +22,6 @@ export type { } from './audible.js'; export type { AudioProbe, - AuditInput, - AuditReceipt, CuratorDependencies, InspectedAudioFile, InspectionReceipt, diff --git a/examples/audiobook-curator/src/integrity-audit.ts b/examples/audiobook-curator/src/integrity-audit.ts index 7c2f89302..202ddb73a 100644 --- a/examples/audiobook-curator/src/integrity-audit.ts +++ b/examples/audiobook-curator/src/integrity-audit.ts @@ -1,9 +1,8 @@ -import { createHash } from 'node:crypto'; -import { lstat, open } from 'node:fs/promises'; +import { lstat } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { chapterMappingIssues, type ChapterRow } from './conversion.ts'; -import { CuratorError, readJson, utcNow, writeReceipt } from './foundation.ts'; +import { CuratorError, asRecord, readJson, sha256File, utcNow, writeReceipt } from './foundation.ts'; import { probeMediaDetails, probeMediaRecord, type LibraryDependencies, type MediaDetails, type MediaRecord } from './library.ts'; import { runMediaProcess, type MediaProcess } from './media-process.ts'; @@ -40,37 +39,17 @@ export interface IntegrityAuditDependencies extends LibraryDependencies { readonly process?: MediaProcess; } -const object = (value: unknown): Record => value !== null && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : {}; - -const fileHash = async (path: string): Promise => { - const handle = await open(path, 'r'); - try { - const hash = createHash('sha256'); - const bytes = Buffer.allocUnsafe(1024 * 1024); - let position = 0; - while (true) { - const result = await handle.read(bytes, 0, bytes.length, position); - if (result.bytesRead === 0) break; - hash.update(bytes.subarray(0, result.bytesRead)); - position += result.bytesRead; - } - return hash.digest('hex'); - } finally { await handle.close(); } -}; - const chapterEvidence = (details: MediaDetails): { readonly issues: string[]; readonly rows: ChapterRow[] } => { const source = details.chapters; if (!Array.isArray(source) || source.length > 16_384) throw new CuratorError('ffprobe returned invalid chapters.'); - const mediaDuration = Number(object(details.format).duration ?? 0); + const mediaDuration = Number(asRecord(details.format).duration ?? 0); const rows = source.map((chapter, index) => { - const row = object(chapter); + const row = asRecord(chapter); return { endSeconds: Number(row.end_time ?? 0), number: index + 1, startSeconds: Number(row.start_time ?? 0), - title: String(object(row.tags).title ?? '').trim(), + title: String(asRecord(row.tags).title ?? '').trim(), }; }); const issues: string[] = []; @@ -90,10 +69,10 @@ const chapterEvidence = (details: MediaDetails): { readonly issues: string[]; re }; const expectedChapters = (value: unknown): ChapterRow[] => { - const rows = object(value).expectedChapters; + const rows = asRecord(value).expectedChapters; if (!Array.isArray(rows) || rows.length > 16_384) throw new CuratorError('Conversion receipt has invalid expected chapters.'); return rows.map((entry, index) => { - const row = object(entry); + const row = asRecord(entry); const result = { endSeconds: Number(row.endSeconds), number: Number(row.number ?? index + 1), @@ -158,7 +137,7 @@ export const auditAudiobookIntegrity = async ( mutation: false, operation: 'audit', probe, - sha256: await fileHash(file), + sha256: await sha256File(file), sourceChapterMapping: mapping, status, }); diff --git a/examples/audiobook-curator/src/library.ts b/examples/audiobook-curator/src/library.ts index c4daac4d0..7fbd60249 100644 --- a/examples/audiobook-curator/src/library.ts +++ b/examples/audiobook-curator/src/library.ts @@ -1,7 +1,7 @@ import { lstat, opendir } from 'node:fs/promises'; import { basename, dirname, extname, join, relative, resolve } from 'node:path'; -import { audioExtensions, naturalCompare, normalizedIdentity, utcNow } from './foundation.ts'; +import { audioExtensions, mapWithConcurrency, naturalCompare, normalizedIdentity, utcNow } from './foundation.ts'; import { runMediaProcess, type MediaProcess } from './media-process.ts'; const maximumEntries = 65_536; @@ -284,24 +284,6 @@ const auditFile = async (path: string, root: string, dependencies: LibraryDepend } }; -const parallelMap = async ( - values: readonly T[], - concurrency: number, - operation: (value: T) => Promise, -): Promise => { - const results = new Array(values.length); - let cursor = 0; - const workers = Array.from({ length: Math.min(concurrency, Math.max(values.length, 1)) }, async () => { - while (cursor < values.length) { - const index = cursor; - cursor += 1; - results[index] = await operation(values[index]!); - } - }); - await Promise.all(workers); - return results; -}; - export const auditLibrary = async ( input: { readonly concurrency?: number; readonly sources: readonly string[]; readonly strict?: boolean }, dependencies: LibraryDependencies = {}, @@ -315,7 +297,7 @@ export const auditLibrary = async ( candidates.push(...discovered.files.map((path) => ({ path, root: discovered.root }))); } candidates.sort((left, right) => naturalCompare(left.path, right.path)); - const files = await parallelMap(candidates, concurrency, ({ path, root }) => auditFile(path, root, dependencies)); + const files = await mapWithConcurrency(candidates, concurrency, ({ path, root }) => auditFile(path, root, dependencies)); const duplicates = new Map(); const multipart = new Map>(); for (const file of files) { diff --git a/examples/audiobook-curator/src/mcp-tools.tsx b/examples/audiobook-curator/src/mcp-tools.tsx deleted file mode 100644 index 3dd9f9600..000000000 --- a/examples/audiobook-curator/src/mcp-tools.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { lowerMcpResult } from '@agent-bundle/rsc-runtime'; -import type { CallToolResult } from '@modelcontextprotocol/server'; -import type { ZodType } from 'zod'; - -import { - createAudiobookCuratorApplication, - type AudiobookCuratorOperations, -} from './application.js'; - -export type CuratorToolOperations = AudiobookCuratorOperations; - -export const curatorToolNames = Object.freeze([ - 'verify_audible_sample', - 'identify_audible_sample', - 'verify_with_whisper', - 'apply_audiobook_metadata', - 'apply_audiobook_chapters', - 'search_audible', - 'select_audible_edition', - 'cache_audible_edition', - 'inspect_sources', - 'inventory_sources', - 'audit_library', - 'select_sources', - 'convert_audiobook', - 'prepare_audiobook', - 'audit_audiobook', -] as const); - -export interface CuratorTool { - readonly description: string; - readonly execute: (input: unknown, signal: AbortSignal) => Promise; - readonly inputSchema: ZodType; - readonly name: (typeof curatorToolNames)[number]; - readonly readOnly: boolean; -} - -export const createCuratorTools = ( - options: { readonly operations?: CuratorToolOperations } = {}, -): readonly CuratorTool[] => { - const application = createAudiobookCuratorApplication(options); - return Object.freeze(application.operations.flatMap((operation) => operation.mcp === undefined ? [] : [Object.freeze({ - description: operation.mcp.description, - execute: async (input: unknown, signal: AbortSignal) => { - const result = await operation.execute(input, { signal }); - return lowerMcpResult(operation.render(result)); - }, - inputSchema: operation.inputSchema, - name: operation.mcp.name as CuratorTool['name'], - readOnly: operation.mcp.readOnly, - })])); -}; diff --git a/examples/audiobook-curator/src/media-mutation.ts b/examples/audiobook-curator/src/media-mutation.ts index 55601fee4..4fbd69bdf 100644 --- a/examples/audiobook-curator/src/media-mutation.ts +++ b/examples/audiobook-curator/src/media-mutation.ts @@ -1,10 +1,20 @@ -import { createHash } from 'node:crypto'; -import { chmod, lstat, mkdtemp, open, rename, rm, utimes, writeFile } from 'node:fs/promises'; +import { chmod, lstat, mkdtemp, rename, rm, utimes, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { chapterMappingIssues, type ChapterRow } from './conversion.ts'; -import { CuratorError, readJson, utcNow, writeReceipt } from './foundation.ts'; -import { probeMediaDetails, probeMediaRecord, type LibraryDependencies, type MediaDetails } from './library.ts'; +import { + CuratorError, + asRecord, + contributorNames, + escapeFfmetadata, + readJson, + sha256File, + syncDirectory, + syncFile, + utcNow, + writeReceipt, +} from './foundation.ts'; +import { probeMediaDetails, type LibraryDependencies, type MediaDetails } from './library.ts'; import { runMediaProcess, type MediaProcess } from './media-process.ts'; export interface MetadataInput { @@ -77,28 +87,24 @@ export interface MediaMutationDependencies extends LibraryDependencies { readonly process?: MediaProcess; } -const object = (value: unknown): Record => value !== null && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : {}; - const streams = (details: MediaDetails): Record[] => { if (!Array.isArray(details.streams) || details.streams.length > 256) throw new CuratorError('ffprobe returned invalid streams.'); - return details.streams.map(object); + return details.streams.map(asRecord); }; const chaptersFromDetails = (details: MediaDetails): Omit[] => { if (!Array.isArray(details.chapters) || details.chapters.length > 16_384) throw new CuratorError('ffprobe returned invalid chapters.'); return details.chapters.map((chapter) => { - const row = object(chapter); + const row = asRecord(chapter); return Object.freeze({ endSeconds: Number(row.end_time ?? 0), startSeconds: Number(row.start_time ?? 0), - title: String(object(row.tags).title ?? '').trim(), + title: String(asRecord(row.tags).title ?? '').trim(), }); }); }; -const duration = (details: MediaDetails): number => Number(object(details.format).duration ?? 0); +const duration = (details: MediaDetails): number => Number(asRecord(details.format).duration ?? 0); const errorText = (error: unknown): string => error instanceof Error ? error.message : 'Media mutation failed.'; @@ -117,8 +123,8 @@ export const chapterRowsFromPayload = ( payload: unknown, durationSeconds: number, ): readonly Omit[] => { - const payloadObject = object(payload); - const nested = object(object(payloadObject.content_metadata).chapter_info).chapters; + const payloadObject = asRecord(payload); + const nested = asRecord(asRecord(payloadObject.content_metadata).chapter_info).chapters; const chapterData = Array.isArray(payload) ? payload : Array.isArray(payloadObject.chapters) ? payloadObject.chapters : nested; if (!Array.isArray(chapterData) || chapterData.length === 0 || chapterData.length > 16_384) { throw new CuratorError('chapter document contains no chapters'); @@ -186,7 +192,7 @@ interface StreamSignature { } const streamSignature = (details: MediaDetails, includeArtwork = true): readonly StreamSignature[] => Object.freeze(streams(details).flatMap((stream) => { - const artwork = Boolean(object(stream.disposition).attached_pic); + const artwork = Boolean(asRecord(stream.disposition).attached_pic); if (stream.codec_type === 'data' && stream.codec_tag_string === 'text') return []; if (artwork && !includeArtwork) return []; return [Object.freeze({ @@ -201,32 +207,14 @@ const streamSignature = (details: MediaDetails, includeArtwork = true): readonly })); const stableFormatTags = (details: MediaDetails): Readonly> => Object.freeze(Object.fromEntries( - Object.entries(object(object(details.format).tags)) + Object.entries(asRecord(asRecord(details.format).tags)) .filter(([key]) => key.toLowerCase() !== 'encoder') .map(([key, value]) => [key.toLowerCase(), String(value)]), )); -const fileHash = async (path: string): Promise => { - const handle = await open(path, 'r'); - try { - const hash = createHash('sha256'); - const buffer = Buffer.allocUnsafe(1024 * 1024); - let position = 0; - while (true) { - const result = await handle.read(buffer, 0, buffer.length, position); - if (result.bytesRead === 0) break; - hash.update(buffer.subarray(0, result.bytesRead)); - position += result.bytesRead; - } - return hash.digest('hex'); - } finally { await handle.close(); } -}; - -const escapeMetadata = (value: string): string => value.replaceAll('\\', '\\\\').replaceAll('=', '\\=').replaceAll(';', '\\;').replaceAll('#', '\\#').replaceAll('\n', '\\n'); - const chapterMetadata = (rows: readonly Omit[]): string => `${[ ';FFMETADATA1', - ...rows.flatMap((row) => ['[CHAPTER]', 'TIMEBASE=1/1000', `START=${Math.round(row.startSeconds * 1000)}`, `END=${Math.round(row.endSeconds * 1000)}`, `title=${escapeMetadata(row.title)}`]), + ...rows.flatMap((row) => ['[CHAPTER]', 'TIMEBASE=1/1000', `START=${Math.round(row.startSeconds * 1000)}`, `END=${Math.round(row.endSeconds * 1000)}`, `title=${escapeFfmetadata(row.title)}`]), ].join('\n')}\n`; const publishReplacement = async (source: string, temporary: string, before: Awaited>): Promise => { @@ -236,19 +224,12 @@ const publishReplacement = async (source: string, temporary: string, before: Awa } await chmod(temporary, Number(before.mode)); await utimes(temporary, before.atime, before.mtime); - const file = await open(temporary, 'r'); - try { await file.sync(); } finally { await file.close(); } + await syncFile(temporary); await rename(temporary, source); - const directory = await open(dirname(source), 'r'); - try { await directory.sync(); } catch (error) { - if (!['EACCES', 'EINVAL'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error; - } finally { await directory.close(); } + await syncDirectory(dirname(source)); }; -const names = (value: unknown): string => Array.isArray(value) ? value.flatMap((row) => { - const name = object(row).name; - return typeof name === 'string' && name !== '' ? [name] : []; -}).join(' & ') : ''; +const names = (value: unknown): string => contributorNames(value).filter((name) => name !== '').join(' & '); export const applyAudiobookMetadata = async ( input: MetadataInput, @@ -256,7 +237,7 @@ export const applyAudiobookMetadata = async ( ): Promise => { const path = resolve(input.file); const productPath = resolve(input.product); - const product = object(await readJson(productPath)); + const product = asRecord(await readJson(productPath)); const authors = names(product.authors); const narrators = names(product.narrators); const title = input.title ?? String(product.title ?? ''); @@ -317,16 +298,16 @@ export const applyAudiobookMetadata = async ( || Math.abs(duration(after) - beforeDuration) > 0.01) { throw new CuratorError('chapter structure or duration changed during metadata update; original left untouched'); } - const afterTags = Object.fromEntries(Object.entries(object(object(after.format).tags)).map(([key, value]) => [key.toLowerCase(), String(value)])); + const afterTags = Object.fromEntries(Object.entries(asRecord(asRecord(after.format).tags)).map(([key, value]) => [key.toLowerCase(), String(value)])); const missingKeys = Object.entries(metadata).filter(([key, value]) => value !== '' && afterTags[key.toLowerCase()] !== value).map(([key]) => key); if (missingKeys.length > 0) throw new CuratorError(`metadata verification failed for: ${missingKeys.join(', ')}; original left untouched`); const afterStreams = streams(after); const firstAudio = afterStreams.find((stream) => stream.codec_type === 'audio'); - if (input.language !== undefined && String(object(firstAudio?.tags).language ?? '').toLowerCase() !== input.language.toLowerCase()) { + if (input.language !== undefined && String(asRecord(firstAudio?.tags).language ?? '').toLowerCase() !== input.language.toLowerCase()) { throw new CuratorError('audio language metadata verification failed; original left untouched'); } - const beforeArtwork = streams(before).filter((stream) => Boolean(object(stream.disposition).attached_pic)).length; - const afterArtwork = afterStreams.filter((stream) => Boolean(object(stream.disposition).attached_pic)).length; + const beforeArtwork = streams(before).filter((stream) => Boolean(asRecord(stream.disposition).attached_pic)).length; + const afterArtwork = afterStreams.filter((stream) => Boolean(asRecord(stream.disposition).attached_pic)).length; if (afterArtwork < (input.artwork === undefined ? beforeArtwork : 1)) throw new CuratorError('artwork verification failed; original left untouched'); if (JSON.stringify(streamSignature(before, input.artwork === undefined)) !== JSON.stringify(streamSignature(after, input.artwork === undefined))) { throw new CuratorError('non-artwork stream inventory changed; original left untouched'); @@ -340,7 +321,7 @@ export const applyAudiobookMetadata = async ( audioStreamHashesAfter: afterHashes, bytesAfter: final.size, chapterCountAfter: afterRows.length, - sha256After: await fileHash(path), + sha256After: await sha256File(path), status: 'applied-verified', streamCountAfter: afterStreams.length, verifiedMetadataKeys: Object.freeze([...Object.entries(metadata).filter(([, value]) => value !== '').map(([key]) => key), ...(input.language === undefined ? [] : ['audio.language'])]), @@ -411,7 +392,7 @@ export const applyAudiobookChapters = async ( bytesAfter: final.size, chapterCountAfter: afterRows.length, durationSeconds: duration(after), - sha256After: await fileHash(path), + sha256After: await sha256File(path), status: 'applied-verified', verifiedBoundaries: true, }); diff --git a/examples/audiobook-curator/src/operations/audible.tsx b/examples/audiobook-curator/src/operations/audible.tsx index 782434d70..70cbefbe2 100644 --- a/examples/audiobook-curator/src/operations/audible.tsx +++ b/examples/audiobook-curator/src/operations/audible.tsx @@ -22,8 +22,10 @@ import { readJson, writeReceipt } from '../foundation.ts'; import { CuratorResult } from '../result.tsx'; import { assertOptions, + numberOption, optionChoice, optionValue, + optionalField, positionalArguments, requiredOption, } from './cli-arguments.ts'; @@ -85,16 +87,13 @@ export const audibleOperations = (operations: Required) => [ const valued = new Set(['--attempts', '--author', '--duration', '--limit', '--narrator', '--regions', '--report', '--title']); assertOptions(args, new Set(), valued); if (positionalArguments(args, valued).length > 0) throw new Error('audible-search accepts only named options.'); - const attempts = optionValue(args, '--attempts'); - const duration = optionValue(args, '--duration'); - const limit = optionValue(args, '--limit'); const regions = optionValue(args, '--regions'); return { - ...(attempts === undefined ? {} : { attempts: Number(attempts) }), - ...(optionValue(args, '--author') === undefined ? {} : { author: optionValue(args, '--author') }), - ...(duration === undefined ? {} : { durationSeconds: Number(duration) }), - ...(limit === undefined ? {} : { limit: Number(limit) }), - ...(optionValue(args, '--narrator') === undefined ? {} : { narrator: optionValue(args, '--narrator') }), + ...optionalField('attempts', numberOption(args, '--attempts')), + ...optionalField('author', optionValue(args, '--author')), + ...optionalField('durationSeconds', numberOption(args, '--duration')), + ...optionalField('limit', numberOption(args, '--limit')), + ...optionalField('narrator', optionValue(args, '--narrator')), ...(regions === undefined ? {} : { regions: audibleRegionList(regions) }), report: requiredOption(args, '--report', 'audible-search'), title: requiredOption(args, '--title', 'audible-search'), @@ -125,7 +124,7 @@ export const audibleOperations = (operations: Required) => [ return { candidate: Number(requiredOption(args, '--candidate', 'audible-select')), candidates: requiredOption(args, '--candidates', 'audible-select'), - ...(optionValue(args, '--note') === undefined ? {} : { note: optionValue(args, '--note') }), + ...optionalField('note', optionValue(args, '--note')), receipt: requiredOption(args, '--receipt', 'audible-select'), }; }, @@ -146,13 +145,12 @@ export const audibleOperations = (operations: Required) => [ const valued = new Set(['--asin', '--attempts', '--cache-dir', '--receipt', '--region']); assertOptions(args, new Set(), valued); if (positionalArguments(args, valued).length > 0) throw new Error('audible-cache accepts only named options.'); - const attempts = optionValue(args, '--attempts'); return { asin: requiredOption(args, '--asin', 'audible-cache'), - ...(attempts === undefined ? {} : { attempts: Number(attempts) }), + ...optionalField('attempts', numberOption(args, '--attempts')), cacheDirectory: requiredOption(args, '--cache-dir', 'audible-cache'), receipt: requiredOption(args, '--receipt', 'audible-cache'), - ...(optionChoice(args, '--region', audibleRegions) === undefined ? {} : { region: optionChoice(args, '--region', audibleRegions) }), + ...optionalField('region', optionChoice(args, '--region', audibleRegions)), }; }, summary: 'Cache one reviewed Audible product, chapters, artwork, and source URLs.', diff --git a/examples/audiobook-curator/src/operations/cli-arguments.ts b/examples/audiobook-curator/src/operations/cli-arguments.ts index 5916f5782..bdfa3fac5 100644 --- a/examples/audiobook-curator/src/operations/cli-arguments.ts +++ b/examples/audiobook-curator/src/operations/cli-arguments.ts @@ -61,3 +61,12 @@ export const optionChoice = ( if (!choices.includes(value as T)) throw new Error(`${option} must be one of: ${choices.join(', ')}.`); return value as T; }; + +export const numberOption = (args: readonly string[], option: string): number | undefined => { + const value = optionValue(args, option); + return value === undefined ? undefined : Number(value); +}; + +/** Spread helper that omits the key entirely when the option is absent, so parsed inputs never carry explicit `undefined` entries. */ +export const optionalField = (key: K, value: V | undefined): Readonly>> => + (value === undefined ? {} : { [key]: value }) as Partial>; diff --git a/examples/audiobook-curator/src/operations/discovery.tsx b/examples/audiobook-curator/src/operations/discovery.tsx index 6e573a340..4aa63d0f8 100644 --- a/examples/audiobook-curator/src/operations/discovery.tsx +++ b/examples/audiobook-curator/src/operations/discovery.tsx @@ -20,8 +20,9 @@ import { import { CuratorResult } from '../result.tsx'; import { assertOptions, + numberOption, onePath, - optionValue, + optionalField, positionalArguments, requiredOption, } from './cli-arguments.ts'; @@ -93,9 +94,8 @@ export const discoveryOperations = (operations: Required) = parse: (args) => { const valued = new Set(['--max-files']); assertOptions(args, new Set(), valued); - const maximum = optionValue(args, '--max-files'); return { - ...(maximum === undefined ? {} : { maxFiles: Number(maximum) }), + ...optionalField('maxFiles', numberOption(args, '--max-files')), root: onePath(args, valued, 'inspect'), }; }, @@ -149,11 +149,10 @@ export const discoveryOperations = (operations: Required) = parse: (args) => { const valued = new Set(['--concurrency', '--report']); assertOptions(args, new Set(['--strict']), valued); - const concurrency = optionValue(args, '--concurrency'); const sources = positionalArguments(args, valued); if (sources.length === 0) throw new Error('library-audit requires at least one source path.'); return { - ...(concurrency === undefined ? {} : { concurrency: Number(concurrency) }), + ...optionalField('concurrency', numberOption(args, '--concurrency')), report: requiredOption(args, '--report', 'library-audit'), sources, ...(args.includes('--strict') ? { strict: true } : {}), diff --git a/examples/audiobook-curator/src/operations/evidence.tsx b/examples/audiobook-curator/src/operations/evidence.tsx index a48865101..03cfb9de1 100644 --- a/examples/audiobook-curator/src/operations/evidence.tsx +++ b/examples/audiobook-curator/src/operations/evidence.tsx @@ -20,8 +20,10 @@ import { readJson } from '../foundation.ts'; import { CuratorResult } from '../result.tsx'; import { assertOptions, + numberOption, optionChoice, optionValue, + optionalField, positionalArguments, requiredOption, } from './cli-arguments.ts'; @@ -63,17 +65,15 @@ export const evidenceOperations = (operations: Required) => const valued = new Set(['--asin', '--attempts', '--audiolocate-python', '--chunk-seconds', '--file', '--receipt', '--region', '--sample-url']); assertOptions(args, new Set(['--verbose']), valued); if (positionalArguments(args, valued).length > 0) throw new Error('acoustic-verify accepts only named options.'); - const attempts = optionValue(args, '--attempts'); - const chunks = optionValue(args, '--chunk-seconds'); return { asin: requiredOption(args, '--asin', 'acoustic-verify'), - ...(attempts === undefined ? {} : { attempts: Number(attempts) }), - ...(optionValue(args, '--audiolocate-python') === undefined ? {} : { audiolocatePython: optionValue(args, '--audiolocate-python') }), - ...(chunks === undefined ? {} : { chunkSeconds: Number(chunks) }), + ...optionalField('attempts', numberOption(args, '--attempts')), + ...optionalField('audiolocatePython', optionValue(args, '--audiolocate-python')), + ...optionalField('chunkSeconds', numberOption(args, '--chunk-seconds')), file: requiredOption(args, '--file', 'acoustic-verify'), receipt: requiredOption(args, '--receipt', 'acoustic-verify'), - ...(optionChoice(args, '--region', audibleRegions) === undefined ? {} : { region: optionChoice(args, '--region', audibleRegions) }), - ...(optionValue(args, '--sample-url') === undefined ? {} : { sampleUrl: optionValue(args, '--sample-url') }), + ...optionalField('region', optionChoice(args, '--region', audibleRegions)), + ...optionalField('sampleUrl', optionValue(args, '--sample-url')), ...(args.includes('--verbose') ? { verbose: true } : {}), }; }, @@ -99,17 +99,14 @@ export const evidenceOperations = (operations: Required) => const valued = new Set(['--attempts', '--candidates', '--chunk-seconds', '--file', '--receipt', '--top']); assertOptions(args, new Set(['--all', '--verbose']), valued); if (positionalArguments(args, valued).length > 0) throw new Error('acoustic-identify accepts only named options.'); - const attempts = optionValue(args, '--attempts'); - const chunks = optionValue(args, '--chunk-seconds'); - const top = optionValue(args, '--top'); return { ...(args.includes('--all') ? { all: true } : {}), - ...(attempts === undefined ? {} : { attempts: Number(attempts) }), + ...optionalField('attempts', numberOption(args, '--attempts')), candidates: requiredOption(args, '--candidates', 'acoustic-identify'), - ...(chunks === undefined ? {} : { chunkSeconds: Number(chunks) }), + ...optionalField('chunkSeconds', numberOption(args, '--chunk-seconds')), file: requiredOption(args, '--file', 'acoustic-identify'), receipt: requiredOption(args, '--receipt', 'acoustic-identify'), - ...(top === undefined ? {} : { top: Number(top) }), + ...optionalField('top', numberOption(args, '--top')), ...(args.includes('--verbose') ? { verbose: true } : {}), }; }, @@ -136,17 +133,17 @@ export const evidenceOperations = (operations: Required) => assertOptions(args, new Set(), valued); if (positionalArguments(args, valued).length > 0) throw new Error('whisper-verify accepts only named options.'); return { - ...(optionValue(args, '--author') === undefined ? {} : { author: optionValue(args, '--author') }), + ...optionalField('author', optionValue(args, '--author')), file: requiredOption(args, '--file', 'whisper-verify'), - ...(optionValue(args, '--language') === undefined ? {} : { language: optionValue(args, '--language') }), - ...(optionValue(args, '--max-windows') === undefined ? {} : { maxWindows: Number(optionValue(args, '--max-windows')) }), - ...(optionValue(args, '--minimum-chars') === undefined ? {} : { minimumChars: Number(optionValue(args, '--minimum-chars')) }), + ...optionalField('language', optionValue(args, '--language')), + ...optionalField('maxWindows', numberOption(args, '--max-windows')), + ...optionalField('minimumChars', numberOption(args, '--minimum-chars')), model: requiredOption(args, '--model', 'whisper-verify'), receipt: requiredOption(args, '--receipt', 'whisper-verify'), - ...(optionValue(args, '--threads') === undefined ? {} : { threads: Number(optionValue(args, '--threads')) }), - ...(optionValue(args, '--title') === undefined ? {} : { title: optionValue(args, '--title') }), - ...(optionValue(args, '--whisper-cli') === undefined ? {} : { whisperCli: optionValue(args, '--whisper-cli') }), - ...(optionValue(args, '--window-seconds') === undefined ? {} : { windowSeconds: Number(optionValue(args, '--window-seconds')) }), + ...optionalField('threads', numberOption(args, '--threads')), + ...optionalField('title', optionValue(args, '--title')), + ...optionalField('whisperCli', optionValue(args, '--whisper-cli')), + ...optionalField('windowSeconds', numberOption(args, '--window-seconds')), }; }, summary: 'Transcribe distributed audiobook windows for human language and identity review.', diff --git a/examples/audiobook-curator/src/operations/media-mutation.tsx b/examples/audiobook-curator/src/operations/media-mutation.tsx index 909914895..2f44c0352 100644 --- a/examples/audiobook-curator/src/operations/media-mutation.tsx +++ b/examples/audiobook-curator/src/operations/media-mutation.tsx @@ -18,6 +18,7 @@ import { CuratorResult } from '../result.tsx'; import { assertOptions, optionValue, + optionalField, positionalArguments, requiredOption, } from './cli-arguments.ts'; @@ -46,15 +47,15 @@ export const mediaMutationOperations = (operations: Required 0) throw new Error('apply-metadata accepts only named options.'); return { ...(args.includes('--apply') ? { apply: true } : {}), - ...(optionValue(args, '--artwork') === undefined ? {} : { artwork: optionValue(args, '--artwork') }), - ...(optionValue(args, '--author') === undefined ? {} : { author: optionValue(args, '--author') }), + ...optionalField('artwork', optionValue(args, '--artwork')), + ...optionalField('author', optionValue(args, '--author')), file: requiredOption(args, '--file', 'apply-metadata'), - ...(optionValue(args, '--language') === undefined ? {} : { language: optionValue(args, '--language') }), - ...(optionValue(args, '--narrator') === undefined ? {} : { narrator: optionValue(args, '--narrator') }), + ...optionalField('language', optionValue(args, '--language')), + ...optionalField('narrator', optionValue(args, '--narrator')), product: requiredOption(args, '--product', 'apply-metadata'), receipt: requiredOption(args, '--receipt', 'apply-metadata'), - ...(optionValue(args, '--title') === undefined ? {} : { title: optionValue(args, '--title') }), - ...(optionValue(args, '--year') === undefined ? {} : { year: optionValue(args, '--year') }), + ...optionalField('title', optionValue(args, '--title')), + ...optionalField('year', optionValue(args, '--year')), }; }, summary: 'Plan or apply verified Audible metadata and artwork without changing encoded audio.', diff --git a/examples/audiobook-curator/src/operations/output.tsx b/examples/audiobook-curator/src/operations/output.tsx index 726edbb0b..a9087549f 100644 --- a/examples/audiobook-curator/src/operations/output.tsx +++ b/examples/audiobook-curator/src/operations/output.tsx @@ -18,9 +18,11 @@ import { import { CuratorResult } from '../result.tsx'; import { assertOptions, + numberOption, onePath, optionChoice, optionValue, + optionalField, positionalArguments, requiredOption, } from './cli-arguments.ts'; @@ -65,27 +67,24 @@ export const outputOperations = (operations: Required) => [ ]); assertOptions(args, new Set(['--apply', '--overwrite']), valued); if (positionalArguments(args, valued).length > 0) throw new Error('convert accepts only named options.'); - const audioCodec = optionChoice(args, '--audio-codec', ['aac', 'alac'] as const); - const engine = optionChoice(args, '--engine', ['audiobook-forge', 'ffmpeg'] as const); - const jobs = optionValue(args, '--jobs'); return { ...(args.includes('--apply') ? { apply: true } : {}), ...(args.includes('--overwrite') ? { overwrite: true } : {}), - ...(optionValue(args, '--artwork') === undefined ? {} : { artwork: optionValue(args, '--artwork') }), - ...(optionValue(args, '--audio-bitrate') === undefined ? {} : { audioBitrate: optionValue(args, '--audio-bitrate') }), - ...(audioCodec === undefined ? {} : { audioCodec }), + ...optionalField('artwork', optionValue(args, '--artwork')), + ...optionalField('audioBitrate', optionValue(args, '--audio-bitrate')), + ...optionalField('audioCodec', optionChoice(args, '--audio-codec', ['aac', 'alac'] as const)), author: requiredOption(args, '--author', 'convert'), - ...(engine === undefined ? {} : { engine }), - ...(optionValue(args, '--forge-aac-encoder') === undefined ? {} : { forgeAacEncoder: optionValue(args, '--forge-aac-encoder') }), - ...(optionValue(args, '--forge-cli') === undefined ? {} : { forgeCli: optionValue(args, '--forge-cli') }), - ...(jobs === undefined ? {} : { jobs: Number(jobs) }), - ...(optionValue(args, '--language') === undefined ? {} : { language: optionValue(args, '--language') }), - ...(optionValue(args, '--narrator') === undefined ? {} : { narrator: optionValue(args, '--narrator') }), + ...optionalField('engine', optionChoice(args, '--engine', ['audiobook-forge', 'ffmpeg'] as const)), + ...optionalField('forgeAacEncoder', optionValue(args, '--forge-aac-encoder')), + ...optionalField('forgeCli', optionValue(args, '--forge-cli')), + ...optionalField('jobs', numberOption(args, '--jobs')), + ...optionalField('language', optionValue(args, '--language')), + ...optionalField('narrator', optionValue(args, '--narrator')), output: requiredOption(args, '--output', 'convert'), receipt: requiredOption(args, '--receipt', 'convert'), selection: requiredOption(args, '--selection', 'convert'), title: requiredOption(args, '--title', 'convert'), - ...(optionValue(args, '--year') === undefined ? {} : { year: optionValue(args, '--year') }), + ...optionalField('year', optionValue(args, '--year')), }; }, summary: 'Plan or apply a verified conversion to one chaptered M4B.', @@ -119,10 +118,9 @@ export const outputOperations = (operations: Required) => [ assertOptions(args, new Set(['--apply']), valued); const outputRoot = optionValue(args, '--output'); if (outputRoot === undefined) throw new Error('prepare requires --output.'); - const outputName = optionValue(args, '--name'); return { ...(args.includes('--apply') ? { apply: true } : {}), - ...(outputName === undefined ? {} : { outputName }), + ...optionalField('outputName', optionValue(args, '--name')), outputRoot, source: onePath(args, valued, 'prepare'), }; @@ -152,7 +150,7 @@ export const outputOperations = (operations: Required) => [ assertOptions(args, new Set(['--full-decode']), valued); if (positionalArguments(args, valued).length > 0) throw new Error('audit accepts only named options.'); return { - ...(optionValue(args, '--conversion-receipt') === undefined ? {} : { conversionReceipt: optionValue(args, '--conversion-receipt') }), + ...optionalField('conversionReceipt', optionValue(args, '--conversion-receipt')), file: requiredOption(args, '--file', 'audit'), ...(args.includes('--full-decode') ? { fullDecode: true } : {}), receipt: requiredOption(args, '--receipt', 'audit'), diff --git a/examples/audiobook-curator/src/result.tsx b/examples/audiobook-curator/src/result.tsx index 801fc1405..42f23ebfa 100644 --- a/examples/audiobook-curator/src/result.tsx +++ b/examples/audiobook-curator/src/result.tsx @@ -1,5 +1,4 @@ -import { Mcp, lowerMcpResult } from '@agent-bundle/rsc-runtime'; -import type { CallToolResult } from '@modelcontextprotocol/server'; +import { Mcp } from '@agent-bundle/rsc-runtime'; import React from 'react'; import type { AudibleCacheReceipt, AudibleSearchReceipt, AudibleSelectionReceipt } from './audible.ts'; @@ -63,6 +62,10 @@ const summary = (receipt: CuratorReceipt): string => { return receipt.status === 'planned' ? `Planned ${receipt.audioMode} output at ${receipt.output}; sources remain unchanged.` : `Converted and verified ${receipt.output}; sources remain unchanged.`; + default: { + const unhandled: never = receipt; + throw new Error(`Unhandled receipt operation: ${JSON.stringify(unhandled)}`); + } } }; @@ -71,6 +74,3 @@ export const CuratorResult = ({ receipt }: { readonly receipt: CuratorReceipt }) {summary(receipt)} ); - -export const renderCuratorResult = (receipt: CuratorReceipt): CallToolResult => - lowerMcpResult(CuratorResult({ receipt })); diff --git a/examples/audiobook-curator/tests/application.test.tsx b/examples/audiobook-curator/tests/application.test.tsx index 629042939..623108256 100644 --- a/examples/audiobook-curator/tests/application.test.tsx +++ b/examples/audiobook-curator/tests/application.test.tsx @@ -1,3 +1,4 @@ +import { lowerMcpResult } from '@agent-bundle/rsc-runtime'; import { describe, expect, it } from '@rstest/core'; import { @@ -117,6 +118,17 @@ describe('audiobook curator RSC application', () => { expect(signal).toBe(controller.signal); }); + it('renders text and detached structured receipts through the public RSC lowerer', async () => { + const application = createAudiobookCuratorApplication({ operations: operations() }); + const inspect = application.operations.find((operation) => operation.mcp?.name === 'inspect_sources')!; + const receipt = await inspect.execute({ root: '/library' }, { signal: new AbortController().signal }); + + expect(lowerMcpResult(inspect.render(receipt))).toEqual({ + content: [{ text: 'Inspected 0 audio files (0 bytes).', type: 'text' }], + structuredContent: { files: [], operation: 'inspect', root: '/library', totalBytes: 0 }, + }); + }); + it('provides root and command help through the installed CLI adapter', async () => { const output: string[] = []; await expect(runCli(['--help'], { operations: operations(), write: (value) => output.push(value) })).resolves.toBe(0); diff --git a/examples/audiobook-curator/tests/curator-core.test.ts b/examples/audiobook-curator/tests/curator-core.test.ts index efb9db315..ab63d8663 100644 --- a/examples/audiobook-curator/tests/curator-core.test.ts +++ b/examples/audiobook-curator/tests/curator-core.test.ts @@ -1,11 +1,10 @@ -import { mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import { afterEach, describe, expect, it } from '@rstest/core'; import { - auditAudiobook, inspectSources, prepareAudiobook, type MediaProcess, @@ -19,7 +18,6 @@ const makeRoot = async (): Promise => { }; afterEach(async () => { - const { rm } = await import('node:fs/promises'); await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); }); @@ -82,19 +80,6 @@ describe('audiobook curator core', () => { )).rejects.toThrow('already exists'); }); - it('audits content with a stable hash and an optional full decode', async () => { - const root = await makeRoot(); - const source = join(root, 'book.m4b'); - const calls: Array<{ args: readonly string[]; executable: string }> = []; - await writeFile(source, 'audited audio'); - - const receipt = await auditAudiobook({ fullDecode: true, source }, { process: processFixture(calls) }); - - expect(receipt).toMatchObject({ fullDecode: true, operation: 'audit', source }); - expect(receipt.sha256).toMatch(/^[a-f0-9]{64}$/u); - expect(calls.some(({ executable }) => basename(executable).includes('ffmpeg'))).toBe(true); - }); - it('rejects output names that can escape the selected output root', async () => { const root = await makeRoot(); const source = join(root, 'source.mp3'); diff --git a/examples/audiobook-curator/tests/mcp-tools.test.tsx b/examples/audiobook-curator/tests/mcp-tools.test.tsx deleted file mode 100644 index f8bd9b3ff..000000000 --- a/examples/audiobook-curator/tests/mcp-tools.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from '@rstest/core'; - -import { - createCuratorTools, - type CuratorToolOperations, -} from '../src/mcp-tools.js'; - -const operations = (): CuratorToolOperations => ({ - audit: async (input) => ({ - audioSha256: 'b'.repeat(64), - bytes: 12, - chapterIssues: [], - chapters: [], - exitCode: 0, - file: input.file, - fullDecode: input.fullDecode === true ? 'verified' : 'not-requested', - generatedAt: '2026-08-26T00:00:00.000Z', - mutation: false, - operation: 'audit', - probe: { codec: 'aac', durationSeconds: 12, format: 'mov', tags: {} }, - sha256: 'a'.repeat(64), - sourceChapterMapping: { issues: [], status: 'not-requested' }, - status: 'verified', - }), - inspect: async (input) => ({ files: [], operation: 'inspect', root: input.root, totalBytes: 0 }), - prepare: async (input) => ({ - applied: input.apply ?? false, - operation: 'prepare', - output: `${input.outputRoot}/book.m4b`, - probe: { codec: 'mp3', durationSeconds: 12, format: 'mp3', tags: {} }, - source: input.source, - }), -}); - -describe('audiobook curator MCP tools', () => { - it('renders text and detached structured receipts through the public RSC lowerer', async () => { - const tools = createCuratorTools({ operations: operations() }); - const inspect = tools.find(({ name }) => name === 'inspect_sources')!; - const result = await inspect.execute({ root: '/library' }, new AbortController().signal); - - expect(result).toEqual({ - content: [{ text: 'Inspected 0 audio files (0 bytes).', type: 'text' }], - structuredContent: { files: [], operation: 'inspect', root: '/library', totalBytes: 0 }, - }); - }); - - it('forwards typed apply and caller cancellation to the shared core', async () => { - let apply = false; - let signal: AbortSignal | undefined; - const fixture = operations(); - const tools = createCuratorTools({ - operations: { - ...fixture, - prepare: async (input, options) => { - apply = input.apply === true; - signal = options.signal; - return fixture.prepare(input, options); - }, - }, - }); - const controller = new AbortController(); - const prepare = tools.find(({ name }) => name === 'prepare_audiobook')!; - await prepare.execute({ apply: true, outputRoot: '/curated', source: '/library/book.mp3' }, controller.signal); - - expect(apply).toBe(true); - expect(signal).toBe(controller.signal); - }); -}); From cb786458acdf17e486bee483aaa3c71c723ed38b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 30 Aug 2026 19:33:10 +0000 Subject: [PATCH 3/3] docs(examples/audiobook-curator): document the layout and the dual-config build gap Add a source-layout section and maintainer notes to the README explaining why both agent-bundle.config.ts and rslib.config.ts exist (agent-bundle build emits host artifacts but no node-consumable dist for bin/exports), and leave a pointed comment in rslib.config.ts marking it for deletion once the framework owns the package build. --- examples/audiobook-curator/README.md | 31 ++++++++++++++++++++++ examples/audiobook-curator/rslib.config.ts | 8 ++++++ 2 files changed, 39 insertions(+) diff --git a/examples/audiobook-curator/README.md b/examples/audiobook-curator/README.md index 48f9f1569..562bf9321 100644 --- a/examples/audiobook-curator/README.md +++ b/examples/audiobook-curator/README.md @@ -38,6 +38,24 @@ including each host's plugin metadata, Skill, bundled CLI script, and bundled MC server. The example uses only public `agent-bundle` and `@agent-bundle/rsc-runtime` exports with `workspace:*` dependencies. +## Source layout + +- `src/application.tsx` — composition only: merges the feature modules' + defaults and declares the `` tree (Skill, CLI Script, MCP + server, operations). +- `src/operations/` — the operation catalog, grouped by workflow stage: + `discovery` (inspect/inventory/library-audit/select), `audible` + (search/select/cache), `evidence` (acoustic/whisper), `media-mutation` + (apply-metadata/apply-chapters), and `output` (convert/prepare/audit), with + shared `cli-arguments.ts` and `schemas.ts`. +- Domain logic lives beside them in `src/` (`library.ts`, `audible.ts`, + `evidence.ts`, `conversion.ts`, `media-mutation.ts`, `integrity-audit.ts`, + `curator-core.ts`) over the shared `foundation.ts` and `media-process.ts` + primitives; `result.tsx` renders every receipt for MCP. +- `src/cli.ts`, `src/cli-entry.ts`, `src/mcp-server.ts`, and + `bin/audiobook-curator.js` are the entry shims for the CLI (test-injectable + runner, bundled `