UNPKG

@withpica/mcp-server

Version:

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

750 lines • 44.9 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; /** * 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; } /** ADR-267 Rule 3 — the duplicate-resolution choice, ready to put to the user. */ export function buildDuplicateGroupDecision(group) { 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 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: "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?` : `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", consequence: isFuzzy || isTitleOnly ? "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). 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) " + "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. " + "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), }, ]; } 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), }; } 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 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. if (structured.error === "MERGE_ACCOUNT_CONFLICT") { return { content: [ { type: "text", text: JSON.stringify({ error: "MERGE_ACCOUNT_CONFLICT", code: structured.code ?? "MERGE_ACCOUNT_CONFLICT", remedy: structured.remedy ?? "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