UNPKG

@withpica/mcp-server

Version:

MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog

1,209 lines • 65.3 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
import { formatAsText, formatList, formatSuccess, formatDeletion, pageEnvelope, } from "@withpica/mcp-utils";
import { MEASURED_AUDIO_BASIS, toMeasuredAudio } from "./measured-audio.js";
// Server default when the caller omits limit — mirrors the route's
// parsePaginationParams({ limit: 50 }) default on both audio-files list
// branches (workId/no-workId via getAudioFiles, and unassigned via
// findUnassigned), so pageEnvelope's `limit` reflects what the server
// actually applied even when the agent didn't pass one.
const DEFAULT_AUDIO_QUERY_LIMIT = 50;
/**
 * Caps copied from the server so the schema advertises what the route
 * enforces: `AUDIO_QUERY_MAX_WORK_IDS` in lib/services/audio-files/urls.ts and
 * `AUDIO_BULK_UPDATE_MAX_ITEMS` in lib/services/audio-files/bulk-update.ts.
 * Hand-copied (this package cannot import the app); no test ties them —
 * change both together.
 */
const AUDIO_QUERY_MAX_WORK_IDS = 100;
const AUDIO_BULK_UPDATE_MAX_ITEMS = 200;
/** Fields a LIST row carries. Everything else is pica_audio_inspect's. */
const AUDIO_LIST_FIELDS = [
    "id",
    "title",
    "filename",
    "file_type",
    "stem_label",
    "version_label",
    "classification",
    "file_size",
    "audio_format",
    "duration_seconds",
    "detected_bpm",
    "detected_key",
    "detected_energy",
    "detected_mood",
    "analysis_status",
    "published",
    "work_id",
    "recording_id",
    "storage_bucket",
    "uploaded_at",
    "created_at",
    // Attached by the route on the unassigned path — the whole point of that
    // call, so it must survive the projection.
    "probable_match",
];
/**
 * Project one audio row to its list shape. Dropped on purpose:
 * `waveform_data` (the weight — on pica_audio_inspect sections:["waveform"]),
 * sample-rate / bit-depth / danceability (inspect sections:["analysis"]),
 * and `metadata`, `file_path`, `cdn_url`, `extraction_error`, analysis
 * version/processor and the `recording` / `work` embeds — storage internals
 * not exposed over MCP. See queryAudioFiles for the measurement.
 */
export function toAudioListItem(row) {
    if (!row || typeof row !== "object")
        return {};
    const src = row;
    const out = {};
    for (const k of AUDIO_LIST_FIELDS) {
        if (src[k] !== undefined)
            out[k] = src[k];
    }
    return out;
}
/**
 * The `identification` section of pica_audio_inspect — what ACRCloud said this
 * file is. Built from the single-file GET, which already carries the
 * audio_analysis_results satellite flattened onto the row (findById in
 * lib/services/audio-files/queries.ts selects `audio_analysis_results(*)`), so
 * the section costs no extra call.
 *
 * Before 2026-09-26 no read tool exposed these columns: the only way an agent
 * saw a match was the full row echoed by pica_audio_update, which #2360
 * slimmed — leaving them unreachable (ops issue 3cf19a67).
 *
 * Deliberately lean: each DSP entry keeps only track_id + link (artwork,
 * preview URLs and artist genres are dropped), and the ACRCloud AI-detection
 * payload is cut to its headline fields (the per-stem and per-generator
 * probability arrays are left out). Values are copied as stored, never
 * rescaled: `identification_confidence` is ACRCloud's match score as written
 * (25-100 across prod rows on 2026-09-26). `ai_detection.confidence` is the
 * AI probability on 0-100 (the AiDetectionAcrcloud type). Its prod values
 * span 0.2-96.47, which looked like mixed scales, but read against the
 * verdict on 2026-09-26 (723 rows, one model_id): every `ai_generated` row is
 * ≥ 61.91 and every `human` row ≤ 47.71, and all 363 values ≤ 1 are `human`
 * — one 0-100 scale, with human tracks scoring near zero. A file never run
 * through ACRCloud reports `identification_status: null`.
 */
export function toIdentificationSection(file) {
    const src = file && typeof file === "object" ? file : {};
    const asObject = (v) => v && typeof v === "object" && !Array.isArray(v)
        ? v
        : null;
    const strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
    const field = (k) => src[k] ?? null;
    const externalIds = asObject(src.identification_external_ids) ?? {};
    const dspRaw = asObject(src.identification_dsp_ids);
    let dspIds = null;
    if (dspRaw) {
        dspIds = {};
        for (const [platform, entry] of Object.entries(dspRaw)) {
            const e = asObject(entry);
            if (!e)
                continue;
            dspIds[platform] = { track_id: e.track_id ?? null, link: e.link ?? null };
        }
    }
    const ai = asObject(src.ai_detection_acrcloud);
    const aiDetection = ai
        ? {
            is_ai_generated: ai.is_ai_generated ?? null,
            confidence: ai.confidence ?? null,
            likely_source: ai.likely_source ?? null,
            model_id: ai.model_id ?? null,
        }
        : null;
    return {
        identification_status: field("identification_status"),
        identification_source: field("identification_source"),
        identification_completed_at: field("identification_completed_at"),
        confidence: field("identification_confidence"),
        acrid: field("acrid"),
        identified_title: field("identified_title"),
        identified_artist: field("identified_artist"),
        identified_album: field("identified_album"),
        identified_label: field("identified_label"),
        identified_release_date: field("identified_release_date"),
        isrc: strings(externalIds.isrc),
        upc: strings(externalIds.upc),
        iswc: strings(externalIds.iswc),
        dsp_ids: dspIds,
        ai_detection: aiDetection,
    };
}
/**
 * The one-line summary of a matched pica_audio_identify.
 *
 * The route (app/api/admin/audio-files/[id]/identify) returns the service's
 * IdentificationResult FLAT — `title`, `artist`, `confidence`,
 * `externalIds.isrc[]` at the top level. Until 2026-09-26 this tool read a
 * `result.match` object the route has never sent, so a real match never
 * reached this branch; and it multiplied `confidence` by 100, which only a
 * 0-1 score would need. ACRCloud's music score is 0-100 — every stored
 * `audio_analysis_results.identification_confidence` on prod reads 25-100
 * (2026-09-26) — so it is printed as-is.
 */
export function describeIdentifyMatch(result) {
    const r = result && typeof result === "object"
        ? result
        : {};
    const ext = r.externalIds && typeof r.externalIds === "object"
        ? r.externalIds
        : {};
    const isrcs = Array.isArray(ext.isrc)
        ? ext.isrc.filter((x) => typeof x === "string")
        : [];
    const confidence = typeof r.confidence === "number"
        ? `${Math.round(r.confidence)}/100`
        : "not reported";
    const title = typeof r.title === "string" ? r.title : "untitled";
    const artist = typeof r.artist === "string" ? r.artist : "unknown artist";
    return (`Match found: "${title}" by ${artist} (ACRCloud score ${confidence}). ` +
        `ISRC: ${isrcs.length ? isrcs.join(", ") : "none returned"}. ` +
        "If the file is linked to a work, metadata has been propagated.");
}
export class AudioFilesTools {
    pica;
    constructor(pica) {
        this.pica = pica;
    }
    getTools() {
        return [
            {
                definition: {
                    name: "pica_audio_query",
                    scope: "read:files",
                    audiences: ["stdio", "http", "public"],
                    tier: "read",
                    description: "Find audio files — filter by name/title (`query`), work (`work_id`, or `work_ids` for several at once), file type, or analysis status. " +
                        "Rows are a LEAN list shape (identity, size, duration, bpm/key/energy/mood, links, bucket). For one file, pica_audio_inspect adds sample rate, bit depth, danceability (sections: ['analysis']) and the waveform (sections: ['waveform']); the raw `metadata` jsonb, storage path and CDN URL are storage internals not exposed over MCP. " +
                        "→ then: pica_audio_inspect (details for a specific file), " +
                        "pica_audio_assign (attach an unassigned file to a work/recording). " +
                        "Set unassigned:true to find orphan audio (no work/recording attached yet).",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            work_id: {
                                type: "string",
                                description: "Filter by work ID to get all audio files for a specific work",
                            },
                            work_ids: {
                                type: "array",
                                items: { type: "string" },
                                maxItems: AUDIO_QUERY_MAX_WORK_IDS,
                                description: `Audio for several works in ONE call (up to ${AUDIO_QUERY_MAX_WORK_IDS} work UUIDs) — e.g. every song in a playlist. Includes files attached through a recording of those works. Pages with limit/offset like the rest of the list; combine with file_type (e.g. 'master') to narrow to that kind of file.`,
                            },
                            file_type: {
                                type: "string",
                                description: "Filter by file type",
                                enum: ["master", "instrumental", "stem", "demo", "version"],
                            },
                            unprocessed: {
                                type: "boolean",
                                description: "Set true to get only files without audio analysis (no BPM/key detection yet)",
                            },
                            unassigned: {
                                type: "boolean",
                                description: "Set true to find audio the user uploaded that isn't attached to any work or recording yet — then attach it with pica_audio_assign",
                            },
                            query: {
                                type: "string",
                                description: "Filter by filename or title (case-insensitive substring). Applies to ALL results, not only unassigned ones — use it to find a specific file by name instead of paging the whole library, which truncates past the 100KB response cap.",
                            },
                            limit: {
                                type: "number",
                                minimum: 1,
                                maximum: 200,
                                description: "Maximum results to return (default: 50)",
                            },
                            offset: {
                                type: "number",
                                description: "Pagination offset — pass next_offset from the previous response to fetch the next page (default 0).",
                            },
                        },
                    },
                },
                executor: this.queryAudioFiles.bind(this),
            },
            {
                definition: {
                    name: "pica_audio_inspect",
                    scope: "read:files",
                    audiences: ["stdio", "http", "public"],
                    tier: "read",
                    description: "Get details for a specific audio file, including its stored " +
                        'transcript (sections: ["transcript"]) even when the file is not ' +
                        "linked to any work, and what ACRCloud identified it as " +
                        '(sections: ["identification"] — status, matched title/artist/' +
                        "album/label, ISRC/UPC, DSP ids, confidence, AI-detection " +
                        "summary). The analysis section carries `measured`: tempo_bpm, key, " +
                        "mode, energy, loudness_lufs and duration_seconds as measured " +
                        "facts with units, plus how energy was measured. " +
                        "Omit sections for everything. " +
                        "→ then: pica_audio_analyze (run analysis), pica_audio_identify (fingerprint match), " +
                        "pica_audio_assign (link an orphaned file to its work)",
                    workflows: ["audio-upload-required"],
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            id: {
                                type: "string",
                                description: "Audio file ID",
                            },
                            sections: {
                                type: "array",
                                items: {
                                    type: "string",
                                    enum: [
                                        "basic",
                                        "analysis",
                                        "waveform",
                                        "status",
                                        "transcript",
                                        "identification",
                                    ],
                                },
                                description: "Which data sections to include. Omit for all sections. " +
                                    "`transcript` returns the stored speech-to-text transcript " +
                                    "for this file whether or not it is linked to a work — the " +
                                    "only way to read one that has not been copied onto a work's " +
                                    "lyrics. `identification` returns the stored ACRCloud " +
                                    "result; identification_status null means the file was " +
                                    "never identified (run pica_audio_identify).",
                            },
                        },
                        required: ["id"],
                    },
                },
                executor: this.inspectAudioFile.bind(this),
            },
            {
                definition: {
                    name: "pica_audio_analyze",
                    scope: "write:files",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Trigger audio analysis on an audio file. Two analysis types available:\n" +
                        "• Audio features (BPM, key, mode, energy from measured loudness, loudness in LUFS, duration; valence and mood are not measured and come back null)\n" +
                        "• Lyrics transcription (speech-to-text + language detection)\n\n" +
                        "Both are enabled by default. Analysis runs asynchronously — use pica_audio_inspect with status section to check progress. " +
                        "Transcribed lyrics are saved to the transcriptions table and auto-copied to the work's lyrics field when it is empty (existing lyrics are never overwritten). " +
                        "→ then: pica_audio_inspect (check progress), pica_search_for_sync (find sync-ready works)",
                    workflows: ["audio-upload-required"],
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            id: {
                                type: "string",
                                description: "Audio file ID to analyze",
                            },
                            force: {
                                type: "boolean",
                                description: "Force re-analysis even if the file was already analyzed",
                            },
                            enable_audio: {
                                type: "boolean",
                                description: "Enable audio feature extraction (BPM, key, energy). Default: true.",
                            },
                            enable_lyrics: {
                                type: "boolean",
                                description: "Enable lyrics transcription. Default: true.",
                            },
                        },
                        required: ["id"],
                    },
                },
                executor: this.analyzeAudioFile.bind(this),
            },
            {
                definition: {
                    name: "pica_audio_presigned_upload",
                    scope: "write:files",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Generate a signed URL for uploading an audio file directly to storage. Returns an upload URL and upload ID. After uploading the file via HTTP PUT to the returned URL, call pica_audio_complete_upload to finalize. Supported formats: WAV, MP3, FLAC, AIFF, OGG, WebM. Max size: 800MB. " +
                        "→ then: pica_audio_complete_upload (finalize upload), pica_audio_analyze (extract BPM, key, mood). " +
                        "TIP: if you are in ChatGPT or another external client, users cannot upload audio " +
                        "through the chat. Direct them to https://withpica.com/inspect where they can " +
                        "drag and drop audio files (it auto-detects format and runs analysis). " +
                        "If the user insists on using this tool, ask for the filename and file size — " +
                        "you can detect the format from the file extension, no need to ask for content type.",
                    workflows: ["audio-upload-required"],
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            filename: {
                                type: "string",
                                description: "Original filename with extension (e.g. 'my-song.wav')",
                            },
                            content_type: {
                                type: "string",
                                description: "MIME type — detect from the filename extension: " +
                                    ".wav→audio/wav, .mp3→audio/mpeg, .flac→audio/flac, " +
                                    ".aiff/.aif→audio/aiff, .ogg→audio/ogg, .webm→audio/webm. " +
                                    "Do not ask the user for this — work it out from the filename.",
                                enum: [
                                    "audio/wav",
                                    "audio/x-wav",
                                    "audio/mpeg",
                                    "audio/mp3",
                                    "audio/flac",
                                    "audio/x-flac",
                                    "audio/aiff",
                                    "audio/x-aiff",
                                    "audio/ogg",
                                    "audio/webm",
                                ],
                            },
                            file_size: {
                                type: "number",
                                minimum: 1,
                                description: "Exact file size in bytes (> 0). Never send 0 or a guess — if you cannot read the file, you cannot upload it from here; send the user to withpica.com/inspect.",
                            },
                            work_id: {
                                type: "string",
                                description: "Optional work ID to link the audio file to. If provided, a recording will be auto-created for master files.",
                            },
                            title: {
                                type: "string",
                                description: "Optional title for the audio file. Defaults to filename without extension.",
                            },
                            file_type: {
                                type: "string",
                                description: "Type of audio file. Set it when you know it. If omitted, PICA reads the filename (instrumental / -inst / karaoke / playback / -pa → instrumental; stem(s) → stem; demo → demo; remix / radio edit / (live) / acoustic → version); with no signal in the name it stays 'master'.",
                                enum: ["master", "instrumental", "stem", "demo", "version"],
                            },
                        },
                        required: ["filename", "content_type", "file_size"],
                    },
                },
                executor: this.presignedUpload.bind(this),
            },
            {
                definition: {
                    name: "pica_audio_complete_upload",
                    scope: "write:files",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Finalize an audio file upload after the file has been PUT to the signed URL from pica_audio_presigned_upload. Creates the database record and optionally links to a work/recording. For master files linked to a work, a recording is auto-created if none exists. " +
                        "After several mutations, surface a pica_dashboard_briefing to the user. " +
                        "→ then: pica_audio_analyze (extract BPM, key, mood), pica_audio_identify (fingerprint match via ACRCloud)",
                    workflows: ["audio-upload-required"],
                    vernacular_kind: "internal",
                    vernacular_reason: "Invoked after pica_audio_presigned_upload PUT completes; finalizes the audio file record.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            upload_id: {
                                type: "string",
                                description: "The uploadId returned from pica_audio_presigned_upload",
                            },
                            key: {
                                type: "string",
                                description: "The storage key returned from pica_audio_presigned_upload",
                            },
                            bucket: {
                                type: "string",
                                description: "The bucket returned from pica_audio_presigned_upload",
                            },
                            filename: {
                                type: "string",
                                description: "Original filename",
                            },
                            content_type: {
                                type: "string",
                                description: "MIME type of the uploaded file",
                            },
                            file_size: {
                                type: "number",
                                description: "File size in bytes",
                            },
                            work_id: {
                                type: "string",
                                description: "Optional work ID to link the audio file to",
                            },
                            title: {
                                type: "string",
                                description: "Optional title for the audio file",
                            },
                            file_type: {
                                type: "string",
                                description: "Type of audio file. Set it when you know it. If omitted, PICA reads the filename (instrumental / -inst / karaoke / playback / -pa → instrumental; stem(s) → stem; demo → demo; remix / radio edit / (live) / acoustic → version); with no signal in the name it stays 'master'.",
                                enum: ["master", "instrumental", "stem", "demo", "version"],
                            },
                            stem_label: {
                                type: "string",
                                description: "Label for stem files (e.g. 'vocals', 'drums', 'bass')",
                            },
                            version_label: {
                                type: "string",
                                description: "Label for version files (e.g. 'radio edit', 'extended mix')",
                            },
                            recording_id: {
                                type: "string",
                                description: "Link to an existing recording ID instead of auto-creating one",
                            },
                            classification: {
                                type: "string",
                                description: "Audio classification (e.g. 'original', 'remix', 'cover', 'live')",
                            },
                        },
                        required: [
                            "upload_id",
                            "key",
                            "bucket",
                            "filename",
                            "content_type",
                            "file_size",
                        ],
                    },
                },
                executor: this.completeUpload.bind(this),
            },
            {
                definition: {
                    name: "pica_audio_identify",
                    scope: "write:files",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Identify a track using ACRCloud audio fingerprinting. Returns matched metadata (title, artist, album, ISRC, label) with confidence score. If matched, metadata is automatically propagated to the linked work. " +
                        "For BPM, key, mood or lyrics rather than a fingerprint match, use pica_audio_analyze.",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            id: {
                                type: "string",
                                description: "Audio file ID to identify",
                            },
                            force: {
                                type: "boolean",
                                description: "Force re-identification even if already matched",
                            },
                        },
                        required: ["id"],
                    },
                },
                executor: this.identifyAudioFile.bind(this),
            },
            // ADR-232 § Decision 4 — operational read of the audio analysis
            // pipeline state. Lets agents triage stuck / failed audio without
            // manually paginating audio_files and joining the satellite.
            {
                definition: {
                    name: "pica_audio_pipeline_status",
                    scope: "read:catalog",
                    audiences: ["stdio", "http"],
                    tier: "read",
                    description: "Use when the user asks: 'what's stuck in audio analysis?', 'why hasn't this audio been analysed?', 'show me the audio pipeline status'. " +
                        "Returns the audio-analysis pipeline state for the calling org: counts per state (pending / complete / failed / stuck_over_24h) plus up to 5 sample rows per non-complete bucket. " +
                        "stuck_over_24h is pending audio that has been waiting more than 24h — the operational signal that the analysis worker has fallen behind. " +
                        "→ then: pica_audio_inspect (drill into a specific stuck file), pica_audio_analyze (force a retry on a failed file)",
                    workflows: "infrastructure",
                    vernacular_kind: "creator-entry",
                    inputSchema: {
                        type: "object",
                        properties: {
                            window_days: {
                                type: "number",
                                description: "Optional lookback window in days. Defaults to 7. Integer between 1 and 365.",
                            },
                        },
                        required: [],
                    },
                    nextSteps: [
                        {
                            tool: "pica_audio_inspect",
                            reason: "Drill into a specific stuck or failed audio file from the samples list.",
                            when: "on_success",
                        },
                        {
                            tool: "pica_audio_analyze",
                            reason: "Force a re-analyse on a failed audio file once the cause is understood.",
                            when: "on_success",
                        },
                    ],
                },
                executor: this.audioPipelineStatus.bind(this),
            },
            {
                definition: {
                    name: "pica_audio_assign",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Attach an uploaded audio file to a work and/or a recording. Find unattached audio first with pica_audio_query unassigned:true. Pass at least one of work_id / recording_id. Reversible.",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist catalog-linking action in the audio assignment flow, outside the Creator Question Atlas.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            audio_file_id: {
                                type: "string",
                                description: "the audio file to attach",
                            },
                            work_id: {
                                type: "string",
                                description: "attach to this work",
                            },
                            recording_id: {
                                type: "string",
                                description: "attach to this recording",
                            },
                        },
                        required: ["audio_file_id"],
                    },
                },
                executor: this.assignAudio.bind(this),
            },
            {
                definition: {
                    name: "pica_audio_bulk_assign_suggested",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Attach ALL orphan audio files whose suggested match is high-confidence " +
                        "(ISRC-verified, non-ambiguous) in ONE call — instead of looping " +
                        "pica_audio_assign per file. Pairings are recomputed server-side at " +
                        "execution time: nothing is sent, stale suggestions can't be applied, " +
                        "dismissed pairs stay excluded, and title-only ('possible') matches are " +
                        "NEVER bulk-attached. Returns counts (attached / skipped_ambiguous / " +
                        "skipped_possible / failed) + the attached audio file ids. Review the " +
                        "remainder with pica_audio_query unassigned:true, attach individually " +
                        "with pica_audio_assign, or reject a bad suggestion with " +
                        "pica_audio_dismiss_match. Reversible per file (reassign or unassign).",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist catalog-linking action in the audio assignment flow, outside the Creator Question Atlas.",
                    annotations: { title: "bulk attach suggested audio" },
                    inputSchema: {
                        type: "object",
                        properties: {
                            dry_run: {
                                type: "boolean",
                                description: "When true, returns { dry_run: true, attached (= would_attach), skipped_ambiguous, skipped_possible, attached_ids } without attaching anything — the SAME server-side classification runs, so the preview can't drift from the real run. Use to preview the bulk before committing.",
                            },
                        },
                    },
                    previewMode: "dry_run",
                },
                executor: this.bulkAssignSuggested.bind(this),
            },
            {
                definition: {
                    name: "pica_audio_dismiss_match",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Reject a suggested (audio file → recording) match so the pair never " +
                        "resurfaces — in suggestions, in the review queue, or in " +
                        "pica_audio_bulk_assign_suggested. The audio file stays an orphan and " +
                        "can still be attached manually (pica_audio_assign) or to a different " +
                        "recording; only THIS pairing is suppressed. Repeating a dismissal is a " +
                        "no-op success.",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist catalog-linking action in the audio assignment flow, outside the Creator Question Atlas.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            audio_file_id: {
                                type: "string",
                                description: "Audio file ID the suggestion is for",
                            },
                            recording_id: {
                                type: "string",
                                description: "Recording ID of the suggested match to reject",
                            },
                        },
                        required: ["audio_file_id", "recording_id"],
                    },
                },
                executor: this.dismissMatch.bind(this),
            },
            {
                definition: {
                    name: "pica_audio_delete",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "destructive",
                    description: "Delete an audio file. Removes the file record AND its underlying " +
                        "storage object (the bucket object is cleaned up too — no orphan is " +
                        "left behind). Analysis and transcription satellites are removed " +
                        "with it. This does not delete the parent work or recording — only " +
                        "the audio file itself. To re-point a wrongly attached file instead " +
                        "of destroying it, use pica_audio_assign.",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Destructive operation; invoked by exact name after explicit user confirmation in a specialist flow.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            id: {
                                type: "string",
                                description: "Audio file ID",
                            },
                        },
                        required: ["id"],
                    },
                },
                executor: this.deleteAudioFile.bind(this),
            },
            {
                definition: {
                    name: "pica_audio_update",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Update metadata on an already-uploaded audio file: set its file_type " +
                        "(master/instrumental/stem/demo/version), stem_label (e.g. 'drums', " +
                        "'bass', 'lead vocal'), version_label, or title. Use this to tag a stem " +
                        "or correct a file_type after upload (previously only possible at upload " +
                        "time). To attach the file to a work/recording use pica_audio_assign " +
                        "instead. Reversible.",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist catalog-metadata action in the audio management flow, outside the Creator Question Atlas.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            id: {
                                type: "string",
                                description: "the audio file to update",
                            },
                            file_type: {
                                type: "string",
                                description: "set the file type",
                                enum: ["master", "instrumental", "stem", "demo", "version"],
                            },
                            stem_label: {
                                type: "string",
                                description: "label for a stem (e.g. 'drums', 'bass', 'lead vocal'); pass an empty string to clear",
                            },
                            version_label: {
                                type: "string",
                                description: "label for a version/alternate (e.g. 'radio edit', 'acoustic'); pass an empty string to clear",
                            },
                            title: {
                                type: "string",
                                description: "set the audio file's title",
                            },
                        },
                        required: ["id"],
                    },
                },
                executor: this.updateAudio.bind(this),
            },
            {
                definition: {
                    name: "pica_audio_bulk_update",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: `Update metadata on up to ${AUDIO_BULK_UPDATE_MAX_ITEMS} audio files in ONE call — each item sets any of ` +
                        "file_type (master/instrumental/stem/demo/version), stem_label, version_label, title for one file id. " +
                        "Use instead of looping pica_audio_update, e.g. to tag every file in a playlist as master or label a " +
                        "set of stems. Only this organisation's files are touched. Returns one lean result per item — " +
                        "updated, or failed with a reason (unknown id, bad file_type, nothing to set) — and never the file " +
                        "rows; a bad item does not stop the others. To attach files to a work/recording use pica_audio_assign. " +
                        "Reversible (update again). Pass dry_run:true to preview.",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist catalog-metadata action in the audio management flow, outside the Creator Question Atlas.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            items: {
                                type: "array",
                                minItems: 1,
                                maxItems: AUDIO_BULK_UPDATE_MAX_ITEMS,
                                description: `One entry per audio file (max ${AUDIO_BULK_UPDATE_MAX_ITEMS}). Each needs id plus at least one field to set.`,
                                items: {
                                    type: "object",
                                    properties: {
                                        id: {
                                            type: "string",
                                            description: "the audio file to update",
                                        },
                                        file_type: {
                                            type: "string",
                                            enum: [
                                                "master",
                                                "instrumental",
                                                "stem",
                                                "demo",
                                                "version",
                                            ],
                                            description: "set the file type",
                                        },
                                        stem_label: {
                                            type: "string",
                                            description: "label for a stem (e.g. 'drums'); pass an empty string to clear",
                                        },
                                        version_label: {
                                            type: "string",
                                            description: "label for a version (e.g. 'radio edit'); pass an empty string to clear",
                                        },
                                        title: {
                                            type: "string",
                                            description: "set the audio file's title",
                                        },
                                    },
                                    required: ["id"],
                                },
                            },
                            dry_run: {
                                type: "boolean",
                                description: "When true, validates every item and checks each id exists in this organisation, returning would_update / failed per item without writing anything.",
                            },
                        },
                        required: ["items"],
                    },
                    previewMode: "dry_run",
                },
                executor: this.bulkUpdateAudio.bind(this),
            },
        ];
    }
    async assignAudio(args) {
        if (!args.work_id && !args.recording_id) {
            return {
                content: [
                    {
                        type: "text",
                        text: "provide at least one of work_id or recording_id to attach the audio",
                    },
                ],
                isError: true,
            };
        }
        // Cast to the real update signature — worktree resolves @withpica/mcp-sdk
        // via the parent repo's node_modules (stale dist). CI builds the SDK fresh.
        const audioFiles = this.pica.audioFiles;
        try {
            const updated = await audioFiles.update(args.audio_file_id, {
                work_id: args.work_id,
                recording_id: args.recording_id,
            });
            // The route returns the full row — waveform_data and the flattened
            // analysis satellite, ~8k tokens a file (ops issue 5c53049a). An agent
            // needs to see the link it made, not the waveform: project to the same
            // lean shape a pica_audio_query row has.
            return formatSuccess("Audio assigned", toAudioListItem(updated));
        }
        catch (err) {
            // resolve-any-ID recovery (WS5 tail): agents holding a bare id often
            // don't know whether it's a work or a recording; a mis-slotted id
            // fails the FK with an opaque error. Probe what the id actually is
            // and say so — response-only, best-effort, error path only.
            const hint = await this.wrongIdTypeHint(args);
            if (hint) {
                return { content: [{ type: "text", text: hint }], isError: true };
            }
            throw err;
        }
    }
    /**
     * When an assign failed and one of the passed ids exists as the OTHER
     * entity type, name the mistake precisely. Returns null when the probe
     * finds nothing (the original error is rethrown by the caller).
     */
    async wrongIdTypeHint(args) {
        const probes = [
            { passedAs: "work_id", id: args.work_id, other: "recording" },
            { passedAs: "recording_id", id: args.recording_id, other: "work" },
        ];
        for (const { passedAs, id, other } of probes) {
            if (typeof id !== "string" || id.length === 0)
                continue;
            const exists = await (other === "recording"
                ? this.pica.recordings.get(id)
                : this.pica.works.get(id)).then(() => true, () => false);
            if (exists) {
                return (`assign failed: ${passedAs}="${id}" is actually a ${other.toUpperCase()} id — ` +
                    `pass it as ${other}_id instead.`);
            }
        }
        return null;
    }
    async bulkAssignSuggested(args) {
        // Cast to the real signature — worktree resolves @withpica/mcp-sdk via
        // the parent repo's node_modules (stale dist). CI builds the SDK fresh.
        const audioFiles = this.pica.audioFiles;
        const dryRun = args?.dry_run === true;
        const result = await audioFiles.bulkAssignSuggested(dryRun ? { dry_run: true } : undefined);
        const verb = dryRun ? "Would attach" : "Attached";
        const summary = result.attached === 0
            ? `No high-confidence matches to attach (${result.skipped_ambiguous} ambiguous, ` +
                `${result.skipped_possible} title-only skipped). Review the queue with ` +
                `pica_audio_query unassigned:true and attach individually with pica_audio_assign.`
            : `${verb} ${result.attached} audio file${result.attached === 1 ? "" : "s"} ` +
                `(${result.skipped_ambiguous} ambiguous + ${result.skipped_possible} title-only ` +
                `skipped, ${result.failed} failed).` +
                (dryRun
                    ? " Nothing was persisted — re-run without dry_run to commit."
                    : "");
        return formatSuccess(summary, result);
    }
    async dismissMatch(args) {
        const audioFiles = this.pica.audioFiles;
        await audioFiles.dismissMatch(args.audio_file_id, args.recording_id);
        return formatSuccess("Match dismissed — this audio → recording pairing will not be suggested again. " +
            "The file stays unassigned (attach it elsewhere with pica_audio_assign).", {
            audio_file_id: args.audio_file_id,
            recording_id: args.recording_id,
            dismissed: true,
        });
    }
    async updateAudio(args) {
        // Build the patch from only the fields the caller actually supplied, so an
        // omitted field is left untouched (and an explicit "" clears a label).
        const body = {};
        if (args.file_type !== undefined)
            body.file_type = args.file_type;
        if (args.stem_label !== undefined)
            body.stem_label = args.stem_label;
        if (args.version_label !== undefined)
            body.version_label = args.version_label;
        if (args.title !== undefined)
            body.title = args.title;
        if (Object.keys(body).length === 0) {
            return {
                content: [
                    {
                        type: "text",
                        text: "provide at least one of file_type, stem_label, version_label or title to update",
                    },
                ],
                isError: true,
            };
        }
        // Cast to the real update signature — worktree resolves @withpica/mcp-sdk
        // via the parent repo's node_modules (stale dist). CI builds the SDK fresh.
        const audioFiles = this.pica.audioFiles;
        const updated = await audioFiles.update(args.id, body);
        // Lean row, not the full record — see assignAudio (ops issue 5c53049a).
        return formatSuccess("Audio updated", toAudioListItem(updated));
    }
    async bulkUpdateAudio(args) {
        const items = args.items;
        if (!Array.isArray(items) || items.length === 0) {
            return {
                content: [
                    {
                        type: "text",
                        text: "items must be a non-empty array of { id, file_type?, stem_label?, version_label?, title? }",
                    },
                ],
                isError: true,
            };
        }
        if (items.length > AUDIO_BULK_UPDATE_MAX_ITEMS) {
            return {
                content: [
                    {
                        type: "text",
                        text: `at most ${AUDIO_BULK_UPDATE_MAX_ITEMS} items per call (got ${items.length}) — split the batch`,
                    },
                ],
                isError: true,
            };
        }
        const dryRun = args.dry_run === true;
        const result = await this.pica.audioFiles.bulkUpdate(items, dryRun ? { dry_run: true } : undefined);
        const verb = dryRun ? "Would update" : "Updated";
        const summary = `${verb} ${result.updated} of ${result.requested} audio file${result.requested === 1 ? "" : "s"}` +
            (result.failed > 0
                ? ` — ${result.failed} failed (see results[].reason).`
                : ".") +
            (dryRun ? " Nothing was written — re-run without dry_run to apply." : "");
        return formatSuccess(summary, result);
    }
    async deleteAudioFile(args) {
        // AudioFilesResource.delete is added in this change's SDK update. In an
        // isolated worktree, mcp-server resolves @withpica/mcp-sdk via the parent
        // repo's node_modules (a known worktree gotcha), so the freshly-built local
        // dist isn't seen by tsc here. Cast to the real signature — CI builds the
        // SDK fresh and typechecks this without the cast. (Not @ts-expect-error,
        // which would become an unused-directive error once the dist is current.)
        const audioFiles = this.pica.audioFiles;
        const outcome = await audioFiles.delete(args.id);
        return formatDeletion("audio file", args.id, outcome);
    }
    async queryAudioFiles(args) {
        const raw = await this.pica.audioFiles.list({
            work_id: args.work_id,
            work_ids: Array.isArray(args.work_ids) ? args.work_ids : undefined,
            file_type: args.file_type,
            unprocessed: args.unprocessed,
            unassigned: args.unassigned,
            query: args.query,
            limit: args.limit,
            offset: args.offset,
        });
        // The REST route returns { success, audioFiles, count, pagination }; the
        // SDK's generic unwrap (data.data || data) hands back the whole envelope
        // for this route (its list key is `audioFiles`, not `data`). Normalise to
        // the file array so count/total are correct and each orphan file's
        // probable_match (added to the unassigned response) surfaces cleanly at
        // items[].probable_match.
        const files = Array.isArray(raw)
            ? raw
            : (raw?.audioFiles ?? []);
        // Neither audio-files list branch (workId/no-workId via getAudioFiles, or
        // unassigned via findUnassigned) has a total row count — both report
        // `pagination.hasMore` from an authoritative limit+1 DB probe instead.
        // pageEnvelope's own no-total fallback (`returned >= limit`) over-reports
        // has_more on an exact-boundary last page (returned === limit, nothing
        // left) — the route's probe doesn't have that blind spot, so trust it
        // whenever the envelope carries one. A bare-array response (defensive
        // path, no route envelope) has no pagination to trust, so the fallback
        // stands.
        const pagination = Array.isArray(raw)
            ? undefined
            : raw?.pagination;
        const limit = args.limit ?? DEFAULT_AUDIO_QUERY_LIMIT;
        const offset = args.offset ?? 0;
        const page = pageEnvelope({ limit, offset, returned: files.length });
        if (pagination) {
            page.has_more = pagination.hasMore;
            page.next_offset = pagination.hasMore ? offset + files.length : null;
        }
        // ⚠️ LIST rows carry only what a list is for. The route returns the full
        // row with the analysis satellite flattened in — including
        // `waveform_data`, ~4.9KB per file on prod against ~1.6KB for the rest of
        // the row put together — plus `metadata` jsonb and recording/work embeds.
        // At ~8KB an item the 100KB response guard trimmed the page to 8 files,
        // which the reporter of ops issue 525585e8 read as "the same first 8
        // items regardless of filters". Paging works (the guard hands back
        // next_offset); it just should not have been needed at 8. Waveform and
        // the extra analysis scalars are on `pica_audio_inspect` (sections
        // "waveform" / "analysis"); `metadata`, `file_path`, `cdn_url` and the
        // recording/work embeds are storage internals and are not exposed over
        // MCP at all — say so rather than point at a call that lacks them.
        const items = files.map((f) => toAudioListItem(f));
        return formatList(items, { total: items.length, page });
    }
    async inspectAudioFile(args) {
        const sections = args.sections;
        const includeAll = !sections || sections.length === 0;
        const includeBasic = includeAll || sections.includes("basic");
        const includeAnalysis = includeAll || sections.includes("analysis");
        // Waveform is heavy (~4.9KB/file on prod) and opt-in even on inspect —
        // it is NOT part of the default (all-sections) response.
        const includeWaveform = !!sections && sections.includes("waveform");
        const includeStatus = includeAll || sections.includes("status");
        const includeTranscript = includeAll || sections.includes("transcript");
        const includeIdentification = includeAll || sections.includes("identification");
        const result = {};
        // Basic, analysis, waveform and identification all come from one get —
        // the route flattens the analysis satellite onto the row.
        if (includeBasic ||
            includeAnalysis ||
            includeWaveform ||
            includeIdentification) {
            const file = await this.pica.audioFiles.get(args.id);
            if (includeIdentification) {
                result.identification = toIdentificationSection(file);
            }
            if (includeWaveform) {
                result.waveform = {
                    waveform_data: file.waveform_data ??
                        null,
                };
            }
            if (includeBasic) {
                // ADR-201 Phase 2: surface every field the write side accepts.
                // pica_audio_complete_upload writes recording_id, stem_label,
                // version_label, classification (4 of the 5 audit-flagged fields)
                // directly to audio_files columns. The fifth — content_type —
                // is transformed into audio_format on write (e.g. "audio/mpeg"
                // → "mpeg"); already surfaced.
                result.basic = {
                    id: file.id,
                    work_id: file.work_id,
                    recording_id: file.recording_id,
                    title: file.title,
                    filename: file.filename,
                    file_type: file.file_type,
                    stem_label: file.stem_label,
                    version_label: file.version_label,
                    classification: file.classification,
                    file_size: file.file_size,
                    audio_format: file.audio_format,
                    published: file.published,
                    created_at: file.created_at,
                    updated_at: file.updated_at,
                };
            }
            if (includeAnalysis) {
                result.analysis = {
                    // The facts an agent should reason over, with units and method.
                    measured: toMeasuredAudio(file),
                    measured_basis: MEASURED_AUDIO_BASIS,
                    duration_seconds: file.duration_seconds,
                    detected_bpm: file.detected_bpm,
                    detected_key: file.detected_key,
                    detected_energy: file.detected_energy,
                    detected_mood: file.detected_mood,
                    // Moved off the LIST row on 2026-09-11 (ops 525585e8); this is now
                    // their only MCP home.
                    detected_danceability: file
                        .detected_danceability ?? null,
                    sample_rate: file.sample_rate ??
                        null,
                    bit_depth: file.bit_depth ??
                        null,
                    metadata_extracted: file.metadata_extracted,
                };
            }
        }
        // Fetch status separately
        if (includeStatus) {
            const status = await this.pica.audioFiles.getStatus(args.id);
            result.status = status;
        }
        // The transcript, wherever it is. Before 2026-09-07 an agent could not
        // read one at all unless it had already propagated to works.lyrics, which
        // never happens for a file with no work — 218 of 558 transcriptions on
        // prod. `has_transcript: false` is a normal answer; `reaches_work: false`
        // says this text is visible nowhere else, which is the cue to link the
        // file (pica_audio_assign), not to invent an association here.
        if (includeTranscript) {
            const transcript = await this.pica.audioFiles.getTranscript(args.id);
            result.transcript = transcript;
        }
        return formatSuccess("Audio file details", result);
    }
    async analyzeAudioFile(args) {
        const result = await this.pica.audioFiles.analyze(args.id, {
            forceReAnalyze: args.force,
            enableAudio: args.enable_audio,
            enableLyrics: args.enable_lyrics,
        });
        return formatSuccess(result.message || "Analysis triggered", result.features);
    }
    async presignedUpload(args) {
        const result = await this.pica.audioFiles.presignedUpload({
            filename: args.filename,
            contentType: args.content_type,
            fileSize: args.file_size,
            workId: args.work_id,
            title: args.title,
            fileType: args.file_type,
        });
        return formatSuccess(`Upload URL generated for "${args.filename}". PUT the file to the uploadUrl, then call pica_audio_complete_upload with the uploadId, key, bucket, and metadata to finalize.`, {
            uploadUrl: result.uploadUrl,
            uploadId: result.uploadId,
            key: result.key,
            bucket: result.bucket,
            expiresAt: result.expiresAt,
            metadata: result.metadata,
        });
    }
    async completeUpload(args) {
        const result = await this.pica.audioFiles.completeUpload({
            uploadId: args.upload_id,
            key: args.key,
            bucket: args.bucket,
            metadata: {
                filename: args.filename,
                contentType: args.content_type,
                fileSize: args.file_size,
                workId: args.work_id,
                title: args.title,
                fileType: args.file_type,
                stemLabel: args.stem_label,
                versionLabel: args.version_label,
                recordingId: args.recording_id,
                classification: args.classification,
            },
        });
        const hints = [
            {
                gap: "audio_not_analysed",
                suggestion: "Run pica_audio_analyze to extract BPM, key, mode and energy",
                severity: "important",
            },
            {
                gap: "audio_not_identified",
                suggestion: "Run pica_audio_identify to fingerprint and match against ACRCloud",
                severity: "nice_to_have",
            },
        ];
        if (!args.work_id) {
            hints.unshift({
                gap: "audio_not_linked",
                suggestion: "Link this audio file to a work",
                severity: "critical",
            });
        }
        return formatSuccess(`Upload finalized. Audio file created with ID: ${result.audioFileId}.`, result, hints);
    }
    async identifyAudioFile(args) {
        const result = await this.pica.audioFiles.identify(args.id, {
            force: args.force,
        });
        if (result.alreadyIdentified) {
            return formatSuccess("Already identified — no credits charged. Use force: true to re-identify.", result);
        }
        else if (result.status === "matched") {
            return formatSuccess(describeIdentifyMatch(result), result);
        }
        else if (result.status === "no_match") {
            return formatSuccess("No match found in ACRCloud database. This may be an unreleased or very rare track.", result);
        }
        else {
            return formatAsText(result);
        }
    }
    /**
     * ADR-232 § Decision 4 — pica_audio_pipeline_status executor.
     * Forwards to the SDK; the service-layer aggregator does the work.
     */
    async audioPipelineStatus(args) {
        const windowDaysRaw = args.window_days;
        let windowDays;
        if (windowDaysRaw !== undefined && windowDaysRaw !== null) {
            const parsed = Number(windowDaysRaw);
            if (!Number.isFinite(parsed) ||
                !Number.isInteger(parsed) ||
                parsed < 1 ||
                parsed > 365) {
                return {
                    content: [
                        {
                            type: "text",
                            text: "window_days must be an integer between 1 and 365.",
                        },
                    ],
                    isError: true,
                };
            }
            windowDays = parsed;
        }
        const result = await this.pica.audioPipelineStatus.status({
            window_days: windowDays,
        });
        const { pending, complete, failed, stuck_over_24h } = result.by_state;
        const total = pending + complete + failed + stuck_over_24h;
        const verdict = total === 0
            ? `No audio files in the last ${result.window_days} day(s)`
            : `${total} audio file${total === 1 ? "" : "s"} in window: ${pending} pending, ${complete} complete, ${failed} failed, ${stuck_over_24h} stuck over 24h`;
        return formatSuccess(verdict, result);
    }
}
//# sourceMappingURL=audio-files.js.map