@lingjingai/scriptctl 0.13.1 → 0.19.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.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +33 -49
  3. package/dist/cli.js.map +1 -1
  4. package/dist/common.d.ts +11 -4
  5. package/dist/common.js +83 -61
  6. package/dist/common.js.map +1 -1
  7. package/dist/domain/direct-core.d.ts +27 -4
  8. package/dist/domain/direct-core.js +281 -55
  9. package/dist/domain/direct-core.js.map +1 -1
  10. package/dist/domain/script-core.d.ts +15 -0
  11. package/dist/domain/script-core.js +207 -40
  12. package/dist/domain/script-core.js.map +1 -1
  13. package/dist/help-text.js +45 -315
  14. package/dist/help-text.js.map +1 -1
  15. package/dist/infra/providers.d.ts +10 -36
  16. package/dist/infra/providers.js +144 -300
  17. package/dist/infra/providers.js.map +1 -1
  18. package/dist/usecases/direct.js +342 -60
  19. package/dist/usecases/direct.js.map +1 -1
  20. package/dist/usecases/doctor.js +2 -5
  21. package/dist/usecases/doctor.js.map +1 -1
  22. package/dist/usecases/parse.js +104 -14
  23. package/dist/usecases/parse.js.map +1 -1
  24. package/dist/usecases/script.d.ts +7 -2
  25. package/dist/usecases/script.js +180 -12
  26. package/dist/usecases/script.js.map +1 -1
  27. package/package.json +2 -3
  28. package/dist/domain/asset-registry.d.ts +0 -141
  29. package/dist/domain/asset-registry.js +0 -318
  30. package/dist/domain/asset-registry.js.map +0 -1
  31. package/dist/domain/collision-detector.d.ts +0 -83
  32. package/dist/domain/collision-detector.js +0 -248
  33. package/dist/domain/collision-detector.js.map +0 -1
  34. package/dist/infra/default-writing-prompt.d.ts +0 -31
  35. package/dist/infra/default-writing-prompt.js +0 -50
  36. package/dist/infra/default-writing-prompt.js.map +0 -1
  37. package/dist/infra/default-writing-prompt.md +0 -115
  38. package/dist/infra/gemini-writer.d.ts +0 -107
  39. package/dist/infra/gemini-writer.js +0 -207
  40. package/dist/infra/gemini-writer.js.map +0 -1
  41. package/dist/usecases/episode.d.ts +0 -48
  42. package/dist/usecases/episode.js +0 -1242
  43. package/dist/usecases/episode.js.map +0 -1
@@ -2,6 +2,16 @@ type Dict = Record<string, unknown>;
2
2
  export declare function findAction(script: Dict, epId: string, sceneId: string, actionIndex: number): Dict;
3
3
  export declare function findEpisode(script: Dict, epId: string): Dict;
4
4
  export declare function findScene(script: Dict, epId: string, sceneId: string): Dict;
5
+ /**
6
+ * Minimal schema-v2 script skeleton: every required top-level field present,
7
+ * all collections empty. It is the starting point for building a script
8
+ * bottom-up with the atomic add/insert verbs instead of assembling it from
9
+ * source material (direct) or md (parse). `worldview`/`style`/`title` are
10
+ * present-but-empty so the field-presence checks pass; they get filled in
11
+ * later via `worldview` / `patch`. A blank script validates clean: NO_EPISODES
12
+ * is a completeness warning (`incomplete_ok`), not a blocker.
13
+ */
14
+ export declare function blankScript(title?: string): Dict;
5
15
  /**
6
16
  * Validate a script's schema and asset wiring.
7
17
  *
@@ -11,6 +21,11 @@ export declare function findScene(script: Dict, epId: string, sceneId: string):
11
21
  *
12
22
  * Callers that already have the script as a parsed object (episode draft / publish
13
23
  * after assembly) should prefer `scriptData` to avoid the write-validate-rm dance.
24
+ *
25
+ * Completeness issues (no episodes, empty episode/scene, scene without location,
26
+ * missing asset description) are emitted as warnings (`incomplete_ok`), not
27
+ * errors — an unfinished but structurally-sound script passes. Only integrity
28
+ * violations (dangling refs, dup ids, invalid enums, missing ids/names) fail it.
14
29
  */
15
30
  export declare function validateScript(workspace: string, scriptPath?: string | null, opts?: {
16
31
  requireSource?: boolean;
@@ -63,6 +63,54 @@ export function findScene(script, epId, sceneId) {
63
63
  });
64
64
  }
65
65
  // ---------------------------------------------------------------------------
66
+ // blank scaffold
67
+ // ---------------------------------------------------------------------------
68
+ /**
69
+ * Minimal schema-v2 script skeleton: every required top-level field present,
70
+ * all collections empty. It is the starting point for building a script
71
+ * bottom-up with the atomic add/insert verbs instead of assembling it from
72
+ * source material (direct) or md (parse). `worldview`/`style`/`title` are
73
+ * present-but-empty so the field-presence checks pass; they get filled in
74
+ * later via `worldview` / `patch`. A blank script validates clean: NO_EPISODES
75
+ * is a completeness warning (`incomplete_ok`), not a blocker.
76
+ */
77
+ export function blankScript(title = "") {
78
+ return {
79
+ version: SCRIPT_SCHEMA_VERSION,
80
+ title: title,
81
+ worldview: "",
82
+ style: "",
83
+ actors: [],
84
+ locations: [],
85
+ props: [],
86
+ speakers: [],
87
+ episodes: [],
88
+ };
89
+ }
90
+ // Completeness vs. integrity. A script can be structurally sound but unfinished
91
+ // (no episodes yet, an empty episode/scene, a scene without a location, an asset
92
+ // without a description). Those gaps are emitted as `severity: "warning"` with
93
+ // `incomplete_ok: true` directly at their source below — they never block, so an
94
+ // unfinished-but-sound script is editable AND publishable. Only integrity
95
+ // violations (dangling refs, duplicate ids, invalid enums, missing ids/names —
96
+ // things that would corrupt the normalized DB graph) are `severity: "error"`.
97
+ // One SCHEMA_ENTITY_FIELD_MISSING issue for a missing asset field. A missing
98
+ // description is a completeness gap (warning); a missing id/name is an identity
99
+ // error that would dangle in the DB graph. Shared by the actor/location/prop
100
+ // validation loops so the severity/incomplete_ok/repair-hint policy lives once.
101
+ function entityFieldMissingIssue(kindLabel, field, idValue) {
102
+ const isDescription = field === "description";
103
+ return {
104
+ code: "SCHEMA_ENTITY_FIELD_MISSING",
105
+ severity: isDescription ? "warning" : "error",
106
+ ...(isDescription ? { incomplete_ok: true } : {}),
107
+ summary: `${kindLabel} missing ${field}: ${idValue}`,
108
+ repair_hint: isDescription
109
+ ? "Describe with `describe` when ready (an undescribed asset is publishable)."
110
+ : `Patch ${kindLabel.toLowerCase()} metadata.`,
111
+ };
112
+ }
113
+ // ---------------------------------------------------------------------------
66
114
  // validate_script
67
115
  // ---------------------------------------------------------------------------
68
116
  /**
@@ -74,6 +122,11 @@ export function findScene(script, epId, sceneId) {
74
122
  *
75
123
  * Callers that already have the script as a parsed object (episode draft / publish
76
124
  * after assembly) should prefer `scriptData` to avoid the write-validate-rm dance.
125
+ *
126
+ * Completeness issues (no episodes, empty episode/scene, scene without location,
127
+ * missing asset description) are emitted as warnings (`incomplete_ok`), not
128
+ * errors — an unfinished but structurally-sound script passes. Only integrity
129
+ * violations (dangling refs, dup ids, invalid enums, missing ids/names) fail it.
77
130
  */
78
131
  export function validateScript(workspace, scriptPath = null, opts = {}) {
79
132
  const requireSource = opts.requireSource ?? true;
@@ -175,12 +228,7 @@ export function validateScript(workspace, scriptPath = null, opts = {}) {
175
228
  const actorName = strOf(actor["actor_name"]).trim();
176
229
  for (const field of ["actor_id", "actor_name", "description"]) {
177
230
  if (!strOf(actor[field]).trim()) {
178
- issues.push({
179
- code: "SCHEMA_ENTITY_FIELD_MISSING",
180
- severity: "error",
181
- summary: `Actor missing ${field}: ${actor["actor_id"] || idx}`,
182
- repair_hint: "Patch actor metadata.",
183
- });
231
+ issues.push(entityFieldMissingIssue("Actor", field, strOf(actor["actor_id"]) || String(idx)));
184
232
  }
185
233
  }
186
234
  if (actorName && cleanName(actorName) !== actorName) {
@@ -223,12 +271,7 @@ export function validateScript(workspace, scriptPath = null, opts = {}) {
223
271
  const locationName = strOf(loc["location_name"]).trim();
224
272
  for (const field of ["location_id", "location_name", "description"]) {
225
273
  if (!strOf(loc[field]).trim()) {
226
- issues.push({
227
- code: "SCHEMA_ENTITY_FIELD_MISSING",
228
- severity: "error",
229
- summary: `Location missing ${field}: ${loc["location_id"] || idx}`,
230
- repair_hint: "Patch location metadata.",
231
- });
274
+ issues.push(entityFieldMissingIssue("Location", field, strOf(loc["location_id"]) || String(idx)));
232
275
  }
233
276
  }
234
277
  if (locationName && cleanName(locationName) !== locationName) {
@@ -255,12 +298,7 @@ export function validateScript(workspace, scriptPath = null, opts = {}) {
255
298
  const propName = strOf(prop["prop_name"]).trim();
256
299
  for (const field of ["prop_id", "prop_name", "description"]) {
257
300
  if (!strOf(prop[field]).trim()) {
258
- issues.push({
259
- code: "SCHEMA_ENTITY_FIELD_MISSING",
260
- severity: "error",
261
- summary: `Prop missing ${field}: ${prop["prop_id"] || idx}`,
262
- repair_hint: "Patch prop metadata.",
263
- });
301
+ issues.push(entityFieldMissingIssue("Prop", field, strOf(prop["prop_id"]) || String(idx)));
264
302
  }
265
303
  }
266
304
  if (propName && cleanName(propName) !== propName) {
@@ -424,7 +462,7 @@ export function validateScript(workspace, scriptPath = null, opts = {}) {
424
462
  }
425
463
  const episodes = asList(script["episodes"]);
426
464
  if (episodes.length === 0) {
427
- issues.push({ code: "NO_EPISODES", severity: "blocking", summary: "No episodes in script.", repair_hint: "Rerun init or patch episodes." });
465
+ issues.push({ code: "NO_EPISODES", severity: "warning", incomplete_ok: true, summary: "No episodes in script.", repair_hint: "Add episodes with add-episode (an empty script is publishable but renders to nothing)." });
428
466
  }
429
467
  let totalScenes = 0;
430
468
  let totalActions = 0;
@@ -444,10 +482,11 @@ export function validateScript(workspace, scriptPath = null, opts = {}) {
444
482
  if (scenes.length === 0) {
445
483
  issues.push({
446
484
  code: "EPISODE_WITHOUT_SCENES",
447
- severity: "error",
485
+ severity: "warning",
486
+ incomplete_ok: true,
448
487
  episode: ep["episode_id"],
449
488
  summary: "Episode has no scenes.",
450
- repair_hint: "Patch at least one scene into the episode.",
489
+ repair_hint: "Add scenes with insert (an empty episode is publishable but renders to nothing).",
451
490
  });
452
491
  }
453
492
  for (const scene of scenes) {
@@ -483,11 +522,12 @@ export function validateScript(workspace, scriptPath = null, opts = {}) {
483
522
  if (ctxLocations.length === 0) {
484
523
  issues.push({
485
524
  code: "SCENE_WITHOUT_LOCATION",
486
- severity: "error",
525
+ severity: "warning",
526
+ incomplete_ok: true,
487
527
  episode: ep["episode_id"],
488
528
  scene: scene["scene_id"],
489
529
  summary: "Scene has no location.",
490
- repair_hint: "Patch scene.locations with a registered location_id.",
530
+ repair_hint: "Set a location with context (an unlocated scene is publishable but cannot render).",
491
531
  });
492
532
  }
493
533
  for (const ref of ctxLocations) {
@@ -566,11 +606,12 @@ export function validateScript(workspace, scriptPath = null, opts = {}) {
566
606
  if (actionsArr.length === 0) {
567
607
  issues.push({
568
608
  code: "SCENE_WITHOUT_ACTIONS",
569
- severity: "error",
609
+ severity: "warning",
610
+ incomplete_ok: true,
570
611
  episode: ep["episode_id"],
571
612
  scene: scene["scene_id"],
572
613
  summary: "Scene has no actions.",
573
- repair_hint: "Patch actions from source spans.",
614
+ repair_hint: "Add actions with insert (an empty scene is publishable but renders to nothing).",
574
615
  });
575
616
  }
576
617
  for (let actionIndex = 0; actionIndex < actionsArr.length; actionIndex++) {
@@ -936,17 +977,14 @@ function resolvePatchAsset(script, op, assetType = null) {
936
977
  return [asset, idKey, nameKey];
937
978
  }
938
979
  function nextStateId(script) {
939
- let maxSeen = 0;
980
+ const ids = [];
940
981
  for (const key of ["actors", "locations", "props"]) {
941
982
  for (const asset of asList(script[key])) {
942
- for (const state of asList(asset["states"])) {
943
- const m = /^st_(\d+)$/.exec(strOf(state["state_id"]));
944
- if (m)
945
- maxSeen = Math.max(maxSeen, parseInt(m[1], 10));
946
- }
983
+ for (const state of asList(asset["states"]))
984
+ ids.push(state["state_id"]);
947
985
  }
948
986
  }
949
- return fmtId("st", maxSeen + 1);
987
+ return nextSeqId(ids, "st");
950
988
  }
951
989
  function normalizeStates(script, rawStates, assetKind = "") {
952
990
  if (!isList(rawStates)) {
@@ -1461,16 +1499,31 @@ export function collectAssetRefs(script, kind, targetId) {
1461
1499
  }
1462
1500
  return refs;
1463
1501
  }
1464
- function nextSceneId(script) {
1502
+ // Asset id prefixes. props use `prp_` (not `prop_`); mirrors direct-init output.
1503
+ const ASSET_ID_PREFIX = { actor: "act", location: "loc", prop: "prp" };
1504
+ // Next free `<prefix>_NNN` id given the existing ids in a collection. Scans for
1505
+ // the max NNN so a manually-chosen id (e.g. act_007) never collides with a later
1506
+ // auto-id. The single id-allocation routine behind nextScene/Asset/Episode/State.
1507
+ function nextSeqId(existingIds, prefix) {
1508
+ const re = new RegExp(`^${prefix}_(\\d+)$`);
1465
1509
  let maxSeen = 0;
1466
- for (const ep of asList(script["episodes"])) {
1467
- for (const scene of asList(ep["scenes"])) {
1468
- const m = /^scn_(\d+)$/.exec(strOf(scene["scene_id"]));
1469
- if (m)
1470
- maxSeen = Math.max(maxSeen, parseInt(m[1], 10));
1471
- }
1510
+ for (const raw of existingIds) {
1511
+ const m = re.exec(strOf(raw));
1512
+ if (m)
1513
+ maxSeen = Math.max(maxSeen, parseInt(m[1], 10));
1472
1514
  }
1473
- return fmtId("scn", maxSeen + 1);
1515
+ return fmtId(prefix, maxSeen + 1);
1516
+ }
1517
+ function nextSceneId(script) {
1518
+ const ids = asList(script["episodes"]).flatMap((ep) => asList(ep["scenes"]).map((s) => s["scene_id"]));
1519
+ return nextSeqId(ids, "scn");
1520
+ }
1521
+ function nextAssetId(script, kind) {
1522
+ const [key, idKey] = assetKeys(kind);
1523
+ return nextSeqId(asList(script[key]).map((item) => item[idKey]), ASSET_ID_PREFIX[kind]);
1524
+ }
1525
+ function nextEpisodeId(script) {
1526
+ return nextSeqId(asList(script["episodes"]).map((ep) => ep["episode_id"]), "ep");
1474
1527
  }
1475
1528
  function sceneIdExists(script, sceneId) {
1476
1529
  for (const ep of asList(script["episodes"])) {
@@ -1481,6 +1534,53 @@ function sceneIdExists(script, sceneId) {
1481
1534
  }
1482
1535
  return false;
1483
1536
  }
1537
+ // Shared body for actor.add / location.add / prop.add: validate the name,
1538
+ // allocate or accept an id, reject duplicates, and append a minimal entity
1539
+ // ({id, name, description, states:[]}). The caller layers on kind-specific
1540
+ // fields (actor role_type/aliases). Returns the pushed entity so the caller can
1541
+ // extend it. description defaults to "" — an empty description is a completeness
1542
+ // warning, not a hard error, so you can register an asset now and describe it
1543
+ // later.
1544
+ function pushAsset(script, kind, op) {
1545
+ const [collKey, idKey, nameKey] = assetKeys(kind);
1546
+ const name = strOf(op["name"] ?? op[nameKey]).trim();
1547
+ if (!name) {
1548
+ throw opErr("SCRIPT OP BLOCKED: Asset name empty", "Asset name empty.", {
1549
+ required: ["name"], received: [JSON.stringify(op)],
1550
+ nextSteps: [`Provide a ${kind} name.`], op: `${kind}.add`, errorCode: "ASSET_NAME_EMPTY",
1551
+ });
1552
+ }
1553
+ // name is already whitespace-trimmed; cleanName additionally strips trailing/
1554
+ // leading punctuation (:,、…) and embedded 【】() state annotations. Reuse the
1555
+ // same errorCode validateScript uses for this rule (ASSET_NAME_HAS_STATE_ANNOTATION)
1556
+ // so op-time and validate-time speak one vocabulary; the message stays accurate
1557
+ // for the punctuation-only case too. Suggest the canonical form when there is one
1558
+ // (a name that is entirely punctuation/annotation reduces to "").
1559
+ const canonical = cleanName(name);
1560
+ if (canonical !== name) {
1561
+ const suggestion = canonical
1562
+ ? `Use "${canonical}", and register durable looks with state-add instead of putting them in the name.`
1563
+ : "Provide a real name (this one is only punctuation/annotation); register durable looks with state-add.";
1564
+ throw opErr("SCRIPT OP BLOCKED: Asset name not canonical", "Asset name is not canonical (trailing/leading punctuation or an embedded state annotation).", {
1565
+ required: [canonical ? `canonical name, e.g. "${canonical}"` : "a canonical name without punctuation/annotation"],
1566
+ received: [name],
1567
+ nextSteps: [suggestion], op: `${kind}.add`, errorCode: "ASSET_NAME_HAS_STATE_ANNOTATION",
1568
+ });
1569
+ }
1570
+ const id = strOf(op[idKey] ?? op["id"]).trim() || nextAssetId(script, kind);
1571
+ if (!isList(script[collKey]))
1572
+ script[collKey] = [];
1573
+ const coll = script[collKey];
1574
+ if (coll.some((a) => strOf(a[idKey]) === id)) {
1575
+ throw opErr("SCRIPT OP BLOCKED: Asset id exists", "Asset id exists.", {
1576
+ required: ["unused id"], received: [id],
1577
+ nextSteps: ["Use another --id or omit it to auto-allocate."], op: `${kind}.add`, errorCode: "ASSET_ID_EXISTS",
1578
+ });
1579
+ }
1580
+ const entity = { [idKey]: id, [nameKey]: name, description: strOf(op["description"]).trim(), states: [] };
1581
+ coll.push(entity);
1582
+ return entity;
1583
+ }
1484
1584
  function renumberSceneIds(script) {
1485
1585
  let counter = 0;
1486
1586
  for (const ep of asList(script["episodes"])) {
@@ -1512,6 +1612,10 @@ export const PATCH_OP_SCHEMA = {
1512
1612
  "state.describe": { required: ["target", "description"], optional: [], description: "Set a state's description." },
1513
1613
  "state.delete": { required: ["target", "strategy"], optional: ["replacement"], description: "Delete a state. strategy: replace|remove." },
1514
1614
  // asset
1615
+ "actor.add": { required: ["name"], optional: ["actor_id", "role_type", "description", "aliases"], description: "Register a new actor (human/character). role_type: 主角|配角." },
1616
+ "location.add": { required: ["name"], optional: ["location_id", "description"], description: "Register a new location." },
1617
+ "prop.add": { required: ["name"], optional: ["prop_id", "description"], description: "Register a new prop." },
1618
+ "episode.add": { required: [], optional: ["episode_id", "title"], description: "Append a new empty episode (no scenes)." },
1515
1619
  "asset.rename": { required: ["target", "name"], optional: [], description: "Rename an asset (actor / location / prop)." },
1516
1620
  "asset.describe": { required: ["target", "description"], optional: [], description: "Set asset description." },
1517
1621
  "asset.alias.set": { required: ["target", "aliases"], optional: [], description: "Bulk replace aliases[]." },
@@ -1909,6 +2013,69 @@ export function applyPatchOperations(script, sourceText, operations) {
1909
2013
  delete action["speaker_id"];
1910
2014
  applied.push(kind);
1911
2015
  }
2016
+ else if (kind === "actor.add") {
2017
+ const name = strOf(op["name"] ?? op["actor_name"]).trim();
2018
+ // Reject non-human speakers as actors up front (validate would flag
2019
+ // NONHUMAN_AS_ACTOR otherwise) — system/broadcast/prop voices belong in
2020
+ // speakers[], not actors[].
2021
+ if (name && inferNonActorSpeakerKind(name) !== "actor") {
2022
+ throw opErr("SCRIPT OP BLOCKED: Non-human as actor", "Non-human speaker registered as actor.", {
2023
+ required: ["a human/character name"], received: [name],
2024
+ nextSteps: ["Register non-human voices with add-speaker (kind system/broadcast/prop/group/other)."],
2025
+ op: kind, errorCode: "NONHUMAN_AS_ACTOR",
2026
+ });
2027
+ }
2028
+ const entity = pushAsset(script, "actor", op);
2029
+ const roleType = strOf(op["role_type"] ?? op["role"]).trim();
2030
+ if (roleType) {
2031
+ if (!ROLE_TYPE_VALUES.includes(roleType)) {
2032
+ throw opErr("SCRIPT OP BLOCKED: Role type invalid", "Role type invalid.", {
2033
+ required: ["role_type: 主角 or 配角"], received: [roleType],
2034
+ nextSteps: ["Use a supported actor role_type, or omit to leave it unset."],
2035
+ op: kind, errorCode: "ROLE_TYPE_INVALID",
2036
+ });
2037
+ }
2038
+ entity["role_type"] = roleType;
2039
+ }
2040
+ // Only stamp aliases when there's at least one — keeps the entity shape
2041
+ // identical to direct/parse-produced actors (which omit the key when empty).
2042
+ const aliases = isList(op["aliases"]) ? op["aliases"].map((a) => strOf(a).trim()).filter(Boolean) : [];
2043
+ if (aliases.length)
2044
+ entity["aliases"] = aliases;
2045
+ applied.push(kind);
2046
+ }
2047
+ else if (kind === "location.add") {
2048
+ pushAsset(script, "location", op);
2049
+ applied.push(kind);
2050
+ }
2051
+ else if (kind === "prop.add") {
2052
+ pushAsset(script, "prop", op);
2053
+ applied.push(kind);
2054
+ }
2055
+ else if (kind === "episode.add") {
2056
+ // Append an empty episode. Scenes/actions are added afterwards with
2057
+ // scene.insert / action.insert. episode_id auto-allocates as ep_NNN.
2058
+ const epId = strOf(op["episode_id"] ?? op["id"]).trim() || nextEpisodeId(script);
2059
+ if (!/^ep_\d+$/.test(epId)) {
2060
+ throw opErr("SCRIPT OP BLOCKED: Episode id invalid", "Episode id must use ep_NNN format.", {
2061
+ required: ["episode_id: ep_NNN"], received: [epId],
2062
+ nextSteps: ["Pass --id ep_004 or omit to auto-allocate."],
2063
+ op: kind, errorCode: "EPISODE_ID_INVALID",
2064
+ });
2065
+ }
2066
+ if (!isList(script["episodes"]))
2067
+ script["episodes"] = [];
2068
+ const eps = script["episodes"];
2069
+ if (eps.some((e) => strOf(e["episode_id"]) === epId)) {
2070
+ throw opErr("SCRIPT OP BLOCKED: Episode id exists", "Episode id exists.", {
2071
+ required: ["unused episode_id"], received: [epId],
2072
+ nextSteps: ["Use another --id or omit it to auto-allocate."],
2073
+ op: kind, errorCode: "EPISODE_ID_EXISTS",
2074
+ });
2075
+ }
2076
+ eps.push({ episode_id: epId, title: strOf(op["title"]).trim(), scenes: [] });
2077
+ applied.push(kind);
2078
+ }
1912
2079
  else if (kind === "meta.worldview.set") {
1913
2080
  // 0.6.0 dot-style equivalent of the legacy `set_worldview` op.
1914
2081
  const worldview = strOf(op["worldview"] ?? op["value"]).trim();