UNPKG

@withpica/mcp-server

Version:

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

856 lines • 52.7 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
/**
 * Duplicate Detection & Merge Tools - MCP tools for finding and merging duplicates
 */
import { ApiError } from "@withpica/mcp-sdk";
import { buildDecision, formatAsText, formatSuccess, formatValidationError, } from "@withpica/mcp-utils";
/** Most merges one batched pica_merge_duplicates call may carry — kept equal
 *  to MAX_MERGE_BATCH_ITEMS in lib/services/human-approval-merge-batch.ts (the
 *  approval gate's cap), pinned by lib/services/__tests__/human-approval-merge-batch.test.ts. */
export const MAX_MERGE_BATCH_ITEMS = 50;
/** Most ids one pica_duplicates_mark_different call may carry — kept equal to
 *  MAX_EXCLUSION_IDS in lib/services/duplicate-pair-exclusions.ts (which the
 *  route enforces), pinned by lib/services/__tests__/duplicate-pair-exclusions.test.ts. */
export const MAX_MARK_DIFFERENT_IDS = 25;
/**
 * Coerce entity_type to canonical plural + SDK-shaped singular.
 * Accepts both legacy ("work", "person") and canonical ("works", "people"),
 * plus the new ADR-264 WS1.3 types ("releases"/"recordings" + singular aliases).
 * Canonical plural matches `pica_find_duplicates` and `pica_*_query`.
 */
export function coerceMergeEntityType(value) {
    switch (value) {
        case "work":
        case "works":
            return { canonical: "works", sdk: "work" };
        case "person":
        case "people":
            return { canonical: "people", sdk: "person" };
        case "recording":
        case "recordings":
            return { canonical: "recordings", sdk: "recording" };
        case "release":
        case "releases":
            return { canonical: "releases", sdk: "release" };
        // ⚠️ audio_files is deliberately ABSENT here. `pica_find_duplicates`
        // accepts it (ops issue dcea642b) but there is no merge semantics for a
        // FILE — the outcome is a person deleting a redundant upload. Adding it
        // to this coercion would hand pica_merge_duplicates an entity type the
        // route cannot merge; see coerceFindEntityType below for the find side.
        default:
            throw new Error(`Invalid entity_type: ${value}. Expected one of: works, people, releases, recordings (or singular aliases work, person, recording, release). audio_files can be FOUND but not merged.`);
    }
}
/**
 * Entity types `pica_find_duplicates` accepts — a SUPERSET of the mergeable
 * ones. `audio_files` (ops issue dcea642b, 2026-09-10) detects the same file
 * uploaded twice; it is discovery-only, so it must never reach
 * `coerceMergeEntityType`.
 */
export function coerceFindEntityType(value) {
    switch (value) {
        case "audio":
        case "audio_file":
        case "audio_files":
            return "audio_files";
        default:
            // Everything else must be a mergeable type — reuse that validation so
            // the two lists cannot drift apart silently.
            return coerceMergeEntityType(value).canonical;
    }
}
/** Human display name for a duplicate group — title/name over identifier key. */
export function duplicateGroupDisplayName(group) {
    const first = group.items[0] ?? {};
    const title = typeof first.title === "string" ? first.title : undefined;
    const firstName = typeof first.first_name === "string" ? first.first_name : undefined;
    const lastName = typeof first.last_name === "string" ? first.last_name : undefined;
    const artistName = typeof first.artist_name === "string" ? first.artist_name : undefined;
    const personName = [firstName, lastName].filter(Boolean).join(" ");
    return title || personName || artistName || group.key;
}
/** Entity types whose pairs can be marked "they're different" by an agent. */
export const MARK_DIFFERENT_ENTITY_TYPES = ["recordings", "releases"];
function canMarkDifferent(entityType) {
    return (entityType !== undefined &&
        MARK_DIFFERENT_ENTITY_TYPES.includes(entityType));
}
/**
 * ADR-267 Rule 3 — the duplicate-resolution choice, ready to put to the user.
 * `entityType` is the find call's entity type: recording and release groups
 * can be marked "they're different" (pica_duplicates_mark_different), so
 * "keep them separate" says how to stop them coming back.
 */
export function buildDuplicateGroupDecision(group, entityType) {
    const markable = canMarkDifferent(entityType);
    const n = group.items.length;
    const name = duplicateGroupDisplayName(group);
    const others = n - 1 === 1 ? "the other copy" : `the other ${n - 1} copies`;
    const isFuzzy = group.match_kind === "fuzzy_title";
    const isTitleOnly = group.match_kind === "title_artist";
    const isSameSong = group.match_kind === "same_song";
    const similarity = typeof group.similarity === "number" ? `${group.similarity}%` : "very";
    // ADR-324 — a remix paired with the song it remixes is NOT a duplicate
    // question. Never offer "fold the copies": folding would merge the remix's
    // writers into the original's shares.
    if (group.match_kind === "remix_recording" ||
        group.match_kind === "remix_modified_version") {
        const remix = (group.remix ?? {});
        const title = (id) => {
            const item = group.items.find((i) => i.id === id);
            return typeof item?.title === "string" ? `"${item.title}"` : "the work";
        };
        const original = title(remix.original_work_id);
        const remixTitle = title(remix.remix_work_id);
        const recordingIds = Array.isArray(remix.recording_ids)
            ? remix.recording_ids
            : [];
        const remixer = typeof remix.remixer_candidate === "string"
            ? remix.remixer_candidate
            : null;
        if (group.match_kind === "remix_recording") {
            return buildDecision({
                question: `${remixTitle} looks like a remix of ${original} with no new writers — should its recording move under ${original} as a remix, instead of being a separate song?`,
                options: [
                    {
                        label: "move the recording under the original",
                        consequence: `pica_recordings_update {id, work_id: <${original} id>, version_type: "remix"} for each of its ${recordingIds.length} recording(s)` +
                            (remixer
                                ? `; credit ${remixer} on the recording as Remixer if they are the remixer (not as a writer)`
                                : "; credit the remixer on the recording as Remixer, not as a writer") +
                            `; then the empty remix entry can be deleted. The original's writers and shares are untouched`,
                    },
                    {
                        label: "it added writers — keep it as its own song",
                        consequence: `add its writers, then pica_works_update {id: <remix id>, original_work_id: <${original} id>} to link it as a modified version`,
                    },
                    {
                        label: "leave both as they are",
                        consequence: "nothing changes",
                    },
                ],
                safe_default: "leave both as they are",
            });
        }
        const added = Array.isArray(remix.added_writers)
            ? remix.added_writers
                .map((w) => (typeof w.name === "string" ? w.name : null))
                .filter(Boolean)
                .join(", ")
            : "";
        return buildDecision({
            question: `${remixTitle} has ${added ? `writers ${original} does not (${added})` : `its own registered song code`} — it is a modified version of ${original}. Should PICA record that link?`,
            options: [
                {
                    label: "link it as a modified version",
                    consequence: `pica_works_update {id: <remix id>, original_work_id: <${original} id>} — both songs, their writers and shares stay as they are`,
                },
                {
                    label: "leave both as they are",
                    consequence: "nothing changes — it stays a separate song with no recorded link to the original",
                },
            ],
            safe_default: "leave both as they are",
        });
    }
    if (group.match_kind === "shared_upc_different_title") {
        // Not a duplicate question at all: one barcode, different titles — a
        // single carrying its album's UPC, most likely. Merging would fold a
        // 16-track album into a 1-track single (feedback 2026-09-05).
        const titles = group.items
            .map((i) => (typeof i.title === "string" ? `"${i.title}"` : null))
            .filter(Boolean)
            .join(" and ");
        return buildDecision({
            question: `${titles} share the barcode ${group.key} but have different titles — is one of them carrying the other's UPC by mistake?`,
            options: [
                {
                    label: "correct the UPC on the wrong release",
                    consequence: "pica_releases_update {id, upc} on the release that should not carry it — nothing is merged or deleted",
                },
                {
                    label: "leave both as they are",
                    consequence: markable
                        ? "nothing changes — they will keep appearing here until the barcodes differ, unless the user marks them as different (pica_duplicates_mark_different)"
                        : "nothing changes — they will keep appearing here until the barcodes differ",
                },
            ],
            safe_default: "leave both as they are",
        });
    }
    const question = isFuzzy
        ? `${n} entries in your catalog look very similar to "${name}" (${similarity} title match) — could they be the same song entered more than once?`
        : isTitleOnly
            ? `${n} entries called "${name}" carry no identifier (no ISRC or UPC) to tell them apart — are they the same one entered more than once?`
            : isSameSong
                ? `${n} recordings of the same song look like "${name}" entered more than once (the same Spotify track or the same title, and not all of them have an ISRC) — are they the same recording?`
                : `it seems like "${name}" appears ${n} times in your catalog — would it be wrong to fold the copies into one entry?`;
    return buildDecision({
        question,
        options: [
            {
                label: "fold the copies into one",
                consequence: `the most-connected copy stays; credits, recordings and agreements from ${others} move across before deletion — one entry, every history kept`,
            },
            {
                label: "keep them separate",
                // What makes a dismissal stick differs by entity: work pairs are
                // dismissed in the catalog duplicates view (work_duplicate_exclusions;
                // fuzzy_title is works-only), recording and release pairs by
                // pica_duplicates_mark_different (one-song A2), and people pairs not
                // at all — so each says only what is true for it.
                consequence: markable
                    ? "nothing changes — if they are genuinely different this is the right call; mark them as different (pica_duplicates_mark_different with these ids) so they stop appearing in duplicate checks"
                    : isFuzzy
                        ? "nothing changes — if they are genuinely different this is the right call; the user can dismiss the pair as not-duplicates from the catalog duplicates view so it stops appearing in checks"
                        : "nothing changes — they stay as separate entries and will keep appearing in duplicate checks",
            },
        ],
        safe_default: "keep them separate",
    });
}
/**
 * Summarise an item's linked-record counts (linked_credits / linked_recordings
 * / linked_agreements, etc.) into a total + human parts — the inputs the
 * service already uses to pick suggested_winner_id.
 */
function summariseLinks(item) {
    const parts = [];
    let total = 0;
    for (const [k, v] of Object.entries(item)) {
        if (k.startsWith("linked_") && typeof v === "number" && v > 0) {
            total += v;
            parts.push(`${v} ${k.replace("linked_", "").replace(/_/g, " ")}`);
        }
    }
    return { total, parts };
}
/**
 * Surface the merge recommendation already implied by the service's
 * `suggested_winner_id` as an explicit, reviewable read model: a named
 * `recommended_canonical` (id + one-line reason) plus a per-item `rationale`,
 * so an agent can present "keep X, fold Y/Z" for human sign-off without
 * reconstructing the heuristic. Additive presentation only — no data change.
 * (Founder feedback a03bfd1b — duplicate-cluster review object.)
 */
export function buildCanonicalRecommendation(group) {
    const winnerId = typeof group.suggested_winner_id === "string"
        ? group.suggested_winner_id
        : null;
    // ADR-324 — a remix pair has an original, not a canonical copy, and
    // nothing in it "folds": say what each side is instead.
    if (group.match_kind === "remix_recording" ||
        group.match_kind === "remix_modified_version") {
        const moving = group.match_kind === "remix_recording";
        return {
            items: group.items.map((it) => ({
                ...it,
                rationale: it.id === winnerId
                    ? "the original song — stays as it is"
                    : moving
                        ? "a remix: its recordings move under the original as version_type remix; nothing merges into the original's writers"
                        : "a modified version: stays its own song, linked to the original",
            })),
            recommended_canonical: winnerId
                ? { id: winnerId, reason: "the original song the remix is of" }
                : null,
        };
    }
    const items = group.items.map((it) => {
        const { parts } = summariseLinks(it);
        const isWinner = winnerId != null && it.id === winnerId;
        const rationale = isWinner
            ? `recommended canonical${parts.length ? ` — most linked data (${parts.join(", ")})` : " — most complete copy"}`
            : parts.length
                ? `would fold into the canonical; brings ${parts.join(", ")}`
                : "would fold into the canonical (no linked records)";
        return { ...it, rationale };
    });
    const winner = group.items.find((it) => it.id === winnerId);
    const winnerParts = winner ? summariseLinks(winner).parts : [];
    const recommended_canonical = winnerId
        ? {
            id: winnerId,
            reason: winnerParts.length
                ? `most linked data: ${winnerParts.join(", ")}`
                : "earliest / most complete copy",
        }
        : null;
    return { items, recommended_canonical };
}
export class DuplicatesTools {
    pica;
    constructor(pica) {
        this.pica = pica;
    }
    getTools() {
        return [
            {
                definition: {
                    name: "pica_find_duplicates",
                    scope: "read:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "read",
                    description: "Detect duplicates in the catalog: works by exact title, shared ISWC/tunecode, OR base composition title (the version/edition marker stripped, so a re-issue like 'Song (… Edition 2012)' or 'Song - Live' groups with its canonical 'Song'); people by exact name; releases by normalized UPC (12-digit UPC-A and 13-digit EAN with a leading zero are treated as equal); recordings by normalized ISRC (case/dash insensitive; malformed codes never group), then — where at least one copy has no ISRC and no two carry different ISRCs — by title + artist (match_kind \"title_artist\") and, inside one song, by the same Spotify track or the same title whatever the artist (match_kind \"same_song\"); both are CANDIDATES to put to the user, never verdicts. Returns groups of duplicates with suggested winners. " +
                        'Works additionally get a FUZZY near-title pass (Levenshtein ≥85% on version-stripped titles ≥5 chars): groups marked match_kind:"fuzzy_title" with a similarity score are CANDIDATES, not verdicts — present them to the user before any merge; exact-key groups are marked match_kind:"exact". ' +
                        'A remix is NOT a duplicate of the song it remixes: a remix-titled work comes back paired with its original as match_kind "remix_recording" (no new writers — move its recordings under the original with version_type "remix", remixer credited on the recording; never merge, which would fold its writers into the original\'s shares) or "remix_modified_version" (it added writers — keep it and link it with pica_works_update original_work_id). The pair\'s `remix` block names the recordings, any added writers and the action. ' +
                        "audio_files finds the SAME FILE uploaded twice, grouped on exact byte size + filename (audio rows carry no content hash). Each item reports storage_bucket — a `withpica-*` bucket is storage PICA pays for. ⚠️ pica_merge_duplicates does NOT accept audio_files: the outcome is a person deleting a redundant file, never an automatic merge. " +
                        "→ then: pica_merge_duplicates (merge confirmed duplicates — works, people, releases, recordings only), or pica_duplicates_mark_different (when the user says a recording or release group is NOT one thing; pairs marked different are never offered again) " +
                        "Each group carries a decision block (question + options with consequences + safe default) ready to put to the user.",
                    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: {
                            entity_type: {
                                type: "string",
                                enum: [
                                    "works",
                                    "people",
                                    "releases",
                                    "recordings",
                                    "audio_files",
                                ],
                                description: "Type of entity to check for duplicates (releases are keyed by UPC, recordings by ISRC, audio_files by exact byte size + filename)",
                            },
                        },
                        required: ["entity_type"],
                    },
                },
                executor: this.findDuplicates.bind(this),
            },
            {
                definition: {
                    name: "pica_merge_duplicates",
                    // ADR-288 — write:catalog (the backing route's resource scope) so a
                    // catalog-scoped delegated agent can PROPOSE a merge; "admin" would
                    // refuse at INSUFFICIENT_SCOPE before the approval gate ever fired.
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "destructive",
                    // ADR-288 — merges delete every loser, so delegated-agent (OAuth/
                    // grant) callers cannot execute directly: they propose, the org owner
                    // approves out-of-band, PICA replays the frozen params via
                    // human-approval-executor. Session + first-party-key callers fall
                    // through to the normal destructive path unchanged.
                    approvalMode: "human",
                    description: "Merge duplicate works, people, recordings, or releases into a single winner. " +
                        "Moves all credits, recordings (for works), agreements, release-track links, and metadata to the winner; fills NULL fields on the winner from losers; deletes losers. " +
                        "Supported entity types: `works` / `work` (credits, recordings, agreements); `people` / `person` (credits, splits, parties); " +
                        "`recordings` / `recording` (keyed by ISRC — credits, splits, release-track links, streaming IDs gap-filled); " +
                        "`releases` / `release` (keyed by UPC — tracks rehomed, collaboration invites moved, artwork and label fields gap-filled). " +
                        "PROVENANCE BLOCK: a recording loser that has custody history (recording_custodians rows) blocks the merge with a MERGE_BLOCKED_PROVENANCE error — " +
                        "custody chains are append-only; transfer custody first (pica_recording_custody_assign) or keep both recordings. " +
                        "DISTINCT-RECORDINGS BLOCK: a works merge is refused with MERGE_BLOCKED_DISTINCT_RECORDINGS when the loser works link to recordings whose ISRCs differ from the winner's — these are distinct recordings of the same composition, not duplicates; keep the works separate or move recordings individually with pica_recordings_update. " +
                        "The same error with reason `different_artists` refuses works that share a title but carry different primary artists and no common ISWC or tunecode — different songs, not duplicates; keep them separate, or if one really is a cover give both the same ISWC first. pica_find_duplicates no longer groups such works on title. " +
                        "With reason `modified_version` it refuses merging a remix that added writers into its original — link it with pica_works_update original_work_id instead. " +
                        "ACCOUNT BLOCK: a people merge is refused with MERGE_ACCOUNT_CONFLICT when the people are linked to two different PICA accounts — they are two humans, not duplicates; keep them separate and do not retry. When only one account is linked, the link moves to the winner. " +
                        "CLAIMED BLOCK: a people merge is refused with MERGE_BLOCKED_CLAIMED when either person has claimed their profile — it belongs to them; keep the records separate and do not retry. " +
                        "For delegated agents (OAuth connectors / pica_grant_*) this tool does NOT execute directly: it creates a pending human approval and the org owner approves out-of-band — relay the returned message and poll pica_approval_status. " +
                        `BATCH: to merge several confirmed duplicate groups, send them together as \`merges\` (up to ${MAX_MERGE_BATCH_ITEMS} items of {entity_type, winner_id, loser_ids}) instead of one call per group — a delegated agent then raises ONE approval and the owner gets ONE email listing every pair, approved or denied as a whole. Every pair is checked before the email goes out (one bad pair refuses the list); no record may be merged away twice or be kept by one pair and merged away by another. Once approved, pica_approval_status returns a per-pair outcome (merged / skipped because already merged or deleted / failed with the reason); one failed pair does not stop the others. Send either \`merges\` or the single-merge fields, never both. ` +
                        "entity_type accepts both canonical plural (`works`, `people`, `releases`, `recordings`) and singular aliases for backwards compatibility.",
                    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: {
                            entity_type: {
                                type: "string",
                                enum: [
                                    "works",
                                    "people",
                                    "releases",
                                    "recordings",
                                    "work",
                                    "person",
                                    "recording",
                                    "release",
                                ],
                                description: "Type of entity to merge. Canonical form is plural (`works` / `people` / `releases` / `recordings`); singular aliases accepted for backwards compatibility.",
                            },
                            winner_id: {
                                type: "string",
                                description: "UUID of the entity to keep (all data will be merged into this one) — take ids " +
                                    "from a pica_find_duplicates group, never guess from names.",
                            },
                            loser_ids: {
                                type: "array",
                                items: { type: "string" },
                                description: "UUIDs of duplicate entities to merge into the winner and then soft-delete — " +
                                    "take ids from the same pica_find_duplicates group as winner_id.",
                            },
                            merges: {
                                type: "array",
                                maxItems: MAX_MERGE_BATCH_ITEMS,
                                description: `Batch form: up to ${MAX_MERGE_BATCH_ITEMS} merges, each {entity_type, winner_id, loser_ids} exactly as for a single merge. ` +
                                    "Use INSTEAD of the top-level entity_type/winner_id/loser_ids when merging several duplicate groups — a delegated agent raises one approval for the whole list.",
                                items: {
                                    type: "object",
                                    properties: {
                                        entity_type: {
                                            type: "string",
                                            enum: [
                                                "works",
                                                "people",
                                                "releases",
                                                "recordings",
                                                "work",
                                                "person",
                                                "recording",
                                                "release",
                                            ],
                                        },
                                        winner_id: { type: "string" },
                                        loser_ids: { type: "array", items: { type: "string" } },
                                    },
                                    required: ["entity_type", "winner_id", "loser_ids"],
                                },
                            },
                        },
                        // Either the single-merge fields or `merges` — enforced by the
                        // executor and the approval gate, since one JSON-schema
                        // `required` list cannot say "one of two shapes".
                    },
                },
                executor: this.mergeDuplicates.bind(this),
            },
            {
                definition: {
                    name: "pica_duplicates_mark_different",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Record the user's answer that recordings or releases pica_find_duplicates grouped are NOT the same — \"they're different\" — so they stop being offered as duplicates. " +
                        'action "mark" marks every pair among `ids` (2–25 recordings, or 2–25 releases, all in this catalogue); action "unmark" takes that back so they may be offered again. ' +
                        "Nothing is merged, deleted or edited; marking an already-marked pair, or unmarking one never marked, is not an error. " +
                        "Only call this after the user has said the items are different — never as a way to tidy the duplicate list. Take ids from one pica_find_duplicates group, never guess. " +
                        "Works are dismissed in the catalog duplicates view, not here; people cannot be dismissed. " +
                        "Returns {entity_type, ids, pairs, changed}: `changed` counts the pairs this call actually marked or unmarked.",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist duplicate-review follow-up; invoked by exact name after the user answers a pica_find_duplicates decision.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            action: {
                                type: "string",
                                enum: ["mark", "unmark"],
                                description: "mark: they're different, stop offering them. unmark: take that back.",
                            },
                            entity_type: {
                                type: "string",
                                enum: [...MARK_DIFFERENT_ENTITY_TYPES],
                                description: "recordings or releases",
                            },
                            ids: {
                                type: "array",
                                items: { type: "string" },
                                minItems: 2,
                                maxItems: MAX_MARK_DIFFERENT_IDS,
                                description: "2–25 ids of the same entity type, from one pica_find_duplicates group; every pair among them is marked or unmarked.",
                            },
                        },
                        required: ["action", "entity_type", "ids"],
                    },
                },
                executor: this.markDifferent.bind(this),
            },
        ];
    }
    async findDuplicates(args) {
        const result = await this.pica.duplicates.findDuplicates(coerceFindEntityType(args.entity_type));
        if (Array.isArray(result?.groups)) {
            result.groups = result.groups.map((g) => {
                if (!Array.isArray(g.items))
                    return g;
                try {
                    // Reviewable canonical recommendation + per-item rationale, derived
                    // from the service's suggested_winner_id + link counts (additive).
                    const { items: ratedItems, recommended_canonical } = buildCanonicalRecommendation(g);
                    return {
                        ...g,
                        items: ratedItems,
                        recommended_canonical,
                        // ADR-267 Rule 3 — ready-to-ask choice; additive, agent re-voices.
                        decision: buildDuplicateGroupDecision(g, typeof result.entity_type === "string"
                            ? result.entity_type
                            : undefined),
                    };
                }
                catch {
                    // a UUID-shaped title (import artefact) fails the no-identifiers
                    // floor — ship the group without a decision rather than failing
                    // the whole call. The data is untouched either way.
                    return g;
                }
            });
        }
        return formatAsText(result);
    }
    async markDifferent(args) {
        const fields = {};
        if (args.action !== "mark" && args.action !== "unmark") {
            fields.action = 'must be "mark" or "unmark"';
        }
        if (!canMarkDifferent(args.entity_type)) {
            fields.entity_type =
                'must be "recordings" or "releases" (works are dismissed in the catalog duplicates view; people cannot be)';
        }
        const ids = args.ids;
        if (!Array.isArray(ids) ||
            ids.length < 2 ||
            ids.length > MAX_MARK_DIFFERENT_IDS ||
            ids.some((id) => typeof id !== "string")) {
            fields.ids = `must be 2–${MAX_MARK_DIFFERENT_IDS} ids from one pica_find_duplicates group`;
        }
        if (Object.keys(fields).length > 0) {
            return { ...formatValidationError(fields), isError: true };
        }
        const entityType = args.entity_type;
        const result = args.action === "mark"
            ? await this.pica.duplicates.markDifferent(entityType, ids)
            : await this.pica.duplicates.unmarkDifferent(entityType, ids);
        const changed = typeof result?.changed === "number" ? result.changed : undefined;
        return formatSuccess(args.action === "mark"
            ? `Marked ${ids.length} ${entityType} as different${changed !== undefined ? ` (${changed} pair${changed === 1 ? "" : "s"} newly marked)` : ""} — they will not be offered as duplicates again`
            : `Took back "different" for ${ids.length} ${entityType}${changed !== undefined ? ` (${changed} pair${changed === 1 ? "" : "s"} unmarked)` : ""} — they may be offered as duplicates again`, result);
    }
    async mergeDuplicates(args) {
        if (args.merges !== undefined)
            return this.mergeBatch(args);
        return this.mergeOne(args);
    }
    /**
     * The batch form for callers that execute directly (session / first-party
     * key — delegated agents never reach here: the HTTP gate freezes the list
     * into one approval). Runs each merge in turn through the single path; a
     * failure is recorded and the rest still run, and the result names every
     * pair's outcome so nothing reads as a blanket success.
     */
    async mergeBatch(args) {
        const invalid = (fields) => ({
            ...formatValidationError(fields),
            isError: true,
        });
        for (const single of ["entity_type", "winner_id", "loser_ids"]) {
            if (args[single] !== undefined) {
                return invalid({
                    merges: `send either merges or entity_type/winner_id/loser_ids, not both (found ${single})`,
                });
            }
        }
        const merges = args.merges;
        if (!Array.isArray(merges) || merges.length === 0) {
            return invalid({
                merges: "must be a non-empty array of {entity_type, winner_id, loser_ids}",
            });
        }
        if (merges.length > MAX_MERGE_BATCH_ITEMS) {
            return invalid({
                merges: `at most ${MAX_MERGE_BATCH_ITEMS} merges per call (received ${merges.length}) — split the list`,
            });
        }
        // Same overlap rule the approval gate enforces (validateMergeBatchParams
        // in lib/services/human-approval-executor.ts): no record merged away
        // twice, or kept by one pair and merged away by another — the outcome
        // would depend on order.
        const kept = new Map();
        const mergedAway = new Map();
        const typeKey = (raw) => {
            try {
                return coerceMergeEntityType(raw?.entity_type).sdk;
            }
            catch {
                return String(raw?.entity_type);
            }
        };
        merges.forEach((raw, i) => {
            if (typeof raw?.winner_id === "string") {
                kept.set(`${typeKey(raw)}:${raw.winner_id}`, i);
            }
        });
        for (const [i, raw] of merges.entries()) {
            for (const id of Array.isArray(raw?.loser_ids) ? raw.loser_ids : []) {
                const key = `${typeKey(raw)}:${id}`;
                if (mergedAway.has(key) || kept.has(key)) {
                    return invalid({
                        merges: `merges[${i}]: ${id} is merged away more than once, or kept by another merge — combine those merges into one`,
                    });
                }
                mergedAway.set(key, i);
            }
        }
        const items = [];
        for (const [index, raw] of merges.entries()) {
            const base = {
                index,
                entity_type: raw?.entity_type,
                winner_id: raw?.winner_id,
            };
            if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
                items.push({ ...base, outcome: "failed", reason: "not an object" });
                continue;
            }
            try {
                const result = await this.mergeOne(raw);
                const first = result.content?.[0];
                const text = first && "text" in first && typeof first.text === "string"
                    ? first.text
                    : "";
                // formatValidationError carries no isError flag, so a refusal is
                // also recognised by the `error` field on its JSON body.
                let parsed = null;
                try {
                    parsed = JSON.parse(text);
                }
                catch {
                    // plain-text body (a success summary) — not a refusal by shape
                }
                if (result.isError || (parsed && typeof parsed.error === "string")) {
                    const fields = parsed?.fields && typeof parsed.fields === "object"
                        ? ` (${Object.entries(parsed.fields)
                            .map(([k, v]) => `${k}: ${String(v)}`)
                            .join("; ")})`
                        : "";
                    const reason = parsed
                        ? `${String(parsed.message ?? parsed.error)}${fields}`
                        : text;
                    items.push({ ...base, outcome: "failed", reason });
                }
                else {
                    items.push({ ...base, outcome: "merged" });
                }
            }
            catch (err) {
                items.push({
                    ...base,
                    outcome: "failed",
                    reason: err instanceof Error ? err.message : String(err),
                });
            }
        }
        const merged = items.filter((i) => i.outcome === "merged").length;
        const failed = items.length - merged;
        const payload = {
            summary: { total: items.length, merged, skipped: 0, failed },
            items,
            ...(failed > 0
                ? {
                    retry_safe: false,
                    suggestion: "only the failed merges need attention — the merged ones are done; do not resend them",
                }
                : {}),
        };
        if (failed > 0) {
            return {
                content: [
                    {
                        type: "text",
                        text: JSON.stringify({
                            error: "MERGE_BATCH_PARTIAL",
                            message: `${merged} of ${items.length} merges ran; ${failed} failed`,
                            ...payload,
                        }, null, 2),
                    },
                ],
                isError: true,
            };
        }
        return formatSuccess(`Merged all ${items.length} duplicate groups`, payload);
    }
    async mergeOne(args) {
        if (!args.loser_ids?.length) {
            return formatValidationError({ loser_ids: "must be a non-empty array" });
        }
        const loserIds = args.loser_ids;
        if (loserIds && loserIds.length > 25) {
            return {
                content: [
                    {
                        type: "text",
                        text: JSON.stringify({
                            error: "VALIDATION_ERROR",
                            message: `maximum 25 loser IDs per merge — received ${loserIds.length}`,
                            retry_safe: false,
                            suggestion: "split into multiple merge operations with 25 or fewer IDs each",
                        }, null, 2),
                    },
                ],
                isError: true,
            };
        }
        const { canonical, sdk } = coerceMergeEntityType(args.entity_type);
        try {
            const result = await this.pica.duplicates.merge(sdk, args.winner_id, args.loser_ids);
            return formatSuccess(`Merged ${args.loser_ids.length} duplicate ${canonical} into ${args.winner_id}`, result);
        }
        catch (err) {
            // ADR-262 / ADR-264 WS1.3: the route returns 409 when a loser recording
            // carries custody history. Extract the structured error from the SDK's
            // ApiError body and surface the remedy so the caller knows the safe path.
            if (err instanceof ApiError && err.status === 409) {
                const bodyText = err.message.replace(/^API request failed: 409 /, "");
                let structured = {};
                try {
                    structured = JSON.parse(bodyText);
                }
                catch {
                    // non-JSON 409; fall through to generic representation
                }
                // 20260928090000: people linked to two different PICA accounts are two
                // humans; merge_person_atomic refuses before moving anything.
                // 20261007121500: a person who has claimed their profile is never
                // merged.
                if (structured.error === "MERGE_ACCOUNT_CONFLICT" ||
                    structured.error === "MERGE_BLOCKED_CLAIMED") {
                    const claimed = structured.error === "MERGE_BLOCKED_CLAIMED";
                    return {
                        content: [
                            {
                                type: "text",
                                text: JSON.stringify({
                                    error: structured.error,
                                    code: structured.code ?? structured.error,
                                    remedy: structured.remedy ??
                                        (claimed
                                            ? "one of these people has claimed their profile, so it belongs to them — keep the records separate; if they really are one person, ask them"
                                            : "these people are linked to different PICA accounts, so they are different people — keep them separate"),
                                    message: structured.message ?? err.message,
                                    retry_safe: false,
                                }, null, 2),
                            },
                        ],
                        isError: true,
                    };
                }
                if (structured.error === "MERGE_BLOCKED_PROVENANCE") {
                    return {
                        content: [
                            {
                                type: "text",
                                text: JSON.stringify({
                                    error: "MERGE_BLOCKED_PROVENANCE",
                                    code: structured.code ?? "MERGE_BLOCKED_PROVENANCE",
                                    blocked_ids: structured.blocked_ids ?? [],
                                    remedy: structured.remedy ??
                                        "custody chains are append-only; transfer custody first or keep both recordings",
                                    suggestion: "use pica_recording_custody_assign to transfer custody from the blocked recording(s) before merging, or keep both recordings",
                                    next_tool: "pica_recording_custody_assign",
                                    message: structured.message ?? err.message,
                                    retry_safe: false,
                                    decision: buildDecision({
                                        question: "one of these recordings carries a custody history that can't be merged away — would it be wrong to keep both instead?",
                                        options: [
                                            {
                                                label: "keep both recordings",
                                                consequence: "nothing is deleted; both stay in your catalog with their histories intact",
                                            },
                                            {
                                                label: "move the custody record first",
                                                consequence: "transfer custody to the recording you're keeping, then run the merge again",
                                            },
                                        ],
                                        safe_default: "keep both recordings",
                                    }),
                                }, null, 2),
                            },
                        ],
                        isError: true,
                    };
                }
                // ops_issue 496ce024: a works merge whose loser recordings carry ISRCs
                // disjoint from the winner's is blocked — distinct recordings of the
                // same composition, not duplicates. Surface the remedy so the agent
                // keeps the works separate or moves recordings individually. The
                // different-artists case is explained separately below.
                if (structured.error === "MERGE_BLOCKED_DISTINCT_RECORDINGS") {
                    // ADR-324: the same code refuses merging a modified version (a
                    // remix with writers of its own) into its original.
                    if (structured.reason === "modified_version") {
                        return {
                            content: [
                                {
                                    type: "text",
                                    text: JSON.stringify({
                                        error: "MERGE_BLOCKED_DISTINCT_RECORDINGS",
                                        code: structured.code ?? "MERGE_BLOCKED_DISTINCT_RECORDINGS",
                                        blocked_ids: structured.blocked_ids ?? [],
                                        reason: "modified_version",
                                        remedy: structured.remedy,
                                        suggestion: "this remix added writers, so it is a modified version of the original, not a copy — keep both and link it with pica_works_update {id: <remix id>, original_work_id: <original id>}",
                                        next_tool: "pica_works_update",
                                        message: structured.message ?? err.message,
                                        retry_safe: false,
                                    }, null, 2),
                                },
                            ],
                            isError: true,
                        };
                    }
                    // ops 7a29ce1e: the same code also refuses different primary
                    // artists with no shared ISWC/tunecode (reason "different_artists").
                    const differentArtists = structured.reason === "different_artists";
                    return {
                        content: [
                            {
                                type: "text",
                                text: JSON.stringify({
                                    error: "MERGE_BLOCKED_DISTINCT_RECORDINGS",
                                    code: structured.code ?? "MERGE_BLOCKED_DISTINCT_RECORDINGS",
                                    blocked_ids: structured.blocked_ids ?? [],
                                    reason: structured.reason ?? "distinct_isrcs",
                                    remedy: structured.remedy ??
                                        (differentArtists
                                            ? "these works share a title but carry different primary artists and no common ISWC or tunecode, so they are different songs; keep the works separate, or move recordings individually with pica_recordings_update. If one really is a cover of the other, give both the same ISWC first"
                                            : "these works link to recordings with different ISRCs (distinct recordings of the same composition); keep the works separate or move recordings individually with pica_recordings_update"),
                                    suggestion: differentArtists
                                        ? "these works are different songs by different artists that happen to share a title — keep them separate; only if one is genuinely a cover of the other, set the same ISWC on both (pica_works_update) and merge again"
                                        : "these works are distinct recordings of the same composition (their recordings carry different ISRCs) — keep the works separate, or move recordings one-by-one with pica_recordings_update instead of merging",
                                    next_tool: differentArtists
                                        ? "pica_works_update"
                                        : "pica_recordings_update",
                                    message: structured.message ?? err.message,
                                    retry_safe: false,
                                    decision: differentArtists
                                        ? buildDecision({
                                            question: "these works share a title but have different primary artists and no common ISWC or tunecode — they look like different songs; would merging them be wrong?",
                                            options: [
                                                {
                                                    label: "keep the works separate",
                                                    consequence: "nothing is deleted; each artist keeps their own song, recordings and credits",
                                                },
                                                {
                                                    label: "record them as the same composition",
                                                    consequence: "only if one is a cover of the other: set the same ISWC on both with pica_works_update, then merge again",
                                                },
                                            ],
                                            safe_default: "keep the works separate",
                                        })
                                        : buildDecision({
                                            question: "these works link to recordings with different ISRCs — they look like distinct recordings of the same composition, not duplicates; would merging them be wrong?",
                                            options: [
                                                {
                                                    label: "keep the works separate",
                                                    consequence: "nothing is deleted; each work keeps its own recording(s) and ISRCs",
                                                },
                                                {
                                                    label: "move a recording individually",
                                                    consequence: "repoint a specific recording with pica_recordings_update if it truly belongs to the other work, then re-evaluate",
                                                },
                                            ],
                                            safe_default: "keep the works separate",
                                        }),
                                }, null, 2),
                            },
                        ],
                        isError: true,
                    };
                }
            }
            throw err;
        }
    }
}
//# sourceMappingURL=duplicates.js.map