@lingjingai/scriptctl 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1236,8 +1236,18 @@ function setContextState(script, epId, sceneId, kind, targetId, stateId) {
1236
1236
  const normalizedState = validateStateForTarget(script, kind, targetId, stateId);
1237
1237
  const scene = findScene(script, epId, sceneId);
1238
1238
  const ctx = sceneContext(scene);
1239
- const refs = [...contextRefsForKind(ctx, kind)];
1240
1239
  const idKey = idKeyForKind(kind);
1240
+ // location is single-valued per scene by direct-init invariant; the legacy
1241
+ // set_scene_location_ref op replaced the whole locations[] array to preserve
1242
+ // that. Mirror it here: for location kind, context.set REPLACES (one entry
1243
+ // total). actor/prop stay upsert because multi-actor / multi-prop per scene
1244
+ // is intentional.
1245
+ if (kind === "location") {
1246
+ ctx[pluralForKind(kind)] = [{ [idKey]: targetId, state_id: normalizedState }];
1247
+ setSceneContext(scene, ctx);
1248
+ return;
1249
+ }
1250
+ const refs = [...contextRefsForKind(ctx, kind)];
1241
1251
  const existing = refs.find((ref) => strOf(ref[idKey]) === targetId);
1242
1252
  if (existing) {
1243
1253
  existing["state_id"] = normalizedState;
@@ -1304,6 +1314,167 @@ export function collectStateRefs(script, kind, targetId, stateId) {
1304
1314
  }
1305
1315
  return refs;
1306
1316
  }
1317
+ // Try each format in order. The order matters: action (has `#`) is checked
1318
+ // before scene (just `/`), and asset+state (has `:`) is checked before scene.
1319
+ // Episode and speaker are bare ids (no separators) distinguished by prefix.
1320
+ export function parseAnyAddress(raw) {
1321
+ const value = strOf(raw).trim();
1322
+ if (!value) {
1323
+ throw new CliError("ADDRESS BLOCKED: Address empty", "Address empty.", {
1324
+ exitCode: EXIT_USAGE,
1325
+ required: ["one of the supported address formats"],
1326
+ received: ["<empty>"],
1327
+ nextSteps: ["Pass an address like ep_001/scn_001#3, ep_001/scn_001, actor:act_001, actor:act_001/st_001, ep_001, or spk_001."],
1328
+ errorCode: "ADDRESS_EMPTY",
1329
+ });
1330
+ }
1331
+ if (value.includes("#")) {
1332
+ const [episodeId, sceneId, actionIndex] = parseActionRef(value);
1333
+ return { kind: "action", episodeId, sceneId, actionIndex };
1334
+ }
1335
+ if (value.includes(":")) {
1336
+ if (value.slice(value.indexOf(":") + 1).includes("/")) {
1337
+ const [assetKind, assetId, stateId] = parseStateTarget(value);
1338
+ return { kind: "state", assetKind, assetId, stateId };
1339
+ }
1340
+ const [assetKind, assetId] = parseAssetTarget(value);
1341
+ return { kind: "asset", assetKind, assetId };
1342
+ }
1343
+ if (value.includes("/")) {
1344
+ const [episodeId, sceneId] = parseSceneRef(value);
1345
+ return { kind: "scene", episodeId, sceneId };
1346
+ }
1347
+ if (/^ep_/.test(value)) {
1348
+ return { kind: "episode", episodeId: value };
1349
+ }
1350
+ if (/^spk_/.test(value)) {
1351
+ return { kind: "speaker", speakerId: value };
1352
+ }
1353
+ throw new CliError("ADDRESS BLOCKED: Address invalid", "Address invalid.", {
1354
+ exitCode: EXIT_USAGE,
1355
+ required: [
1356
+ "address: ep_NNN/scn_NNN#idx (action) | ep_NNN/scn_NNN (scene) | actor|location|prop:id (asset) | actor|location|prop:id/state_id (state) | ep_NNN (episode) | spk_XXX (speaker)",
1357
+ ],
1358
+ received: [value],
1359
+ nextSteps: ["Use a recognized address format."],
1360
+ errorCode: "ADDRESS_INVALID",
1361
+ });
1362
+ }
1363
+ // Reverse lookup for asset / speaker references. Parallel to collectStateRefs
1364
+ // but tracks where an actor/location/prop/speaker is mentioned rather than
1365
+ // where a specific state is mentioned. Returns one `{target, location, role}`
1366
+ // entry per reference site. Walks the same edges the validator does
1367
+ // (script-core.ts validateScript) so coverage is complete:
1368
+ // - scene.context.{actors|locations|props}[]
1369
+ // - action.actor_id (actor only)
1370
+ // - action.speaker_id + action.speakers[] + action.lines[].speaker_id (speaker only)
1371
+ // - action.state_changes[].target_{kind,id}
1372
+ // - action.transition_prompt.target_{kind,id}
1373
+ // - script.speakers[].source_{kind,id} (for actor/location/prop reverse)
1374
+ export function collectAssetRefs(script, kind, targetId) {
1375
+ const refs = [];
1376
+ const isSpeaker = kind === "speaker";
1377
+ const target = isSpeaker ? `speaker:${targetId}` : `${kind}:${targetId}`;
1378
+ const idKey = isSpeaker ? null : idKeyForKind(kind);
1379
+ for (const ep of asList(script["episodes"])) {
1380
+ const epId = strOf(ep["episode_id"]);
1381
+ for (const scene of asList(ep["scenes"])) {
1382
+ const sceneId = strOf(scene["scene_id"]);
1383
+ if (!isSpeaker) {
1384
+ const ctx = sceneContext(scene);
1385
+ const refsList = contextRefsForKind(ctx, kind);
1386
+ for (let i = 0; i < refsList.length; i++) {
1387
+ if (strOf(refsList[i][idKey]) === targetId) {
1388
+ refs.push({
1389
+ target,
1390
+ location: `${epId}/${sceneId}.context.${pluralForKind(kind)}[${i}]`,
1391
+ role: `scene_${pluralForKind(kind)}`,
1392
+ });
1393
+ }
1394
+ }
1395
+ }
1396
+ const actions = asList(scene["actions"]);
1397
+ for (let actionIdx = 0; actionIdx < actions.length; actionIdx++) {
1398
+ const action = actions[actionIdx];
1399
+ if (kind === "actor" && strOf(action["actor_id"]) === targetId) {
1400
+ refs.push({
1401
+ target,
1402
+ location: `${epId}/${sceneId}#${actionIdx}.actor_id`,
1403
+ role: "action_actor",
1404
+ });
1405
+ }
1406
+ if (isSpeaker) {
1407
+ if (strOf(action["speaker_id"]) === targetId) {
1408
+ refs.push({
1409
+ target,
1410
+ location: `${epId}/${sceneId}#${actionIdx}.speaker_id`,
1411
+ role: "action_speaker",
1412
+ });
1413
+ }
1414
+ const speakers = asList(action["speakers"]);
1415
+ for (let i = 0; i < speakers.length; i++) {
1416
+ if (strOf(speakers[i]["speaker_id"]) === targetId) {
1417
+ refs.push({
1418
+ target,
1419
+ location: `${epId}/${sceneId}#${actionIdx}.speakers[${i}]`,
1420
+ role: "dialogue_speaker",
1421
+ });
1422
+ }
1423
+ }
1424
+ const lines = asList(action["lines"]);
1425
+ for (let i = 0; i < lines.length; i++) {
1426
+ if (strOf(lines[i]["speaker_id"]) === targetId) {
1427
+ refs.push({
1428
+ target,
1429
+ location: `${epId}/${sceneId}#${actionIdx}.lines[${i}].speaker_id`,
1430
+ role: "overlap_line_speaker",
1431
+ });
1432
+ }
1433
+ }
1434
+ }
1435
+ if (!isSpeaker) {
1436
+ const changes = asList(action["state_changes"]);
1437
+ for (let i = 0; i < changes.length; i++) {
1438
+ const change = changes[i];
1439
+ if (strOf(change["target_kind"]) === kind && strOf(change["target_id"]) === targetId) {
1440
+ refs.push({
1441
+ target,
1442
+ location: `${epId}/${sceneId}#${actionIdx}.state_changes[${i}]`,
1443
+ role: "state_change_target",
1444
+ });
1445
+ }
1446
+ }
1447
+ const transition = action["transition_prompt"];
1448
+ if (isDict(transition) && strOf(transition["target_kind"]) === kind && strOf(transition["target_id"]) === targetId) {
1449
+ refs.push({
1450
+ target,
1451
+ location: `${epId}/${sceneId}#${actionIdx}.transition_prompt`,
1452
+ role: "transition_target",
1453
+ });
1454
+ }
1455
+ }
1456
+ }
1457
+ }
1458
+ }
1459
+ // Speaker registrations pointing at this asset (actor/location/prop only).
1460
+ // A speaker.source_id ties an actor/location/prop to a speaker entity; the
1461
+ // speaker entry itself counts as a reference, so deleting / merging the
1462
+ // asset must surface it.
1463
+ if (!isSpeaker) {
1464
+ const speakers = asList(script["speakers"]);
1465
+ for (let i = 0; i < speakers.length; i++) {
1466
+ const s = speakers[i];
1467
+ if (strOf(s["source_kind"]) === kind && strOf(s["source_id"]) === targetId) {
1468
+ refs.push({
1469
+ target,
1470
+ location: `speakers[${i}]`,
1471
+ role: "speaker_source",
1472
+ });
1473
+ }
1474
+ }
1475
+ }
1476
+ return refs;
1477
+ }
1307
1478
  function nextSceneId(script) {
1308
1479
  let maxSeen = 0;
1309
1480
  for (const ep of asList(script["episodes"])) {
@@ -1344,9 +1515,53 @@ function opErr(title, message, opts = {}) {
1344
1515
  nextSteps: opts.nextSteps,
1345
1516
  op: opts.op,
1346
1517
  errorCode: opts.errorCode,
1347
- hint: opts.hint,
1348
1518
  });
1349
1519
  }
1520
+ export const PATCH_OP_SCHEMA = {
1521
+ // meta
1522
+ "meta.worldview.set": { required: ["worldview"], optional: ["worldview_raw"], description: "Set the script.worldview enum." },
1523
+ // state
1524
+ "state.add": { required: ["target", "name"], optional: ["description", "state_id"], description: "Add a state to an asset's states[]." },
1525
+ "state.rename": { required: ["target", "name"], optional: [], description: "Rename an asset state." },
1526
+ "state.describe": { required: ["target", "description"], optional: [], description: "Set a state's description." },
1527
+ "state.delete": { required: ["target", "strategy"], optional: ["replacement"], description: "Delete a state. strategy: replace|remove." },
1528
+ // asset
1529
+ "asset.rename": { required: ["target", "name"], optional: [], description: "Rename an asset (actor / location / prop)." },
1530
+ "asset.describe": { required: ["target", "description"], optional: [], description: "Set asset description." },
1531
+ "asset.alias.set": { required: ["target", "aliases"], optional: [], description: "Bulk replace aliases[]." },
1532
+ "asset.alias.add": { required: ["target", "alias"], optional: [], description: "Append alias(es)." },
1533
+ "asset.alias.remove": { required: ["target", "alias"], optional: [], description: "Remove alias(es)." },
1534
+ "asset.states.set": { required: ["target", "states"], optional: ["asset_type"], description: "Bulk replace states[]." },
1535
+ "asset.role.set": { required: ["target", "role_type"], optional: [], description: "Set actor role_type (主角|配角)." },
1536
+ "asset.merge": { required: ["from", "into"], optional: [], description: "Merge from asset into another; from disappears." },
1537
+ "asset.delete": { required: ["target"], optional: ["strategy", "replacement"], description: "Delete an asset. strategy: replace|remove (required if refs exist)." },
1538
+ // action
1539
+ "action.type.set": { required: ["at", "type"], optional: [], description: "Set action.type (dialogue|action|inner_thought)." },
1540
+ "action.actor.set": { required: ["at"], optional: ["actor_id"], description: "Set/clear action.actor_id." },
1541
+ "action.content.replace": { required: ["at", "from"], optional: ["to", "all"], description: "Literal substring replace in action.content." },
1542
+ "action.state.change": { required: ["at", "target", "to"], optional: ["from", "effective"], description: "Add/update a state_change on an action." },
1543
+ "action.state.remove": { required: ["at", "target"], optional: [], description: "Remove a state_change from an action." },
1544
+ "action.transition.set": { required: ["at", "target", "process", "contrast"], optional: [], description: "Set transition_prompt." },
1545
+ "action.transition.clear": { required: ["at", "target"], optional: [], description: "Clear transition_prompt." },
1546
+ "action.delete": { required: ["at"], optional: [], description: "Remove an action; later indices shift." },
1547
+ "action.insert": { required: ["at", "type"], optional: ["content", "at_index", "before", "after", "actor_id", "speaker_id"], description: "Insert an action into a scene." },
1548
+ "action.move": { required: ["at", "to"], optional: [], description: "Move an action (within or across scenes)." },
1549
+ // scene
1550
+ "scene.split": { required: ["at", "at_index"], optional: ["new_scene_id"], description: "Split a scene at an action boundary." },
1551
+ "scene.merge": { required: ["from", "into"], optional: [], description: "Merge two scenes within the same episode." },
1552
+ "scene.delete": { required: ["at"], optional: ["force"], description: "Delete a scene; empty by default, force=true for non-empty." },
1553
+ "scene.move": { required: ["at", "to"], optional: ["at_index"], description: "Move a scene to another episode." },
1554
+ "scene.insert": { required: ["at", "location"], optional: ["time", "space", "at_index", "before", "after", "scene_id"], description: "Insert a new empty scene." },
1555
+ // context
1556
+ "context.set": { required: ["at", "target"], optional: ["state", "state_id"], description: "Upsert (actor/prop) or replace (location) scene context ref + state." },
1557
+ "context.clear": { required: ["at", "target"], optional: [], description: "Keep ref, clear state_id." },
1558
+ "context.ref.remove": { required: ["at", "target"], optional: [], description: "Remove the ref entirely from scene context." },
1559
+ // dialogue / speaker
1560
+ "dialogue.speakers": { required: ["at", "speakers"], optional: ["delivery"], description: "Set dialogue speakers; delivery: single|simultaneous|group." },
1561
+ "dialogue.overlap": { required: ["at", "lines"], optional: [], description: "Set overlap dialogue lines[]." },
1562
+ "speaker.add": { required: ["kind", "name"], optional: ["source_id", "voice_desc", "speaker_id"], description: "Register a new speaker." },
1563
+ "speaker.delete": { required: ["target"], optional: ["strategy", "replacement"], description: "Delete a speaker. strategy: replace|remove (required if refs exist)." },
1564
+ };
1350
1565
  export function applyPatchOperations(script, sourceText, operations) {
1351
1566
  const applied = [];
1352
1567
  for (const op of operations) {
@@ -1485,7 +1700,7 @@ export function applyPatchOperations(script, sourceText, operations) {
1485
1700
  const fromStateExplicit = strOf(op["from"] || op["from_state_id"]).trim();
1486
1701
  const fromState = fromStateExplicit || inferStateBeforeAction(script, epId, sceneId, actionIndex, targetKind, targetId);
1487
1702
  if (!fromState) {
1488
- throw opErr("SCRIPT OP BLOCKED: From state ambiguous", "Unable to infer current state before this action.", { required: ["scene initial state or --from"], received: [`${op["at"]} ${op["target"]}`], nextSteps: ["Set scene context first, or pass --from explicitly."], op: kind, errorCode: "FROM_STATE_AMBIGUOUS", hint: "先设置场景初始状态,或修复前序状态变化。" });
1703
+ throw opErr("SCRIPT OP BLOCKED: From state ambiguous", "Unable to infer current state before this action.", { required: ["scene initial state or --from"], received: [`${op["at"]} ${op["target"]}`], nextSteps: ["Set scene context first, or pass --from explicitly.", "先设置场景初始状态,或修复前序状态变化。"], op: kind, errorCode: "FROM_STATE_AMBIGUOUS" });
1489
1704
  }
1490
1705
  validateStateForTarget(script, targetKind, targetId, fromState);
1491
1706
  const effective = strOf(op["effective"] || op["effective_from"] || "after").trim();
@@ -1539,6 +1754,72 @@ export function applyPatchOperations(script, sourceText, operations) {
1539
1754
  }
1540
1755
  applied.push(kind);
1541
1756
  }
1757
+ else if (kind === "action.content.replace") {
1758
+ // Literal substring replace on action.content. Designed for "find this
1759
+ // phrase and edit it" — the script-stage equivalent of
1760
+ // set_action_content_from_span, but without needing source.txt (the DB-
1761
+ // backed script doesn't ship source). Refuses ambiguous matches by
1762
+ // default so an off-by-one edit can't silently rewrite the wrong copy.
1763
+ const [epId, sceneId, actionIndex] = parseActionRef(op["at"]);
1764
+ const from = strOf(op["from"]);
1765
+ const to = strOf(op["to"]);
1766
+ const all = Boolean(op["all"]);
1767
+ if (!from) {
1768
+ throw opErr("SCRIPT OP BLOCKED: From text empty", "From text empty.", {
1769
+ required: ["from: non-empty string"],
1770
+ received: [JSON.stringify(op)],
1771
+ nextSteps: ["Pass --from with the literal substring to replace."],
1772
+ op: kind,
1773
+ errorCode: "FROM_TEXT_EMPTY",
1774
+ });
1775
+ }
1776
+ const action = findAction(script, epId, sceneId, actionIndex);
1777
+ if (strOf(action["type"]) === "dialogue" && strOf(action["delivery"]) === "overlap") {
1778
+ throw opErr("SCRIPT OP BLOCKED: Overlap dialogue not supported", "action.content.replace cannot edit overlap dialogue lines.", {
1779
+ required: ["non-overlap action"],
1780
+ received: [`${op["at"]} delivery=overlap`],
1781
+ nextSteps: ["Use `script dialogue overlap` to rewrite the lines."],
1782
+ op: kind,
1783
+ errorCode: "OVERLAP_DIALOGUE_UNSUPPORTED",
1784
+ });
1785
+ }
1786
+ const current = strOf(action["content"]);
1787
+ let count = 0;
1788
+ let cursor = 0;
1789
+ while (true) {
1790
+ const i = current.indexOf(from, cursor);
1791
+ if (i < 0)
1792
+ break;
1793
+ count += 1;
1794
+ cursor = i + from.length;
1795
+ }
1796
+ if (count === 0) {
1797
+ throw opErr("SCRIPT OP BLOCKED: From text not found", "From text not found in action content.", {
1798
+ required: ["from substring present in action.content"],
1799
+ received: [`${op["at"]} from=${JSON.stringify(from)}`],
1800
+ nextSteps: ["Inspect the action with `script inspect --target action --grep` and adjust --from."],
1801
+ op: kind,
1802
+ errorCode: "FROM_TEXT_NOT_FOUND",
1803
+ });
1804
+ }
1805
+ if (count > 1 && !all) {
1806
+ throw opErr("SCRIPT OP BLOCKED: From text ambiguous", `From text matches ${count} times in this action.`, {
1807
+ required: ["unique substring, or pass all=true to replace every occurrence"],
1808
+ received: [`${op["at"]} matches=${count}`],
1809
+ nextSteps: ["Use a longer/more specific --from, or pass --all."],
1810
+ op: kind,
1811
+ errorCode: "FROM_TEXT_AMBIGUOUS",
1812
+ });
1813
+ }
1814
+ if (all) {
1815
+ action["content"] = current.split(from).join(to);
1816
+ }
1817
+ else {
1818
+ const i = current.indexOf(from);
1819
+ action["content"] = current.slice(0, i) + to + current.slice(i + from.length);
1820
+ }
1821
+ applied.push(kind);
1822
+ }
1542
1823
  else if (kind === "speaker.add") {
1543
1824
  const sourceKind = strOf(op["kind"] || op["source_kind"]).trim();
1544
1825
  if (!SPEAKER_SOURCE_KINDS.has(sourceKind)) {
@@ -1642,234 +1923,626 @@ export function applyPatchOperations(script, sourceText, operations) {
1642
1923
  delete action["speaker_id"];
1643
1924
  applied.push(kind);
1644
1925
  }
1645
- else if (kind === "set_worldview") {
1646
- const worldview = strOf(op["worldview"]).trim();
1926
+ else if (kind === "meta.worldview.set") {
1927
+ // 0.6.0 dot-style equivalent of the legacy `set_worldview` op.
1928
+ const worldview = strOf(op["worldview"] ?? op["value"]).trim();
1647
1929
  if (!WORLDVIEW_VALUES.includes(worldview)) {
1648
- throw opErr("PATCH BLOCKED: Worldview invalid", "Worldview invalid.", { required: [`worldview: one of ${WORLDVIEW_VALUES.join(", ")}`], received: [worldview || "<empty>"], nextSteps: ["Use a supported worldview enum."] });
1930
+ throw opErr("SCRIPT OP BLOCKED: Worldview invalid", "Worldview invalid.", {
1931
+ required: [`worldview: one of ${WORLDVIEW_VALUES.join(", ")}`],
1932
+ received: [worldview || "<empty>"],
1933
+ nextSteps: ["Use a supported worldview enum."],
1934
+ op: kind,
1935
+ errorCode: "WORLDVIEW_INVALID",
1936
+ });
1649
1937
  }
1650
1938
  script["worldview"] = worldview;
1651
1939
  if ("worldview_raw" in op)
1652
1940
  script["worldview_raw"] = strOf(op["worldview_raw"]).trim();
1653
1941
  applied.push(kind);
1654
1942
  }
1655
- else if (kind === "set_actor_role_type") {
1656
- const [asset] = resolvePatchAsset(script, op, "actor");
1657
- const roleType = strOf(op["role_type"]).trim();
1943
+ else if (kind === "asset.role.set") {
1944
+ const [assetKind, assetId] = parseAssetTarget(op["target"]);
1945
+ if (assetKind !== "actor") {
1946
+ throw opErr("SCRIPT OP BLOCKED: Role only on actors", "Role applies to actor only.", {
1947
+ required: ["actor:<id>"],
1948
+ received: [strOf(op["target"])],
1949
+ nextSteps: ["Use an actor target."],
1950
+ op: kind,
1951
+ errorCode: "ROLE_TARGET_INVALID",
1952
+ });
1953
+ }
1954
+ const asset = resolveAssetByTarget(script, "actor", assetId);
1955
+ const roleType = strOf(op["role_type"] ?? op["role"]).trim();
1658
1956
  if (!ROLE_TYPE_VALUES.includes(roleType)) {
1659
- throw opErr("PATCH BLOCKED: Role type invalid", "Role type invalid.", { required: ["role_type: 主角 or 配角"], received: [roleType || "<empty>"], nextSteps: ["Use a supported actor role_type."] });
1957
+ throw opErr("SCRIPT OP BLOCKED: Role type invalid", "Role type invalid.", {
1958
+ required: ["role_type: 主角 or 配角"],
1959
+ received: [roleType || "<empty>"],
1960
+ nextSteps: ["Use a supported actor role_type."],
1961
+ op: kind,
1962
+ errorCode: "ROLE_TYPE_INVALID",
1963
+ });
1660
1964
  }
1661
1965
  asset["role_type"] = roleType;
1662
1966
  applied.push(kind);
1663
1967
  }
1664
- else if (kind === "set_asset_description") {
1665
- const [asset] = resolvePatchAsset(script, op);
1666
- const description = strOf(op["description"]).trim();
1667
- if (!description) {
1668
- throw opErr("PATCH BLOCKED: Description empty", "Description empty.", { required: ["description: non-empty string"], received: [JSON.stringify(op)], nextSteps: ["Provide a concise grounded description."] });
1968
+ else if (kind === "asset.rename") {
1969
+ const [assetKind, assetId] = parseAssetTarget(op["target"]);
1970
+ const asset = resolveAssetByTarget(script, assetKind, assetId);
1971
+ const newName = strOf(op["name"] ?? op["new_name"]).trim();
1972
+ if (!newName) {
1973
+ throw opErr("SCRIPT OP BLOCKED: Name empty", "Name empty.", {
1974
+ required: ["name"],
1975
+ received: [JSON.stringify(op)],
1976
+ nextSteps: ["Provide a non-empty name."],
1977
+ op: kind,
1978
+ errorCode: "NAME_EMPTY",
1979
+ });
1669
1980
  }
1670
- asset["description"] = description;
1981
+ const [, , nameKey] = assetKeys(assetKind);
1982
+ asset[nameKey] = newName;
1671
1983
  applied.push(kind);
1672
1984
  }
1673
- else if (kind === "set_action_type") {
1674
- const actionIndex = op["action_index"];
1675
- if (typeof actionIndex !== "number" || !Number.isInteger(actionIndex)) {
1676
- throw opErr("PATCH BLOCKED: Action index invalid", "Action index invalid.", { required: ["action_index: integer"], received: [JSON.stringify(op)], nextSteps: ["Inspect episodes and fix the patch."] });
1677
- }
1678
- const actionType = strOf(op["action_type"] || op["type"]).trim();
1679
- if (!ACTION_TYPE_VALUES.has(actionType)) {
1680
- throw opErr("PATCH BLOCKED: Action type invalid", "Action type invalid.", { required: ["action_type: dialogue, inner_thought, or action"], received: [actionType || "<empty>"], nextSteps: ["Use a supported action type."] });
1985
+ else if (kind === "asset.describe") {
1986
+ const [assetKind, assetId] = parseAssetTarget(op["target"]);
1987
+ const asset = resolveAssetByTarget(script, assetKind, assetId);
1988
+ const description = strOf(op["description"]).trim();
1989
+ if (!description) {
1990
+ throw opErr("SCRIPT OP BLOCKED: Description empty", "Description empty.", {
1991
+ required: ["description"],
1992
+ received: [JSON.stringify(op)],
1993
+ nextSteps: ["Provide a concise grounded description."],
1994
+ op: kind,
1995
+ errorCode: "DESCRIPTION_EMPTY",
1996
+ });
1681
1997
  }
1682
- const action = findAction(script, strOf(op["episode_id"]), strOf(op["scene_id"]), actionIndex);
1683
- action["type"] = actionType;
1684
- applied.push(kind);
1685
- }
1686
- else if (kind === "rename_actor" || kind === "rename_location" || kind === "rename_prop") {
1687
- const map = {
1688
- rename_actor: ["actors", "actor_id", "actor_name"],
1689
- rename_location: ["locations", "location_id", "location_name"],
1690
- rename_prop: ["props", "prop_id", "prop_name"],
1691
- };
1692
- const [key, idKey, nameKey] = map[kind];
1693
- const raw = op[idKey] ?? op["id"] ?? op["name"];
1694
- const newName = op["new_name"] ?? op[nameKey];
1695
- const asset = resolveAsset(asList(script[key]), idKey, nameKey, strOf(raw));
1696
- if (!asset || !newName) {
1697
- throw opErr("PATCH BLOCKED: Rename target invalid", "Rename target invalid.", { required: [`${idKey} or name, and new_name`], received: [JSON.stringify(op)], nextSteps: ["Fix the patch operation and rerun patch."] });
1698
- }
1699
- asset[nameKey] = String(newName);
1998
+ asset["description"] = description;
1700
1999
  applied.push(kind);
1701
2000
  }
1702
- else if (kind === "set_asset_aliases") {
1703
- const [asset] = resolvePatchAsset(script, op);
2001
+ else if (kind === "asset.alias.set") {
2002
+ // Bulk replace. Rare; primarily for migration tools.
2003
+ const [assetKind, assetId] = parseAssetTarget(op["target"]);
2004
+ const asset = resolveAssetByTarget(script, assetKind, assetId);
1704
2005
  const aliases = op["aliases"];
1705
2006
  if (!isList(aliases) || aliases.some((it) => typeof it !== "string")) {
1706
- throw opErr("PATCH BLOCKED: Aliases invalid", "Aliases invalid.", { required: ["aliases: array of strings"], received: [JSON.stringify(op)], nextSteps: ["Fix the patch operation and rerun patch."] });
2007
+ throw opErr("SCRIPT OP BLOCKED: Aliases invalid", "Aliases invalid.", {
2008
+ required: ["aliases: array of strings"],
2009
+ received: [JSON.stringify(op)],
2010
+ nextSteps: ["Pass a string array."],
2011
+ op: kind,
2012
+ errorCode: "ALIASES_INVALID",
2013
+ });
1707
2014
  }
1708
2015
  const seen = new Set();
1709
- const cleanedAliases = [];
2016
+ const cleaned = [];
1710
2017
  for (const item of aliases) {
1711
- const alias = item.trim();
1712
- if (alias && !seen.has(alias)) {
2018
+ const trimmed = item.trim();
2019
+ if (trimmed && !seen.has(trimmed)) {
2020
+ seen.add(trimmed);
2021
+ cleaned.push(trimmed);
2022
+ }
2023
+ }
2024
+ asset["aliases"] = cleaned;
2025
+ applied.push(kind);
2026
+ }
2027
+ else if (kind === "asset.alias.add") {
2028
+ const [assetKind, assetId] = parseAssetTarget(op["target"]);
2029
+ const asset = resolveAssetByTarget(script, assetKind, assetId);
2030
+ const rawAdd = op["alias"] ?? op["aliases"];
2031
+ const toAdd = isList(rawAdd)
2032
+ ? rawAdd.map((v) => strOf(v).trim()).filter((v) => v)
2033
+ : strOf(rawAdd).trim() ? [strOf(rawAdd).trim()] : [];
2034
+ if (toAdd.length === 0) {
2035
+ throw opErr("SCRIPT OP BLOCKED: Alias empty", "Alias empty.", {
2036
+ required: ["alias: non-empty string or array"],
2037
+ received: [JSON.stringify(op)],
2038
+ nextSteps: ["Pass --add <alias> at least once."],
2039
+ op: kind,
2040
+ errorCode: "ALIAS_EMPTY",
2041
+ });
2042
+ }
2043
+ const existing = asList(asset["aliases"]).map((s) => strOf(s));
2044
+ const seen = new Set(existing);
2045
+ for (const alias of toAdd) {
2046
+ if (!seen.has(alias)) {
2047
+ existing.push(alias);
1713
2048
  seen.add(alias);
1714
- cleanedAliases.push(alias);
1715
2049
  }
1716
2050
  }
1717
- asset["aliases"] = cleanedAliases;
2051
+ asset["aliases"] = existing;
2052
+ applied.push(kind);
2053
+ }
2054
+ else if (kind === "asset.alias.remove") {
2055
+ const [assetKind, assetId] = parseAssetTarget(op["target"]);
2056
+ const asset = resolveAssetByTarget(script, assetKind, assetId);
2057
+ const rawRemove = op["alias"] ?? op["aliases"];
2058
+ const toRemove = new Set(isList(rawRemove)
2059
+ ? rawRemove.map((v) => strOf(v).trim()).filter((v) => v)
2060
+ : strOf(rawRemove).trim() ? [strOf(rawRemove).trim()] : []);
2061
+ if (toRemove.size === 0) {
2062
+ throw opErr("SCRIPT OP BLOCKED: Alias empty", "Alias empty.", {
2063
+ required: ["alias: non-empty string or array"],
2064
+ received: [JSON.stringify(op)],
2065
+ nextSteps: ["Pass --remove <alias> at least once."],
2066
+ op: kind,
2067
+ errorCode: "ALIAS_EMPTY",
2068
+ });
2069
+ }
2070
+ asset["aliases"] = asList(asset["aliases"]).map((s) => strOf(s)).filter((a) => !toRemove.has(a));
1718
2071
  applied.push(kind);
1719
2072
  }
1720
- else if (kind === "set_asset_states") {
1721
- const [asset] = resolvePatchAsset(script, op);
1722
- const assetKind = strOf(op["asset_type"]).trim();
2073
+ else if (kind === "asset.states.set") {
2074
+ const [assetKind, assetId] = parseAssetTarget(op["target"]);
2075
+ const asset = resolveAssetByTarget(script, assetKind, assetId);
1723
2076
  asset["states"] = normalizeStates(script, op["states"], assetKind);
1724
2077
  applied.push(kind);
1725
2078
  }
1726
- else if (kind === "merge_actor" || kind === "merge_location" || kind === "merge_prop") {
1727
- const map = {
1728
- merge_actor: ["actors", "actor_id", "actors"],
1729
- merge_location: ["locations", "location_id", "locations"],
1730
- merge_prop: ["props", "prop_id", "props"],
1731
- };
1732
- const [key, idKey, refKey] = map[kind];
1733
- const sourceId = op["source_id"];
1734
- const targetId = op["target_id"];
1735
- if (!sourceId || !targetId) {
1736
- throw opErr("PATCH BLOCKED: Merge target invalid", "Merge target invalid.", { required: ["source_id and target_id"], received: [JSON.stringify(op)], nextSteps: ["Fix the patch operation and rerun patch."] });
2079
+ else if (kind === "asset.merge") {
2080
+ // Direction is explicit: `from` disappears, `into` keeps everything.
2081
+ const fromRaw = op["from"] ?? op["source"];
2082
+ const intoRaw = op["into"] ?? op["target"];
2083
+ const [fromKind, fromId] = parseAssetTarget(fromRaw);
2084
+ const [intoKind, intoId] = parseAssetTarget(intoRaw);
2085
+ if (fromKind !== intoKind) {
2086
+ throw opErr("SCRIPT OP BLOCKED: Merge kind mismatch", "Merge source and target must be the same kind.", {
2087
+ required: ["same kind on both sides (actor/actor, location/location, prop/prop)"],
2088
+ received: [`${fromKind}:${fromId} ${intoKind}:${intoId}`],
2089
+ nextSteps: ["Use a same-kind target."],
2090
+ op: kind,
2091
+ errorCode: "MERGE_KIND_MISMATCH",
2092
+ });
1737
2093
  }
1738
- script[key] = asList(script[key]).filter((it) => it[idKey] !== sourceId);
2094
+ const [key, idKey] = assetKeys(fromKind);
2095
+ // Verify both exist.
2096
+ resolveAssetByTarget(script, fromKind, fromId);
2097
+ resolveAssetByTarget(script, intoKind, intoId);
2098
+ // Pre-flight: refuse merge if any scene context already references BOTH
2099
+ // from and into with conflicting state_ids. Silently picking one (the
2100
+ // array-order-first) was a quiet data-loss bug — we'd rather make the
2101
+ // user resolve the state divergence explicitly.
1739
2102
  for (const ep of asList(script["episodes"])) {
1740
2103
  for (const scene of asList(ep["scenes"])) {
1741
- for (const ref of asList(scene[refKey])) {
1742
- if (ref[idKey] === sourceId)
1743
- ref[idKey] = targetId;
2104
+ const ctx = sceneContext(scene);
2105
+ const refList = contextRefsForKind(ctx, fromKind);
2106
+ const fromRef = refList.find((ref) => strOf(ref[idKey]) === fromId);
2107
+ const intoRef = refList.find((ref) => strOf(ref[idKey]) === intoId);
2108
+ if (fromRef && intoRef) {
2109
+ const fromState = strOf(fromRef["state_id"]);
2110
+ const intoState = strOf(intoRef["state_id"]);
2111
+ if (fromState !== intoState) {
2112
+ throw opErr("SCRIPT OP BLOCKED: Merge state conflict", "Merge has conflicting state_ids in a shared scene.", {
2113
+ required: ["from and into agree on state_id in every scene where both appear"],
2114
+ received: [`${ep["episode_id"]}/${scene["scene_id"]}: ${fromId}=${fromState || "null"}, ${intoId}=${intoState || "null"}`],
2115
+ nextSteps: ["Resolve the state divergence (run `scriptctl context <scene> <kind>:<into-id> --state <state>`) then retry merge."],
2116
+ op: kind,
2117
+ errorCode: "MERGE_STATE_CONFLICT",
2118
+ });
2119
+ }
1744
2120
  }
2121
+ }
2122
+ }
2123
+ // Drop source from the asset list.
2124
+ script[key] = asList(script[key]).filter((it) => strOf(it[idKey]) !== fromId);
2125
+ // Rewrite refs: scene.context, action.actor_id (actor only), state_changes targets, transition targets, speaker.source_id.
2126
+ for (const ep of asList(script["episodes"])) {
2127
+ for (const scene of asList(ep["scenes"])) {
2128
+ const ctx = sceneContext(scene);
2129
+ const refList = contextRefsForKind(ctx, fromKind);
2130
+ for (const ref of refList) {
2131
+ if (strOf(ref[idKey]) === fromId)
2132
+ ref[idKey] = intoId;
2133
+ }
2134
+ // After rewrite, dedupe (intoId may now appear twice). State_id was
2135
+ // pre-validated to agree above, so first-seen wins is safe.
2136
+ const seen = new Set();
2137
+ ctx[pluralForKind(fromKind)] = refList.filter((ref) => {
2138
+ const key2 = strOf(ref[idKey]);
2139
+ if (seen.has(key2))
2140
+ return false;
2141
+ seen.add(key2);
2142
+ return true;
2143
+ });
2144
+ setSceneContext(scene, ctx);
1745
2145
  for (const action of asList(scene["actions"])) {
1746
- if (idKey === "actor_id" && action["actor_id"] === sourceId)
1747
- action["actor_id"] = targetId;
2146
+ if (fromKind === "actor" && strOf(action["actor_id"]) === fromId)
2147
+ action["actor_id"] = intoId;
2148
+ for (const change of asList(action["state_changes"])) {
2149
+ if (strOf(change["target_kind"]) === fromKind && strOf(change["target_id"]) === fromId) {
2150
+ change["target_id"] = intoId;
2151
+ }
2152
+ }
2153
+ const transition = action["transition_prompt"];
2154
+ if (isDict(transition) && strOf(transition["target_kind"]) === fromKind && strOf(transition["target_id"]) === fromId) {
2155
+ transition["target_id"] = intoId;
2156
+ }
1748
2157
  }
1749
2158
  }
1750
2159
  }
2160
+ // Speakers anchored to the source actor get reattached to the target.
2161
+ for (const sp of asList(script["speakers"])) {
2162
+ if (strOf(sp["source_kind"]) === fromKind && strOf(sp["source_id"]) === fromId) {
2163
+ sp["source_id"] = intoId;
2164
+ }
2165
+ }
1751
2166
  applied.push(kind);
1752
2167
  }
1753
- else if (kind === "set_scene_actor_ref" || kind === "set_scene_location_ref" || kind === "set_scene_prop_ref") {
1754
- const epId = strOf(op["episode_id"]);
1755
- const sceneId = strOf(op["scene_id"]);
1756
- const scene = findScene(script, epId, sceneId);
1757
- const ctx = sceneContext(scene);
1758
- if (kind === "set_scene_actor_ref") {
1759
- const actorId = ensureRefId(script, "actor", op["actor_id"]);
1760
- const stateId = strOf(op["state_id"]).trim() || null;
1761
- if (!isList(ctx["actors"]))
1762
- ctx["actors"] = [];
1763
- const refs = ctx["actors"];
1764
- const existing = refs.find((r) => r["actor_id"] === actorId);
1765
- if (existing)
1766
- existing["state_id"] = stateId;
1767
- else
1768
- refs.push({ actor_id: actorId, state_id: stateId });
1769
- }
1770
- else if (kind === "set_scene_location_ref") {
1771
- const locationId = ensureRefId(script, "location", op["location_id"]);
1772
- const stateId = strOf(op["state_id"]).trim() || null;
1773
- ctx["locations"] = [{ location_id: locationId, state_id: stateId }];
2168
+ else if (kind === "asset.delete") {
2169
+ // Refs are walked via collectAssetRefs. Default: refuse if any refs exist.
2170
+ // strategy=replace → rewrite refs to a replacement asset, then delete.
2171
+ // strategy=remove → rewrite refs to null/clean and delete (leaves dangling
2172
+ // pointers cleaned by per-edge logic; validation will still surface anything
2173
+ // still wrong).
2174
+ const [assetKind, assetId] = parseAssetTarget(op["target"]);
2175
+ resolveAssetByTarget(script, assetKind, assetId);
2176
+ const refs = collectAssetRefs(script, assetKind, assetId);
2177
+ const strategy = strOf(op["strategy"]).trim();
2178
+ if (refs.length > 0 && !strategy) {
2179
+ throw opErr("SCRIPT OP BLOCKED: Asset has refs", `Asset is referenced ${refs.length} time(s).`, {
2180
+ required: ["--strategy replace|remove"],
2181
+ received: [`refs=${refs.length}`],
2182
+ nextSteps: ["Use --strategy replace --replacement <addr> to remap, or --strategy remove to scrub refs."],
2183
+ op: kind,
2184
+ errorCode: "ASSET_HAS_REFS",
2185
+ });
2186
+ }
2187
+ if (strategy === "replace") {
2188
+ const replacementRaw = op["replacement"] ?? op["into"];
2189
+ const [replacementKind, replacementId] = parseAssetTarget(replacementRaw);
2190
+ if (replacementKind !== assetKind) {
2191
+ throw opErr("SCRIPT OP BLOCKED: Replacement kind mismatch", "Replacement must be same kind.", {
2192
+ required: ["replacement same kind as target"],
2193
+ received: [`${assetKind} vs ${replacementKind}`],
2194
+ nextSteps: ["Pass a same-kind replacement."],
2195
+ op: kind,
2196
+ errorCode: "REPLACEMENT_KIND_MISMATCH",
2197
+ });
2198
+ }
2199
+ resolveAssetByTarget(script, replacementKind, replacementId);
2200
+ // Delegate ref rewrite to asset.merge logic by reusing the same traversal.
2201
+ applyPatchOperations(script, sourceText, [
2202
+ { op: "asset.merge", from: op["target"], into: replacementRaw },
2203
+ ]);
2204
+ applied.push(kind);
2205
+ }
2206
+ else if (strategy === "remove" || refs.length === 0) {
2207
+ // Scrub refs (actor_id → undefined, state_changes/transition refs removed, scene.context entries removed, speakers' source_id cleared).
2208
+ const [key, idKey] = assetKeys(assetKind);
2209
+ script[key] = asList(script[key]).filter((it) => strOf(it[idKey]) !== assetId);
2210
+ for (const ep of asList(script["episodes"])) {
2211
+ for (const scene of asList(ep["scenes"])) {
2212
+ const ctx = sceneContext(scene);
2213
+ ctx[pluralForKind(assetKind)] = contextRefsForKind(ctx, assetKind).filter((ref) => strOf(ref[idKey]) !== assetId);
2214
+ setSceneContext(scene, ctx);
2215
+ for (const action of asList(scene["actions"])) {
2216
+ if (assetKind === "actor" && strOf(action["actor_id"]) === assetId)
2217
+ delete action["actor_id"];
2218
+ const changes = asList(action["state_changes"]).filter((c) => !(strOf(c["target_kind"]) === assetKind && strOf(c["target_id"]) === assetId));
2219
+ if (changes.length > 0)
2220
+ action["state_changes"] = changes;
2221
+ else
2222
+ delete action["state_changes"];
2223
+ const transition = action["transition_prompt"];
2224
+ if (isDict(transition) && strOf(transition["target_kind"]) === assetKind && strOf(transition["target_id"]) === assetId) {
2225
+ delete action["transition_prompt"];
2226
+ }
2227
+ }
2228
+ }
2229
+ }
2230
+ for (const sp of asList(script["speakers"])) {
2231
+ if (strOf(sp["source_kind"]) === assetKind && strOf(sp["source_id"]) === assetId) {
2232
+ sp["source_id"] = null;
2233
+ }
2234
+ }
2235
+ applied.push(kind);
1774
2236
  }
1775
2237
  else {
1776
- const propId = ensureRefId(script, "prop", op["prop_id"]);
1777
- const stateId = strOf(op["state_id"]).trim() || null;
1778
- if (!isList(ctx["props"]))
1779
- ctx["props"] = [];
1780
- const refs = ctx["props"];
1781
- const existing = refs.find((r) => r["prop_id"] === propId);
1782
- if (existing)
1783
- existing["state_id"] = stateId;
1784
- else
1785
- refs.push({ prop_id: propId, state_id: stateId });
2238
+ throw opErr("SCRIPT OP BLOCKED: Strategy invalid", "Strategy invalid.", {
2239
+ required: ["strategy: replace or remove"],
2240
+ received: [strategy],
2241
+ nextSteps: ["Use replace (with --replacement) or remove."],
2242
+ op: kind,
2243
+ errorCode: "DELETE_STRATEGY_INVALID",
2244
+ });
1786
2245
  }
1787
- setSceneContext(scene, ctx);
1788
- applied.push(kind);
1789
2246
  }
1790
- else if (kind === "set_action_actor") {
1791
- const actorId = ensureRefId(script, "actor", op["actor_id"], { allowNone: Boolean(op["allow_null"]) });
1792
- const actionIndex = op["action_index"];
1793
- if (typeof actionIndex !== "number" || !Number.isInteger(actionIndex)) {
1794
- throw opErr("PATCH BLOCKED: Action index invalid", "Action index invalid.", { required: ["action_index: integer"], received: [JSON.stringify(op)], nextSteps: ["Inspect episodes and fix the patch."] });
2247
+ else if (kind === "action.type.set") {
2248
+ const [epId, sceneId, actionIndex] = parseActionRef(op["at"]);
2249
+ const actionType = strOf(op["type"] ?? op["action_type"]).trim();
2250
+ if (!ACTION_TYPE_VALUES.has(actionType)) {
2251
+ throw opErr("SCRIPT OP BLOCKED: Action type invalid", "Action type invalid.", {
2252
+ required: ["type: dialogue, inner_thought, or action"],
2253
+ received: [actionType || "<empty>"],
2254
+ nextSteps: ["Use a supported action type."],
2255
+ op: kind,
2256
+ errorCode: "ACTION_TYPE_INVALID",
2257
+ });
1795
2258
  }
1796
- const action = findAction(script, strOf(op["episode_id"]), strOf(op["scene_id"]), actionIndex);
2259
+ const action = findAction(script, epId, sceneId, actionIndex);
2260
+ action["type"] = actionType;
2261
+ applied.push(kind);
2262
+ }
2263
+ else if (kind === "action.actor.set") {
2264
+ const [epId, sceneId, actionIndex] = parseActionRef(op["at"]);
2265
+ const rawActor = op["actor_id"] ?? op["actor"];
2266
+ const isNone = rawActor === null || strOf(rawActor).trim() === "" || strOf(rawActor).trim() === "none";
2267
+ const actorId = isNone ? null : ensureRefId(script, "actor", rawActor);
2268
+ const action = findAction(script, epId, sceneId, actionIndex);
1797
2269
  if (actorId === null)
1798
2270
  delete action["actor_id"];
1799
2271
  else
1800
2272
  action["actor_id"] = actorId;
1801
2273
  applied.push(kind);
1802
2274
  }
1803
- else if (kind === "set_action_content_from_span") {
1804
- const epId = op["episode_id"];
1805
- const sceneId = op["scene_id"];
1806
- const actionIndex = op["action_index"];
1807
- const start = op["start"];
1808
- const end = op["end"];
1809
- if (typeof actionIndex !== "number" || !Number.isInteger(actionIndex) ||
1810
- typeof start !== "number" || !Number.isInteger(start) ||
1811
- typeof end !== "number" || !Number.isInteger(end) ||
1812
- start < 0 || end <= start || end > sourceText.length) {
1813
- throw opErr("PATCH BLOCKED: Source span invalid", "Source span invalid.", { required: ["episode_id, scene_id, action_index, start, end"], received: [JSON.stringify(op)], nextSteps: ["Use a valid source.txt character span."] });
1814
- }
1815
- const action = findAction(script, strOf(epId), strOf(sceneId), actionIndex);
1816
- action["content"] = sourceText.slice(start, end);
2275
+ else if (kind === "action.delete") {
2276
+ const [epId, sceneId, actionIndex] = parseActionRef(op["at"]);
2277
+ const scene = findScene(script, epId, sceneId);
2278
+ const actions = asList(scene["actions"]);
2279
+ if (actionIndex < 0 || actionIndex >= actions.length) {
2280
+ throw opErr("SCRIPT OP BLOCKED: Action index out of range", "Action index out of range.", {
2281
+ required: [`0 <= action_index < ${actions.length}`],
2282
+ received: [String(actionIndex)],
2283
+ nextSteps: ["Inspect actions in the scene and use a valid index."],
2284
+ op: kind,
2285
+ errorCode: "ACTION_INDEX_OUT_OF_RANGE",
2286
+ });
2287
+ }
2288
+ actions.splice(actionIndex, 1);
2289
+ scene["actions"] = actions;
2290
+ applied.push(kind);
2291
+ }
2292
+ else if (kind === "action.insert") {
2293
+ // <at> is a scene ref (ep_xxx/scn_xxx). Position resolved from
2294
+ // --at <idx> | --before <action-ref> | --after <action-ref> | default-append.
2295
+ const [epId, sceneId] = parseSceneRef(op["at"]);
2296
+ const scene = findScene(script, epId, sceneId);
2297
+ const actions = asList(scene["actions"]);
2298
+ const actionType = strOf(op["type"]).trim();
2299
+ if (!ACTION_TYPE_VALUES.has(actionType)) {
2300
+ throw opErr("SCRIPT OP BLOCKED: Action type invalid", "Action type invalid.", {
2301
+ required: ["type: dialogue, inner_thought, or action"],
2302
+ received: [actionType || "<empty>"],
2303
+ nextSteps: ["Use a supported action type."],
2304
+ op: kind,
2305
+ errorCode: "ACTION_TYPE_INVALID",
2306
+ });
2307
+ }
2308
+ const content = strOf(op["content"]);
2309
+ let insertIndex;
2310
+ if (op["before"]) {
2311
+ const [bEp, bScn, bIdx] = parseActionRef(op["before"]);
2312
+ if (bEp !== epId || bScn !== sceneId) {
2313
+ throw opErr("SCRIPT OP BLOCKED: before address mismatch", "--before must point at the same scene.", {
2314
+ required: [`${epId}/${sceneId}#<idx>`],
2315
+ received: [strOf(op["before"])],
2316
+ nextSteps: ["Use a sibling action address."],
2317
+ op: kind,
2318
+ errorCode: "INSERT_REF_MISMATCH",
2319
+ });
2320
+ }
2321
+ insertIndex = bIdx;
2322
+ }
2323
+ else if (op["after"]) {
2324
+ const [aEp, aScn, aIdx] = parseActionRef(op["after"]);
2325
+ if (aEp !== epId || aScn !== sceneId) {
2326
+ throw opErr("SCRIPT OP BLOCKED: after address mismatch", "--after must point at the same scene.", {
2327
+ required: [`${epId}/${sceneId}#<idx>`],
2328
+ received: [strOf(op["after"])],
2329
+ nextSteps: ["Use a sibling action address."],
2330
+ op: kind,
2331
+ errorCode: "INSERT_REF_MISMATCH",
2332
+ });
2333
+ }
2334
+ insertIndex = aIdx + 1;
2335
+ }
2336
+ else if (op["at_index"] !== undefined && op["at_index"] !== null) {
2337
+ const n = Number(op["at_index"]);
2338
+ if (!Number.isInteger(n) || n < 0 || n > actions.length) {
2339
+ throw opErr("SCRIPT OP BLOCKED: at_index out of range", "at_index out of range.", {
2340
+ required: [`0..${actions.length}`],
2341
+ received: [String(op["at_index"])],
2342
+ nextSteps: ["Use a valid integer index."],
2343
+ op: kind,
2344
+ errorCode: "AT_INDEX_OUT_OF_RANGE",
2345
+ });
2346
+ }
2347
+ insertIndex = n;
2348
+ }
2349
+ else {
2350
+ // Default: append at end.
2351
+ insertIndex = actions.length;
2352
+ }
2353
+ const newAction = { type: actionType, content };
2354
+ if (op["actor_id"])
2355
+ newAction["actor_id"] = ensureRefId(script, "actor", op["actor_id"]);
2356
+ if (op["speaker_id"])
2357
+ newAction["speaker_id"] = ensureSpeakerId(script, strOf(op["speaker_id"]));
2358
+ actions.splice(insertIndex, 0, newAction);
2359
+ scene["actions"] = actions;
1817
2360
  applied.push(kind);
1818
2361
  }
1819
- else if (kind === "merge_scenes") {
1820
- const epId = strOf(op["episode_id"]);
1821
- const keepId = strOf(op["keep_scene_id"]);
1822
- const removeId = strOf(op["remove_scene_id"]);
1823
- const ep = asList(script["episodes"]).find((e) => e["episode_id"] === epId);
1824
- if (!ep) {
1825
- throw opErr("PATCH BLOCKED: Episode not found", "Episode not found.", { required: ["existing episode_id"], received: [epId], nextSteps: ["Inspect episodes and fix the patch."] });
2362
+ else if (kind === "action.move") {
2363
+ const [fromEp, fromScn, fromIdx] = parseActionRef(op["at"] ?? op["from"]);
2364
+ const [toEp, toScn, toIdx] = parseActionRef(op["to"]);
2365
+ const fromScene = findScene(script, fromEp, fromScn);
2366
+ const fromActions = asList(fromScene["actions"]);
2367
+ if (fromIdx < 0 || fromIdx >= fromActions.length) {
2368
+ throw opErr("SCRIPT OP BLOCKED: Action index out of range", "Source action index out of range.", {
2369
+ required: [`0 <= idx < ${fromActions.length}`],
2370
+ received: [String(fromIdx)],
2371
+ nextSteps: ["Inspect actions and use a valid index."],
2372
+ op: kind,
2373
+ errorCode: "ACTION_INDEX_OUT_OF_RANGE",
2374
+ });
1826
2375
  }
2376
+ const [moved] = fromActions.splice(fromIdx, 1);
2377
+ fromScene["actions"] = fromActions;
2378
+ const toScene = (fromEp === toEp && fromScn === toScn) ? fromScene : findScene(script, toEp, toScn);
2379
+ const toActions = asList(toScene["actions"]);
2380
+ // After removing the source, when target is the same scene and idx is past
2381
+ // the removed slot, the agent's intended index is already correct (no
2382
+ // shift needed in the destination); otherwise clamp.
2383
+ let dest = toIdx;
2384
+ if (dest < 0)
2385
+ dest = 0;
2386
+ if (dest > toActions.length)
2387
+ dest = toActions.length;
2388
+ toActions.splice(dest, 0, moved);
2389
+ toScene["actions"] = toActions;
2390
+ applied.push(kind);
2391
+ }
2392
+ else if (kind === "scene.insert") {
2393
+ // Insert a new empty scene into an episode. Position resolved from
2394
+ // --at <idx> | --before <ep/scn> | --after <ep/scn> | default-append.
2395
+ // Requires --location to anchor the scene's locations[] context (matches
2396
+ // direct-init's assumption that every scene names at least one location).
2397
+ const epId = strOf(op["at"] ?? op["episode_id"]);
2398
+ const ep = findEpisode(script, epId);
1827
2399
  const scenes = asList(ep["scenes"]);
1828
- const keep = scenes.find((s) => s["scene_id"] === keepId);
1829
- const remove = scenes.find((s) => s["scene_id"] === removeId);
1830
- if (!keep || !remove) {
1831
- throw opErr("PATCH BLOCKED: Scene not found", "Scene not found.", { required: ["existing keep_scene_id and remove_scene_id"], received: [JSON.stringify(op)], nextSteps: ["Inspect episodes and fix the patch."] });
1832
- }
1833
- keep["actions"].push(...asList(remove["actions"]));
1834
- const keepCtx = sceneContext(keep);
1835
- const removeCtx = sceneContext(remove);
1836
- for (const [field, idKey] of [["actors", "actor_id"], ["locations", "location_id"], ["props", "prop_id"]]) {
1837
- const existingIds = new Set();
1838
- for (const ref of asList(keepCtx[field]))
1839
- existingIds.add(ref[idKey]);
1840
- for (const ref of asList(removeCtx[field])) {
1841
- if (!existingIds.has(ref[idKey])) {
1842
- if (!isList(keepCtx[field]))
1843
- keepCtx[field] = [];
1844
- keepCtx[field].push(ref);
1845
- }
2400
+ const locationId = strOf(op["location"] ?? op["location_id"]).trim();
2401
+ if (!locationId) {
2402
+ throw opErr("SCRIPT OP BLOCKED: location required", "Scene insert requires --location.", {
2403
+ required: ["location: existing location_id"],
2404
+ received: ["<empty>"],
2405
+ nextSteps: ["Pass --location <loc_id>."],
2406
+ op: kind,
2407
+ errorCode: "SCENE_LOCATION_MISSING",
2408
+ });
2409
+ }
2410
+ ensureRefId(script, "location", locationId);
2411
+ let insertIndex;
2412
+ if (op["before"]) {
2413
+ const [bEp, bScn] = parseSceneRef(op["before"]);
2414
+ if (bEp !== epId) {
2415
+ throw opErr("SCRIPT OP BLOCKED: before address mismatch", "--before must point at the same episode.", {
2416
+ required: [`${epId}/<scn>`],
2417
+ received: [strOf(op["before"])],
2418
+ nextSteps: ["Use a sibling scene address."],
2419
+ op: kind,
2420
+ errorCode: "INSERT_REF_MISMATCH",
2421
+ });
1846
2422
  }
2423
+ insertIndex = scenes.findIndex((s) => strOf(s["scene_id"]) === bScn);
2424
+ if (insertIndex < 0) {
2425
+ throw opErr("SCRIPT OP BLOCKED: before scene not found", "before scene not found.", {
2426
+ required: ["existing scene_id"],
2427
+ received: [bScn],
2428
+ nextSteps: ["Inspect scenes."],
2429
+ op: kind,
2430
+ errorCode: "SCENE_NOT_FOUND",
2431
+ });
2432
+ }
2433
+ }
2434
+ else if (op["after"]) {
2435
+ const [aEp, aScn] = parseSceneRef(op["after"]);
2436
+ if (aEp !== epId) {
2437
+ throw opErr("SCRIPT OP BLOCKED: after address mismatch", "--after must point at the same episode.", {
2438
+ required: [`${epId}/<scn>`],
2439
+ received: [strOf(op["after"])],
2440
+ nextSteps: ["Use a sibling scene address."],
2441
+ op: kind,
2442
+ errorCode: "INSERT_REF_MISMATCH",
2443
+ });
2444
+ }
2445
+ const found = scenes.findIndex((s) => strOf(s["scene_id"]) === aScn);
2446
+ if (found < 0) {
2447
+ throw opErr("SCRIPT OP BLOCKED: after scene not found", "after scene not found.", {
2448
+ required: ["existing scene_id"],
2449
+ received: [aScn],
2450
+ nextSteps: ["Inspect scenes."],
2451
+ op: kind,
2452
+ errorCode: "SCENE_NOT_FOUND",
2453
+ });
2454
+ }
2455
+ insertIndex = found + 1;
2456
+ }
2457
+ else if (op["at_index"] !== undefined && op["at_index"] !== null) {
2458
+ const n = Number(op["at_index"]);
2459
+ if (!Number.isInteger(n) || n < 0 || n > scenes.length) {
2460
+ throw opErr("SCRIPT OP BLOCKED: at_index out of range", "at_index out of range.", {
2461
+ required: [`0..${scenes.length}`],
2462
+ received: [String(op["at_index"])],
2463
+ nextSteps: ["Use a valid integer index."],
2464
+ op: kind,
2465
+ errorCode: "AT_INDEX_OUT_OF_RANGE",
2466
+ });
2467
+ }
2468
+ insertIndex = n;
1847
2469
  }
1848
- setSceneContext(keep, keepCtx);
1849
- ep["scenes"] = scenes.filter((s) => s !== remove);
2470
+ else {
2471
+ insertIndex = scenes.length;
2472
+ }
2473
+ const newSceneId = strOf(op["scene_id"]).trim() || nextSceneId(script);
2474
+ if (sceneIdExists(script, newSceneId)) {
2475
+ throw opErr("SCRIPT OP BLOCKED: New scene id exists", "New scene id exists.", {
2476
+ required: ["unused scene_id"],
2477
+ received: [newSceneId],
2478
+ nextSteps: ["Pass --scene-id <unused> or omit."],
2479
+ op: kind,
2480
+ errorCode: "NEW_SCENE_ID_EXISTS",
2481
+ });
2482
+ }
2483
+ const time = strOf(op["time"]).trim() || "day";
2484
+ const space = strOf(op["space"]).trim() || "interior";
2485
+ const newScene = {
2486
+ scene_id: newSceneId,
2487
+ environment: { time, space },
2488
+ context: {
2489
+ locations: [{ location_id: locationId, state_id: null }],
2490
+ actors: [],
2491
+ props: [],
2492
+ },
2493
+ locations: [{ location_id: locationId, state_id: null }],
2494
+ actors: [],
2495
+ props: [],
2496
+ actions: [],
2497
+ };
2498
+ scenes.splice(insertIndex, 0, newScene);
2499
+ ep["scenes"] = scenes;
1850
2500
  applied.push(kind);
1851
2501
  }
1852
- else if (kind === "split_scene") {
1853
- const epId = strOf(op["episode_id"]);
1854
- const sceneId = strOf(op["scene_id"]);
1855
- const splitAt = op["action_index"];
2502
+ else if (kind === "scene.split") {
2503
+ const [epId, sceneId] = parseSceneRef(op["at"]);
2504
+ const splitAt = op["at_index"] ?? op["action_index"];
1856
2505
  if (typeof splitAt !== "number" || !Number.isInteger(splitAt)) {
1857
- throw opErr("PATCH BLOCKED: Split index invalid", "Split index invalid.", { required: ["action_index: integer between existing actions"], received: [JSON.stringify(op)], nextSteps: ["Inspect episode actions and fix the patch."] });
2506
+ throw opErr("SCRIPT OP BLOCKED: Split index invalid", "Split index invalid.", {
2507
+ required: ["at_index: integer between existing actions"],
2508
+ received: [JSON.stringify(op)],
2509
+ nextSteps: ["Inspect actions and use a valid index."],
2510
+ op: kind,
2511
+ errorCode: "SPLIT_INDEX_INVALID",
2512
+ });
1858
2513
  }
1859
2514
  const ep = findEpisode(script, epId);
1860
2515
  const scenes = asList(ep["scenes"]);
1861
2516
  const index = scenes.findIndex((s) => s["scene_id"] === sceneId);
1862
2517
  if (index < 0) {
1863
- throw opErr("PATCH BLOCKED: Scene not found", "Scene not found.", { required: ["existing scene_id"], received: [sceneId], nextSteps: ["Inspect episodes and fix the patch."] });
2518
+ throw opErr("SCRIPT OP BLOCKED: Scene not found", "Scene not found.", {
2519
+ required: ["existing scene_id"],
2520
+ received: [sceneId],
2521
+ nextSteps: ["Inspect scenes and fix the address."],
2522
+ op: kind,
2523
+ errorCode: "SCENE_NOT_FOUND",
2524
+ });
1864
2525
  }
1865
2526
  const scene = scenes[index];
1866
2527
  const actions = asList(scene["actions"]);
1867
2528
  if (splitAt <= 0 || splitAt >= actions.length) {
1868
- throw opErr("PATCH BLOCKED: Split index invalid", "Split index invalid.", { required: ["0 < action_index < action count"], received: [`action_index: ${splitAt}, actions: ${actions.length}`], nextSteps: ["Choose an action boundary inside the scene."] });
2529
+ throw opErr("SCRIPT OP BLOCKED: Split index invalid", "Split index out of bounds.", {
2530
+ required: [`0 < at_index < ${actions.length}`],
2531
+ received: [String(splitAt)],
2532
+ nextSteps: ["Choose an action boundary inside the scene."],
2533
+ op: kind,
2534
+ errorCode: "SPLIT_INDEX_INVALID",
2535
+ });
1869
2536
  }
1870
2537
  const newSceneId = strOf(op["new_scene_id"]) || nextSceneId(script);
1871
2538
  if (sceneIdExists(script, newSceneId)) {
1872
- throw opErr("PATCH BLOCKED: New scene id exists", "New scene id exists.", { required: ["unused new_scene_id"], received: [newSceneId], nextSteps: ["Choose an unused scene id or omit new_scene_id."] });
2539
+ throw opErr("SCRIPT OP BLOCKED: New scene id exists", "New scene id exists.", {
2540
+ required: ["unused new_scene_id"],
2541
+ received: [newSceneId],
2542
+ nextSteps: ["Choose an unused scene id or omit new_scene_id."],
2543
+ op: kind,
2544
+ errorCode: "NEW_SCENE_ID_EXISTS",
2545
+ });
1873
2546
  }
1874
2547
  const ctx = sceneContext(scene);
1875
2548
  const newScene = {
@@ -1885,13 +2558,312 @@ export function applyPatchOperations(script, sourceText, operations) {
1885
2558
  scenes.splice(index + 1, 0, newScene);
1886
2559
  applied.push(kind);
1887
2560
  }
2561
+ else if (kind === "scene.merge") {
2562
+ // 0.6.0 uses {from, into} (source disappears, into keeps). The legacy op
2563
+ // used {keep_scene_id, remove_scene_id}; payload still accepted for back-
2564
+ // compat during transition.
2565
+ const fromRaw = op["from"] ?? op["remove_scene_id"];
2566
+ const intoRaw = op["into"] ?? op["keep_scene_id"];
2567
+ // from can be either ep/scn or just scn (legacy). Normalize.
2568
+ let fromEp, fromScn, intoEp, intoScn;
2569
+ if (typeof fromRaw === "string" && fromRaw.includes("/")) {
2570
+ [fromEp, fromScn] = parseSceneRef(fromRaw);
2571
+ }
2572
+ else {
2573
+ // Legacy: needs episode_id from op.
2574
+ fromEp = strOf(op["episode_id"]);
2575
+ fromScn = strOf(fromRaw);
2576
+ }
2577
+ if (typeof intoRaw === "string" && intoRaw.includes("/")) {
2578
+ [intoEp, intoScn] = parseSceneRef(intoRaw);
2579
+ }
2580
+ else {
2581
+ intoEp = strOf(op["episode_id"]);
2582
+ intoScn = strOf(intoRaw);
2583
+ }
2584
+ if (fromEp !== intoEp) {
2585
+ throw opErr("SCRIPT OP BLOCKED: Merge cross-episode", "Scene merge must be within one episode.", {
2586
+ required: ["same episode_id on both sides"],
2587
+ received: [`${fromEp}/${fromScn} → ${intoEp}/${intoScn}`],
2588
+ nextSteps: ["Move one scene first, or merge same-episode scenes."],
2589
+ op: kind,
2590
+ errorCode: "MERGE_CROSS_EPISODE",
2591
+ });
2592
+ }
2593
+ const ep = findEpisode(script, fromEp);
2594
+ const scenes = asList(ep["scenes"]);
2595
+ const intoScene = scenes.find((s) => s["scene_id"] === intoScn);
2596
+ const fromScene = scenes.find((s) => s["scene_id"] === fromScn);
2597
+ if (!intoScene || !fromScene) {
2598
+ throw opErr("SCRIPT OP BLOCKED: Scene not found", "Scene not found.", {
2599
+ required: ["existing scene_id on both sides"],
2600
+ received: [`from=${fromScn}, into=${intoScn}`],
2601
+ nextSteps: ["Inspect scenes and fix the address."],
2602
+ op: kind,
2603
+ errorCode: "SCENE_NOT_FOUND",
2604
+ });
2605
+ }
2606
+ intoScene["actions"].push(...asList(fromScene["actions"]));
2607
+ const intoCtx = sceneContext(intoScene);
2608
+ const fromCtx = sceneContext(fromScene);
2609
+ for (const [field, idKey] of [["actors", "actor_id"], ["locations", "location_id"], ["props", "prop_id"]]) {
2610
+ const existingIds = new Set();
2611
+ for (const ref of asList(intoCtx[field]))
2612
+ existingIds.add(ref[idKey]);
2613
+ for (const ref of asList(fromCtx[field])) {
2614
+ if (!existingIds.has(ref[idKey])) {
2615
+ if (!isList(intoCtx[field]))
2616
+ intoCtx[field] = [];
2617
+ intoCtx[field].push(ref);
2618
+ }
2619
+ }
2620
+ }
2621
+ setSceneContext(intoScene, intoCtx);
2622
+ ep["scenes"] = scenes.filter((s) => s !== fromScene);
2623
+ applied.push(kind);
2624
+ }
2625
+ else if (kind === "scene.delete") {
2626
+ const [epId, sceneId] = parseSceneRef(op["at"]);
2627
+ const ep = findEpisode(script, epId);
2628
+ const scenes = asList(ep["scenes"]);
2629
+ const scene = scenes.find((s) => s["scene_id"] === sceneId);
2630
+ if (!scene) {
2631
+ throw opErr("SCRIPT OP BLOCKED: Scene not found", "Scene not found.", {
2632
+ required: ["existing scene_id"],
2633
+ received: [`${epId}/${sceneId}`],
2634
+ nextSteps: ["Inspect scenes."],
2635
+ op: kind,
2636
+ errorCode: "SCENE_NOT_FOUND",
2637
+ });
2638
+ }
2639
+ const actions = asList(scene["actions"]);
2640
+ const force = Boolean(op["force"]);
2641
+ if (actions.length > 0 && !force) {
2642
+ throw opErr("SCRIPT OP BLOCKED: Scene non-empty", `Scene has ${actions.length} action(s).`, {
2643
+ required: ["empty scene, or force=true"],
2644
+ received: [`actions=${actions.length}`],
2645
+ nextSteps: ["Pass --force to delete with actions, or move/delete actions first."],
2646
+ op: kind,
2647
+ errorCode: "SCENE_NON_EMPTY",
2648
+ });
2649
+ }
2650
+ ep["scenes"] = scenes.filter((s) => s !== scene);
2651
+ applied.push(kind);
2652
+ }
2653
+ else if (kind === "scene.move") {
2654
+ const [fromEp, fromScn] = parseSceneRef(op["at"] ?? op["from"]);
2655
+ const toEpRaw = strOf(op["to"]);
2656
+ // `to` accepts either bare `ep_NNN` (append) or `ep_NNN/scn_NNN` (insert before that scene).
2657
+ // `--at <idx>` overrides positioning.
2658
+ let toEp;
2659
+ let toAnchorScn = null;
2660
+ if (toEpRaw.includes("/")) {
2661
+ const [a, b] = parseSceneRef(toEpRaw);
2662
+ toEp = a;
2663
+ toAnchorScn = b;
2664
+ }
2665
+ else {
2666
+ toEp = toEpRaw;
2667
+ }
2668
+ if (!toEp) {
2669
+ throw opErr("SCRIPT OP BLOCKED: to address invalid", "to address invalid.", {
2670
+ required: ["to: ep_NNN or ep_NNN/scn_NNN"],
2671
+ received: [toEpRaw],
2672
+ nextSteps: ["Pass a target episode id."],
2673
+ op: kind,
2674
+ errorCode: "SCENE_MOVE_TO_INVALID",
2675
+ });
2676
+ }
2677
+ const fromEpisode = findEpisode(script, fromEp);
2678
+ const fromScenes = asList(fromEpisode["scenes"]);
2679
+ const sourceIdx = fromScenes.findIndex((s) => s["scene_id"] === fromScn);
2680
+ if (sourceIdx < 0) {
2681
+ throw opErr("SCRIPT OP BLOCKED: Scene not found", "Source scene not found.", {
2682
+ required: ["existing scene_id"],
2683
+ received: [`${fromEp}/${fromScn}`],
2684
+ nextSteps: ["Inspect scenes."],
2685
+ op: kind,
2686
+ errorCode: "SCENE_NOT_FOUND",
2687
+ });
2688
+ }
2689
+ const [moved] = fromScenes.splice(sourceIdx, 1);
2690
+ fromEpisode["scenes"] = fromScenes;
2691
+ const toEpisode = findEpisode(script, toEp);
2692
+ const toScenes = asList(toEpisode["scenes"]);
2693
+ let destIdx;
2694
+ if (op["at_index"] !== undefined && op["at_index"] !== null) {
2695
+ const n = Number(op["at_index"]);
2696
+ if (!Number.isInteger(n) || n < 0 || n > toScenes.length) {
2697
+ throw opErr("SCRIPT OP BLOCKED: at_index out of range", "at_index out of range.", {
2698
+ required: [`0..${toScenes.length}`],
2699
+ received: [String(op["at_index"])],
2700
+ nextSteps: ["Use a valid integer index."],
2701
+ op: kind,
2702
+ errorCode: "AT_INDEX_OUT_OF_RANGE",
2703
+ });
2704
+ }
2705
+ destIdx = n;
2706
+ }
2707
+ else if (toAnchorScn) {
2708
+ const found = toScenes.findIndex((s) => s["scene_id"] === toAnchorScn);
2709
+ if (found < 0) {
2710
+ throw opErr("SCRIPT OP BLOCKED: Target scene not found", "Target scene not found.", {
2711
+ required: ["existing scene_id in target episode"],
2712
+ received: [`${toEp}/${toAnchorScn}`],
2713
+ nextSteps: ["Inspect the target episode."],
2714
+ op: kind,
2715
+ errorCode: "SCENE_NOT_FOUND",
2716
+ });
2717
+ }
2718
+ destIdx = found;
2719
+ }
2720
+ else {
2721
+ destIdx = toScenes.length;
2722
+ }
2723
+ toScenes.splice(destIdx, 0, moved);
2724
+ toEpisode["scenes"] = toScenes;
2725
+ applied.push(kind);
2726
+ }
2727
+ else if (kind === "speaker.delete") {
2728
+ // Mirror of asset.delete for speakers: refs scrub or replace via
2729
+ // --strategy. Speaker refs live in action.speaker_id /
2730
+ // action.speakers[] / action.lines[].speaker_id only — no scene
2731
+ // context entries to handle.
2732
+ const speakerId = strOf(op["target"] ?? op["speaker_id"]).trim();
2733
+ if (!speakerId) {
2734
+ throw opErr("SCRIPT OP BLOCKED: Speaker id missing", "Speaker id missing.", {
2735
+ required: ["target speaker id"],
2736
+ received: [JSON.stringify(op)],
2737
+ nextSteps: ["Pass a spk_id."],
2738
+ op: kind,
2739
+ errorCode: "SPEAKER_ID_MISSING",
2740
+ });
2741
+ }
2742
+ const refs = collectAssetRefs(script, "speaker", speakerId);
2743
+ const strategy = strOf(op["strategy"]).trim();
2744
+ if (refs.length > 0 && !strategy) {
2745
+ throw opErr("SCRIPT OP BLOCKED: Speaker has refs", `Speaker is referenced ${refs.length} time(s).`, {
2746
+ required: ["--strategy replace|remove"],
2747
+ received: [`refs=${refs.length}`],
2748
+ nextSteps: ["Use --strategy replace --replacement <spk_id> to remap, or --strategy remove to scrub refs."],
2749
+ op: kind,
2750
+ errorCode: "SPEAKER_HAS_REFS",
2751
+ });
2752
+ }
2753
+ const replacement = strOf(op["replacement"]).trim();
2754
+ if (strategy === "replace") {
2755
+ if (!replacement) {
2756
+ throw opErr("SCRIPT OP BLOCKED: Replacement missing", "Replacement missing.", {
2757
+ required: ["replacement speaker id"],
2758
+ received: ["<empty>"],
2759
+ nextSteps: ["Pass --replacement <spk_id>."],
2760
+ op: kind,
2761
+ errorCode: "REPLACEMENT_MISSING",
2762
+ });
2763
+ }
2764
+ // Verify replacement exists.
2765
+ if (!asList(script["speakers"]).some((s) => strOf(s["speaker_id"]) === replacement)) {
2766
+ throw opErr("SCRIPT OP BLOCKED: Replacement speaker not found", "Replacement speaker not found.", {
2767
+ required: ["existing speaker id"],
2768
+ received: [replacement],
2769
+ nextSteps: ["Add the speaker first or use a different replacement."],
2770
+ op: kind,
2771
+ errorCode: "SPEAKER_NOT_FOUND",
2772
+ });
2773
+ }
2774
+ for (const ep of asList(script["episodes"])) {
2775
+ for (const scene of asList(ep["scenes"])) {
2776
+ for (const action of asList(scene["actions"])) {
2777
+ if (strOf(action["speaker_id"]) === speakerId)
2778
+ action["speaker_id"] = replacement;
2779
+ const speakers = asList(action["speakers"]);
2780
+ for (const sp of speakers) {
2781
+ if (strOf(sp["speaker_id"]) === speakerId)
2782
+ sp["speaker_id"] = replacement;
2783
+ }
2784
+ // Dedupe action.speakers[] in case the action already referenced
2785
+ // the replacement: an unconditional rewrite + no dedupe would
2786
+ // leave [spk_replacement, spk_replacement], breaking overlap/
2787
+ // simultaneous delivery counts downstream.
2788
+ if (action["speakers"] !== undefined) {
2789
+ const seen = new Set();
2790
+ action["speakers"] = speakers.filter((s) => {
2791
+ const id = strOf(s["speaker_id"]);
2792
+ if (seen.has(id))
2793
+ return false;
2794
+ seen.add(id);
2795
+ return true;
2796
+ });
2797
+ }
2798
+ const lines = asList(action["lines"]);
2799
+ for (const line of lines) {
2800
+ if (strOf(line["speaker_id"]) === speakerId)
2801
+ line["speaker_id"] = replacement;
2802
+ }
2803
+ // lines[] semantically represents distinct overlapping lines
2804
+ // (a speaker can legitimately overlap with themselves), so we do
2805
+ // NOT dedupe lines — preserve overlap-with-self if it existed.
2806
+ }
2807
+ }
2808
+ }
2809
+ }
2810
+ else if (strategy === "remove" || refs.length === 0) {
2811
+ for (const ep of asList(script["episodes"])) {
2812
+ for (const scene of asList(ep["scenes"])) {
2813
+ for (const action of asList(scene["actions"])) {
2814
+ if (strOf(action["speaker_id"]) === speakerId)
2815
+ delete action["speaker_id"];
2816
+ const speakers = asList(action["speakers"]).filter((s) => strOf(s["speaker_id"]) !== speakerId);
2817
+ if (action["speakers"] !== undefined) {
2818
+ if (speakers.length > 0)
2819
+ action["speakers"] = speakers;
2820
+ else
2821
+ delete action["speakers"];
2822
+ }
2823
+ const lines = asList(action["lines"]).filter((l) => strOf(l["speaker_id"]) !== speakerId);
2824
+ if (action["lines"] !== undefined) {
2825
+ if (lines.length > 0)
2826
+ action["lines"] = lines;
2827
+ else
2828
+ delete action["lines"];
2829
+ }
2830
+ }
2831
+ }
2832
+ }
2833
+ }
2834
+ else {
2835
+ throw opErr("SCRIPT OP BLOCKED: Strategy invalid", "Strategy invalid.", {
2836
+ required: ["strategy: replace or remove"],
2837
+ received: [strategy],
2838
+ nextSteps: ["Use replace (with --replacement) or remove."],
2839
+ op: kind,
2840
+ errorCode: "DELETE_STRATEGY_INVALID",
2841
+ });
2842
+ }
2843
+ script["speakers"] = asList(script["speakers"]).filter((s) => strOf(s["speaker_id"]) !== speakerId);
2844
+ applied.push(kind);
2845
+ }
2846
+ else if (kind === "context.ref.remove") {
2847
+ const [epId, sceneId] = parseSceneRef(op["at"]);
2848
+ const [targetKind, targetId] = parseAssetTarget(op["target"]);
2849
+ const scene = findScene(script, epId, sceneId);
2850
+ const ctx = sceneContext(scene);
2851
+ const idKey = idKeyForKind(targetKind);
2852
+ ctx[pluralForKind(targetKind)] = contextRefsForKind(ctx, targetKind).filter((ref) => strOf(ref[idKey]) !== targetId);
2853
+ setSceneContext(scene, ctx);
2854
+ applied.push(kind);
2855
+ }
1888
2856
  else {
1889
2857
  throw opErr("PATCH BLOCKED: Unsupported operation", "Unsupported patch operation.", {
1890
2858
  required: [
1891
- "supported op: set_worldview, set_actor_role_type, set_asset_description, set_action_type, rename_*, merge_*, set_asset_aliases, set_asset_states, set_scene_*_ref, set_action_actor, merge_scenes, split_scene, set_action_content_from_span",
2859
+ "dot-style op: meta.worldview.set / asset.{rename,describe,alias.set,alias.add,alias.remove,states.set,role.set,merge,delete} / state.{add,rename,describe,delete} / context.{set,clear,ref.remove} / action.{type.set,actor.set,delete,insert,move,state.change,state.remove,transition.set,transition.clear,content.replace} / scene.{split,merge,delete,move} / dialogue.{speakers,overlap} / speaker.add",
1892
2860
  ],
1893
2861
  received: [kind],
1894
- nextSteps: ["Use a supported patch operation."],
2862
+ nextSteps: [
2863
+ "Use a supported dot-style patch operation.",
2864
+ "Legacy snake-style ops (set_worldview / rename_actor / set_action_type / merge_scenes / split_scene / set_scene_*_ref / set_action_actor / set_asset_aliases / set_asset_states / set_action_content_from_span etc.) were removed in 0.6.0; use the new dot-style equivalents.",
2865
+ ],
2866
+ errorCode: "OP_UNSUPPORTED",
1895
2867
  });
1896
2868
  }
1897
2869
  }