@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
856 lines • 52.7 kB
JavaScript
// 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