@lingjingai/scriptctl 0.31.0 → 0.33.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 (34) hide show
  1. package/changes/0.32.0.md +30 -0
  2. package/changes/0.33.0.md +17 -0
  3. package/dist/common.d.ts +1 -0
  4. package/dist/common.js +70 -0
  5. package/dist/common.js.map +1 -1
  6. package/dist/domain/direct/runner.d.ts +3 -1
  7. package/dist/domain/direct/runner.js +20 -4
  8. package/dist/domain/direct/runner.js.map +1 -1
  9. package/dist/domain/direct/stage.d.ts +23 -1
  10. package/dist/domain/direct/stage.js +3 -0
  11. package/dist/domain/direct/stage.js.map +1 -1
  12. package/dist/domain/direct/stages/asset-curation.js +3 -2
  13. package/dist/domain/direct/stages/asset-curation.js.map +1 -1
  14. package/dist/domain/direct/stages/index.d.ts +3 -1
  15. package/dist/domain/direct/stages/index.js +5 -1
  16. package/dist/domain/direct/stages/index.js.map +1 -1
  17. package/dist/domain/direct/stages/state-binding.d.ts +2 -0
  18. package/dist/domain/direct/stages/state-binding.js +9 -0
  19. package/dist/domain/direct/stages/state-binding.js.map +1 -0
  20. package/dist/domain/direct/stages/state-curation.d.ts +2 -0
  21. package/dist/domain/direct/stages/state-curation.js +10 -0
  22. package/dist/domain/direct/stages/state-curation.js.map +1 -0
  23. package/dist/domain/direct-core.d.ts +78 -0
  24. package/dist/domain/direct-core.js +4252 -239
  25. package/dist/domain/direct-core.js.map +1 -1
  26. package/dist/help-text.js +60 -1
  27. package/dist/help-text.js.map +1 -1
  28. package/dist/infra/providers.d.ts +12 -0
  29. package/dist/infra/providers.js +590 -3
  30. package/dist/infra/providers.js.map +1 -1
  31. package/dist/usecases/direct.d.ts +2 -0
  32. package/dist/usecases/direct.js +1533 -53
  33. package/dist/usecases/direct.js.map +1 -1
  34. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { ACTION_TYPE_VALUES, CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_PROVIDER_ATTEMPTS, EXIT_NEEDS_AGENT, EXIT_RUNTIME, ROLE_TYPE_VALUES, SCRIPT_SCHEMA_VERSION, SCRIPT_TARGET_KINDS, WORLDVIEW_VALUES, fmtId, } from "../common.js";
1
+ import { ACTION_TYPE_VALUES, CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_PROVIDER_ATTEMPTS, EXIT_NEEDS_AGENT, EXIT_RUNTIME, ROLE_TYPE_VALUES, SCRIPT_SCHEMA_VERSION, SCRIPT_TARGET_KINDS, SPEAKER_SOURCE_KINDS, WORLDVIEW_VALUES, fmtId, } from "../common.js";
2
2
  // ---------------------------------------------------------------------------
3
3
  // Regexes & lookup tables (shared with infra/converters.ts via re-export)
4
4
  // ---------------------------------------------------------------------------
@@ -2872,238 +2872,3249 @@ export function sceneAssetUsage(script, refKey, idKey) {
2872
2872
  }
2873
2873
  return usage;
2874
2874
  }
2875
- function compactStateNames(asset) {
2876
- const out = [];
2877
- for (const state of asList(asset["states"])) {
2878
- if (!isDict(state))
2879
- continue;
2880
- const n = strOf(state["state_name"]).trim();
2881
- if (n)
2882
- out.push(n);
2875
+ function actionPreviewForAsset(script, kind, assetId, limit = 1) {
2876
+ const idKey = assetIdKey(kind);
2877
+ const refKey = assetListKey(kind);
2878
+ const previews = [];
2879
+ for (const ep of asList(script["episodes"])) {
2880
+ const episodeId = strOf(ep["episode_id"]);
2881
+ for (const scene of asList(ep["scenes"])) {
2882
+ const sceneId = strOf(scene["scene_id"]);
2883
+ const ctx = sceneContext(scene);
2884
+ const inScene = asList(ctx[refKey]).some((ref) => isDict(ref) && strOf(ref[idKey]) === assetId);
2885
+ if (!inScene)
2886
+ continue;
2887
+ const action = asList(scene["actions"]).map((it) => strOf(it["content"]).trim()).find((it) => it);
2888
+ previews.push(`${episodeId}/${sceneId}: ${action ? action.slice(0, 100) : "(no action)"}`);
2889
+ if (previews.length >= limit)
2890
+ return previews;
2891
+ }
2883
2892
  }
2884
- return out;
2893
+ return previews;
2885
2894
  }
2886
- function compactAssetRefs(refs, idKey, replacements, droppedIds) {
2887
- const compacted = [];
2888
- const seen = new Set();
2889
- for (const ref of asList(refs)) {
2890
- if (!isDict(ref))
2891
- continue;
2892
- const rawId = strOf(ref[idKey]);
2893
- if (!rawId || droppedIds.has(rawId))
2894
- continue;
2895
- const nextId = replacements.get(rawId) ?? rawId;
2896
- const stateId = nextId !== rawId ? null : ref["state_id"];
2897
- const key = `${nextId}::${strOf(stateId)}`;
2898
- if (seen.has(key))
2899
- continue;
2900
- seen.add(key);
2901
- compacted.push({ [idKey]: nextId, state_id: stateId });
2902
- }
2903
- return compacted;
2895
+ function emptyCurationEvidence() {
2896
+ return {
2897
+ sceneRefs: new Set(),
2898
+ actionRefs: new Set(),
2899
+ speakingScenes: new Set(),
2900
+ dialogueCount: 0,
2901
+ actionMentionCount: 0,
2902
+ speakerIds: new Set(),
2903
+ examples: [],
2904
+ dialogueExamples: [],
2905
+ coActors: new Set(),
2906
+ coLocations: new Set(),
2907
+ coProps: new Set(),
2908
+ };
2904
2909
  }
2905
- function addLocationAlias(target, aliasRaw) {
2906
- const alias = cleanName(aliasRaw);
2907
- const targetName = strOf(target["location_name"]).trim();
2908
- if (!alias || alias === targetName)
2910
+ function pushLimitedUnique(out, value, limit) {
2911
+ const text = value.trim();
2912
+ if (!text || out.includes(text) || out.length >= limit)
2909
2913
  return;
2910
- if (!isList(target["aliases"]))
2911
- target["aliases"] = [];
2912
- uniqueAdd(target["aliases"], alias);
2914
+ out.push(text);
2913
2915
  }
2914
- export function applyLocationMerges(script, replacements) {
2915
- if (replacements.size === 0)
2916
- return;
2917
- const locations = asList(script["locations"]);
2918
- const byId = new Map();
2919
- for (const loc of locations) {
2920
- if (isDict(loc))
2921
- byId.set(strOf(loc["location_id"]), loc);
2922
- }
2923
- for (const [oldId, targetId] of replacements) {
2924
- const old = byId.get(oldId);
2925
- const target = byId.get(targetId);
2926
- if (!old || !target)
2916
+ function speakerSourceMap(script) {
2917
+ const out = new Map();
2918
+ for (const speaker of asList(script["speakers"])) {
2919
+ if (!isDict(speaker))
2927
2920
  continue;
2928
- addLocationAlias(target, strOf(old["location_name"]));
2929
- for (const alias of asList(old["aliases"])) {
2930
- addLocationAlias(target, strOf(alias));
2931
- }
2921
+ const speakerId = strOf(speaker["speaker_id"]);
2922
+ if (speakerId)
2923
+ out.set(speakerId, speaker);
2932
2924
  }
2933
- script["locations"] = locations.filter((loc) => isDict(loc) && !replacements.has(strOf(loc["location_id"])));
2934
- }
2935
- function sceneCountFromUsage(usage) {
2936
- return usage.scenes.size;
2925
+ return out;
2937
2926
  }
2938
- export function actorCurationDecisions(script) {
2939
- const usage = sceneAssetUsage(script, "actors", "actor_id");
2940
- const decisions = [];
2941
- for (const actor of asList(script["actors"])) {
2942
- if (!isDict(actor))
2943
- continue;
2944
- const actorId = strOf(actor["actor_id"]);
2945
- const u = usage.get(actorId) ?? { episodes: new Set(), scenes: new Set() };
2946
- const sceneCount = sceneCountFromUsage(u);
2947
- const decision = "keep";
2948
- const reason = `保留:出现在 ${sceneCount} 个 scene。`;
2949
- decisions.push({ actor_id: actorId, name: actor["actor_name"], scene_count: sceneCount, decision, reason });
2927
+ function actionSpeakerIds(action) {
2928
+ const ids = [];
2929
+ uniqueAdd(ids, strOf(action["speaker_id"]));
2930
+ for (const speaker of asList(action["speakers"])) {
2931
+ if (isDict(speaker))
2932
+ uniqueAdd(ids, strOf(speaker["speaker_id"]));
2950
2933
  }
2951
- return decisions;
2952
- }
2953
- export function propCurationDecisions(script) {
2954
- const usage = sceneAssetUsage(script, "props", "prop_id");
2955
- const decisions = [];
2956
- for (const prop of asList(script["props"])) {
2957
- if (!isDict(prop))
2958
- continue;
2959
- const propId = strOf(prop["prop_id"]);
2960
- const u = usage.get(propId) ?? { episodes: new Set(), scenes: new Set() };
2961
- const sceneCount = sceneCountFromUsage(u);
2962
- const decision = "keep";
2963
- const reason = `保留:出现在 ${sceneCount} 个 scene。`;
2964
- decisions.push({ prop_id: propId, name: prop["prop_name"], scene_count: sceneCount, decision, reason });
2934
+ for (const line of asList(action["lines"])) {
2935
+ if (isDict(line))
2936
+ uniqueAdd(ids, strOf(line["speaker_id"]));
2965
2937
  }
2966
- return decisions;
2938
+ return ids;
2967
2939
  }
2968
- function rewriteStateChanges(changes, locationReplacements, droppedPropIds, droppedActorIds) {
2969
- const rewritten = [];
2970
- for (const change of asList(changes)) {
2971
- if (!isDict(change))
2972
- continue;
2973
- const targetKind = strOf(change["target_kind"]);
2974
- const targetId = strOf(change["target_id"]);
2975
- if (targetKind === "actor" && droppedActorIds.has(targetId))
2976
- continue;
2977
- if (targetKind === "prop" && droppedPropIds.has(targetId))
2978
- continue;
2979
- const next = { ...change };
2980
- if (targetKind === "location" && locationReplacements.has(targetId)) {
2981
- next["target_id"] = locationReplacements.get(targetId);
2982
- delete next["from_state_id"];
2983
- delete next["to_state_id"];
2940
+ function assetNameMaps(script) {
2941
+ const maps = {
2942
+ actor: new Map(),
2943
+ location: new Map(),
2944
+ prop: new Map(),
2945
+ };
2946
+ for (const kind of ["actor", "location", "prop"]) {
2947
+ for (const asset of asList(script[assetListKey(kind)])) {
2948
+ if (!isDict(asset))
2949
+ continue;
2950
+ const id = strOf(asset[assetIdKey(kind)]);
2951
+ const name = strOf(asset[assetNameKey(kind)]).trim();
2952
+ if (id && name)
2953
+ maps[kind].set(id, name);
2984
2954
  }
2985
- rewritten.push(next);
2986
2955
  }
2987
- return rewritten;
2956
+ return maps;
2988
2957
  }
2989
- function rewriteTransitionPrompt(transition, locationReplacements, droppedPropIds, droppedActorIds) {
2990
- if (!isDict(transition))
2991
- return transition;
2992
- const targetKind = strOf(transition["target_kind"]);
2993
- const targetId = strOf(transition["target_id"]);
2994
- if (targetKind === "actor" && droppedActorIds.has(targetId))
2995
- return null;
2996
- if (targetKind === "prop" && droppedPropIds.has(targetId))
2997
- return null;
2998
- const next = { ...transition };
2999
- if (targetKind === "location" && locationReplacements.has(targetId)) {
3000
- next["target_id"] = locationReplacements.get(targetId);
2958
+ function findMentionedAssetIds(content, names) {
2959
+ const out = [];
2960
+ const text = content.trim();
2961
+ if (!text)
2962
+ return out;
2963
+ for (const [id, name] of names) {
2964
+ if (name.length >= 2 && text.includes(name))
2965
+ out.push(id);
3001
2966
  }
3002
- return next;
2967
+ return out;
3003
2968
  }
3004
- function applyAssetCurationRefs(script, locationReplacements, droppedPropIds, droppedActorIds) {
3005
- if (locationReplacements.size === 0 && droppedPropIds.size === 0 && droppedActorIds.size === 0)
3006
- return;
2969
+ function ensureEvidence(map, id) {
2970
+ if (!map.has(id))
2971
+ map.set(id, emptyCurationEvidence());
2972
+ return map.get(id);
2973
+ }
2974
+ function buildCurationEvidence(script) {
2975
+ const evidence = {
2976
+ actor: new Map(),
2977
+ location: new Map(),
2978
+ prop: new Map(),
2979
+ };
2980
+ for (const kind of ["actor", "location", "prop"]) {
2981
+ for (const asset of asList(script[assetListKey(kind)])) {
2982
+ if (!isDict(asset))
2983
+ continue;
2984
+ const id = strOf(asset[assetIdKey(kind)]);
2985
+ if (id)
2986
+ evidence[kind].set(id, emptyCurationEvidence());
2987
+ }
2988
+ }
2989
+ const names = assetNameMaps(script);
2990
+ const speakers = speakerSourceMap(script);
2991
+ for (const speaker of speakers.values()) {
2992
+ if (strOf(speaker["source_kind"]) !== "actor")
2993
+ continue;
2994
+ const actorId = strOf(speaker["source_id"]);
2995
+ const speakerId = strOf(speaker["speaker_id"]);
2996
+ if (actorId && speakerId)
2997
+ ensureEvidence(evidence.actor, actorId).speakerIds.add(speakerId);
2998
+ }
3007
2999
  for (const ep of asList(script["episodes"])) {
3000
+ const episodeId = strOf(ep["episode_id"]);
3008
3001
  for (const scene of asList(ep["scenes"])) {
3002
+ const sceneId = strOf(scene["scene_id"]);
3003
+ const sceneRef = `${episodeId || "-"}${sceneId ? `/${sceneId}` : ""}`;
3009
3004
  const ctx = sceneContext(scene);
3010
- if (locationReplacements.size > 0) {
3011
- ctx["locations"] = compactAssetRefs(ctx["locations"], "location_id", locationReplacements, new Set());
3005
+ const actorIds = asList(ctx["actors"]).map((ref) => strOf(ref["actor_id"])).filter((id) => id);
3006
+ const locationIds = asList(ctx["locations"]).map((ref) => strOf(ref["location_id"])).filter((id) => id);
3007
+ const propIds = asList(ctx["props"]).map((ref) => strOf(ref["prop_id"])).filter((id) => id);
3008
+ for (const actorId of actorIds) {
3009
+ const item = ensureEvidence(evidence.actor, actorId);
3010
+ item.sceneRefs.add(sceneRef);
3011
+ for (const locationId of locationIds) {
3012
+ const name = names.location.get(locationId);
3013
+ if (name)
3014
+ item.coLocations.add(`${locationId}:${name}`);
3015
+ }
3016
+ for (const propId of propIds) {
3017
+ const name = names.prop.get(propId);
3018
+ if (name)
3019
+ item.coProps.add(`${propId}:${name}`);
3020
+ }
3012
3021
  }
3013
- if (droppedActorIds.size > 0) {
3014
- ctx["actors"] = compactAssetRefs(ctx["actors"], "actor_id", new Map(), droppedActorIds);
3022
+ for (const locationId of locationIds) {
3023
+ const item = ensureEvidence(evidence.location, locationId);
3024
+ item.sceneRefs.add(sceneRef);
3025
+ for (const otherLocationId of locationIds) {
3026
+ if (otherLocationId === locationId)
3027
+ continue;
3028
+ const name = names.location.get(otherLocationId);
3029
+ if (name)
3030
+ item.coLocations.add(`${otherLocationId}:${name}`);
3031
+ }
3032
+ for (const actorId of actorIds) {
3033
+ const name = names.actor.get(actorId);
3034
+ if (name)
3035
+ item.coActors.add(`${actorId}:${name}`);
3036
+ }
3037
+ for (const propId of propIds) {
3038
+ const name = names.prop.get(propId);
3039
+ if (name)
3040
+ item.coProps.add(`${propId}:${name}`);
3041
+ }
3015
3042
  }
3016
- if (droppedPropIds.size > 0) {
3017
- ctx["props"] = compactAssetRefs(ctx["props"], "prop_id", new Map(), droppedPropIds);
3043
+ for (const propId of propIds) {
3044
+ const item = ensureEvidence(evidence.prop, propId);
3045
+ item.sceneRefs.add(sceneRef);
3046
+ for (const actorId of actorIds) {
3047
+ const name = names.actor.get(actorId);
3048
+ if (name)
3049
+ item.coActors.add(`${actorId}:${name}`);
3050
+ }
3051
+ for (const locationId of locationIds) {
3052
+ const name = names.location.get(locationId);
3053
+ if (name)
3054
+ item.coLocations.add(`${locationId}:${name}`);
3055
+ }
3018
3056
  }
3019
- setSceneContext(scene, ctx);
3020
- for (const action of asList(scene["actions"])) {
3057
+ for (let actionIndex = 0; actionIndex < asList(scene["actions"]).length; actionIndex++) {
3058
+ const action = asList(scene["actions"])[actionIndex];
3021
3059
  if (!isDict(action))
3022
3060
  continue;
3023
- if (droppedActorIds.has(strOf(action["actor_id"])))
3024
- delete action["actor_id"];
3025
- if (action["state_changes"] !== undefined && action["state_changes"] !== null) {
3026
- action["state_changes"] = rewriteStateChanges(action["state_changes"], locationReplacements, droppedPropIds, droppedActorIds);
3061
+ const content = strOf(action["content"]).replace(/\s+/g, " ").trim();
3062
+ const actionRef = `${sceneRef}#${actionIndex}`;
3063
+ const actorId = strOf(action["actor_id"]);
3064
+ if (actorId) {
3065
+ const item = ensureEvidence(evidence.actor, actorId);
3066
+ item.actionRefs.add(actionRef);
3067
+ item.actionMentionCount += 1;
3068
+ pushLimitedUnique(item.examples, `${actionRef}: ${content.slice(0, 120)}`, 3);
3027
3069
  }
3028
- if (action["transition_prompt"] !== undefined && action["transition_prompt"] !== null) {
3029
- const transition = rewriteTransitionPrompt(action["transition_prompt"], locationReplacements, droppedPropIds, droppedActorIds);
3030
- if (transition === null)
3031
- delete action["transition_prompt"];
3032
- else
3033
- action["transition_prompt"] = transition;
3070
+ const speakerActorIds = new Set();
3071
+ for (const speakerId of actionSpeakerIds(action)) {
3072
+ const speaker = speakers.get(speakerId);
3073
+ if (!speaker || strOf(speaker["source_kind"]) !== "actor")
3074
+ continue;
3075
+ const sourceId = strOf(speaker["source_id"]);
3076
+ if (sourceId)
3077
+ speakerActorIds.add(sourceId);
3078
+ }
3079
+ for (const sourceId of speakerActorIds) {
3080
+ const item = ensureEvidence(evidence.actor, sourceId);
3081
+ item.actionRefs.add(actionRef);
3082
+ item.speakingScenes.add(sceneRef);
3083
+ item.dialogueCount += 1;
3084
+ pushLimitedUnique(item.dialogueExamples, `${actionRef}: ${content.slice(0, 120)}`, 3);
3085
+ }
3086
+ for (const mentionedActorId of findMentionedAssetIds(content, names.actor)) {
3087
+ const item = ensureEvidence(evidence.actor, mentionedActorId);
3088
+ item.actionMentionCount += 1;
3089
+ item.actionRefs.add(actionRef);
3090
+ pushLimitedUnique(item.examples, `${actionRef}: ${content.slice(0, 120)}`, 3);
3091
+ }
3092
+ for (const mentionedPropId of findMentionedAssetIds(content, names.prop)) {
3093
+ const item = ensureEvidence(evidence.prop, mentionedPropId);
3094
+ item.actionRefs.add(actionRef);
3095
+ pushLimitedUnique(item.examples, `${actionRef}: ${content.slice(0, 120)}`, 3);
3096
+ }
3097
+ for (const mentionedLocationId of findMentionedAssetIds(content, names.location)) {
3098
+ const item = ensureEvidence(evidence.location, mentionedLocationId);
3099
+ item.actionRefs.add(actionRef);
3100
+ pushLimitedUnique(item.examples, `${actionRef}: ${content.slice(0, 120)}`, 3);
3034
3101
  }
3035
3102
  }
3036
3103
  }
3037
3104
  }
3038
- for (const speaker of asList(script["speakers"])) {
3039
- if (!isDict(speaker))
3040
- continue;
3041
- const sourceKind = strOf(speaker["source_kind"]);
3042
- const sourceId = strOf(speaker["source_id"]);
3043
- if (sourceKind === "actor" && droppedActorIds.has(sourceId)) {
3044
- speaker["source_kind"] = "other";
3045
- speaker["source_id"] = null;
3046
- }
3047
- else if (sourceKind === "location" && locationReplacements.has(sourceId)) {
3048
- speaker["source_id"] = locationReplacements.get(sourceId);
3049
- }
3050
- else if (sourceKind === "prop" && droppedPropIds.has(sourceId)) {
3051
- speaker["source_kind"] = "other";
3052
- speaker["source_id"] = null;
3105
+ return evidence;
3106
+ }
3107
+ function stateCatalog(asset) {
3108
+ return asList(asset["states"]).map((state) => ({
3109
+ state_id: state["state_id"],
3110
+ state_name: state["state_name"],
3111
+ description: state["description"],
3112
+ }));
3113
+ }
3114
+ function clusterMembersByAssetId(clusters) {
3115
+ const out = new Map();
3116
+ for (const cluster of clusters) {
3117
+ const members = asList(cluster["members"]).filter(isDict);
3118
+ const names = members.map((member) => strOf(member["name"]).trim()).filter((name) => name);
3119
+ for (const member of members) {
3120
+ const id = strOf(member["id"]);
3121
+ const name = strOf(member["name"]).trim();
3122
+ if (!id)
3123
+ continue;
3124
+ out.set(id, names.filter((candidate) => candidate && candidate !== name).slice(0, 8));
3053
3125
  }
3054
3126
  }
3127
+ return out;
3055
3128
  }
3056
- export function normalizeAssetCuration(script, rawCuration) {
3057
- const payload = isDict(rawCuration) ? rawCuration : {};
3058
- const rawLocations = new Map();
3059
- for (const item of asList(payload["locations"])) {
3060
- if (!isDict(item))
3129
+ function scopeCandidatesForLocation(name, relatedNames, coLocationNames = []) {
3130
+ const scopes = [];
3131
+ const addScope = (value) => {
3132
+ const scope = value.trim();
3133
+ if (scope)
3134
+ uniqueAdd(scopes, scope);
3135
+ };
3136
+ for (const candidate of [name, ...relatedNames]) {
3137
+ const dotIndex = candidate.indexOf("·");
3138
+ if (dotIndex > 0)
3139
+ addScope(candidate.slice(0, dotIndex));
3140
+ const spacedIndex = candidate.indexOf(" ");
3141
+ if (spacedIndex > 0)
3142
+ addScope(candidate.slice(0, spacedIndex));
3143
+ }
3144
+ for (const candidate of coLocationNames) {
3145
+ if (isLikelyScopeMarkerName(candidate))
3146
+ addScope(candidate);
3147
+ }
3148
+ return scopes.slice(0, 4);
3149
+ }
3150
+ function idNameValuesToNames(values) {
3151
+ const names = [];
3152
+ for (const value of values) {
3153
+ const index = value.indexOf(":");
3154
+ const name = index >= 0 ? value.slice(index + 1).trim() : value.trim();
3155
+ if (name)
3156
+ uniqueAdd(names, name);
3157
+ }
3158
+ return names;
3159
+ }
3160
+ function idNameValuesToPairs(values) {
3161
+ const pairs = [];
3162
+ const seen = new Set();
3163
+ for (const value of values) {
3164
+ const index = value.indexOf(":");
3165
+ const id = index >= 0 ? value.slice(0, index).trim() : "";
3166
+ const name = index >= 0 ? value.slice(index + 1).trim() : value.trim();
3167
+ if (!id || !name)
3061
3168
  continue;
3062
- const id = strOf(item["location_id"]);
3063
- if (id)
3064
- rawLocations.set(id, item);
3065
- }
3066
- const locationUsage = sceneAssetUsage(script, "locations", "location_id");
3067
- const validLocationIds = new Set();
3068
- for (const loc of asList(script["locations"])) {
3069
- if (isDict(loc)) {
3070
- const id = strOf(loc["location_id"]);
3071
- if (id)
3072
- validLocationIds.add(id);
3073
- }
3169
+ const key = `${id}:${name}`;
3170
+ if (seen.has(key))
3171
+ continue;
3172
+ seen.add(key);
3173
+ pairs.push({ id, name });
3074
3174
  }
3075
- const rawLocationDecisions = new Map();
3076
- for (const [locId, item] of rawLocations) {
3077
- rawLocationDecisions.set(locId, strOf(item["decision"]).trim());
3175
+ return pairs;
3176
+ }
3177
+ function isLikelyScopeMarkerName(nameRaw) {
3178
+ const name = cleanName(nameRaw);
3179
+ return /(?:世界|领域|国度|界域|时空|宇宙|位面)$/.test(name) || /(?:仙界|神界|魔界|妖界|人间|凡间|地府)$/.test(name);
3180
+ }
3181
+ function hasScopedLocationCue(name, coLocationNames) {
3182
+ if (!name || name.includes("·"))
3183
+ return false;
3184
+ return coLocationNames.some((candidate) => isLikelyScopeMarkerName(candidate));
3185
+ }
3186
+ function stripLocationScope(nameRaw) {
3187
+ const name = cleanName(nameRaw);
3188
+ const dotIndex = name.indexOf("·");
3189
+ if (dotIndex > 0 && dotIndex < name.length - 1) {
3190
+ return { scope: name.slice(0, dotIndex), base: name.slice(dotIndex + 1) };
3078
3191
  }
3079
- const locations = [];
3192
+ return { scope: "", base: name };
3193
+ }
3194
+ function locationParentCandidates(script, sourceId, sourceNameRaw) {
3195
+ const source = stripLocationScope(sourceNameRaw);
3196
+ if (!source.base)
3197
+ return [];
3198
+ const out = [];
3080
3199
  for (const loc of asList(script["locations"])) {
3081
3200
  if (!isDict(loc))
3082
3201
  continue;
3083
- const locationId = strOf(loc["location_id"]);
3084
- const sceneCount = sceneCountFromUsage(locationUsage.get(locationId) ?? { episodes: new Set(), scenes: new Set() });
3085
- const item = rawLocations.get(locationId) ?? {};
3086
- let decision = strOf(item["decision"]).trim();
3087
- let targetLocationId = strOf(item["target_location_id"]).trim() || null;
3088
- let reason = strOf(item["reason"]).trim() || "provider 基于剧本上下文给出决策。";
3089
- if (decision === "merge") {
3090
- if (targetLocationId === null ||
3091
- !validLocationIds.has(targetLocationId) ||
3092
- targetLocationId === locationId ||
3093
- rawLocationDecisions.get(targetLocationId) === "merge") {
3094
- decision = "keep";
3095
- targetLocationId = null;
3096
- reason = "保留:provider 给出的地点合并目标无效。";
3097
- }
3098
- }
3099
- else if (decision !== "keep") {
3100
- decision = "keep";
3101
- targetLocationId = null;
3102
- reason = "保留:provider 未给出有效地点裁剪决策。";
3103
- }
3104
- else {
3105
- targetLocationId = null;
3106
- }
3202
+ const id = strOf(loc["location_id"]);
3203
+ const rawName = strOf(loc["location_name"]);
3204
+ if (!id || id === sourceId)
3205
+ continue;
3206
+ const candidate = stripLocationScope(rawName);
3207
+ if (!candidate.base || candidate.base === source.base)
3208
+ continue;
3209
+ const sameScope = !source.scope || !candidate.scope || source.scope === candidate.scope;
3210
+ if (!sameScope)
3211
+ continue;
3212
+ const childContainsParent = source.base.length > candidate.base.length + 1 && source.base.includes(candidate.base);
3213
+ const parentContainsChild = candidate.base.length > source.base.length + 1 && candidate.base.includes(source.base);
3214
+ if (childContainsParent || parentContainsChild)
3215
+ uniqueAdd(out, `${id}:${rawName}`);
3216
+ if (out.length >= 6)
3217
+ break;
3218
+ }
3219
+ return out;
3220
+ }
3221
+ function locationGranularityHint(nameRaw, parentCandidates) {
3222
+ const { scope, base } = stripLocationScope(nameRaw);
3223
+ if (isLikelyScopeMarkerName(base))
3224
+ return "scope_marker";
3225
+ if (scope && base)
3226
+ return "scoped_set";
3227
+ if (parentCandidates.length === 0)
3228
+ return "";
3229
+ if (/(?:门口|门外|门内|门前|门后|入口|出口|走廊|过道|附近|旁边|角落|边缘|上空|空中|屋顶|天台)$/.test(base)) {
3230
+ return "micro_or_transition_space";
3231
+ }
3232
+ return "parent_child_candidate";
3233
+ }
3234
+ function scopedLocationRenameSuggestions(nameRaw, coLocationNames) {
3235
+ const { base } = stripLocationScope(nameRaw);
3236
+ if (!base)
3237
+ return [];
3238
+ const out = [];
3239
+ for (const candidate of coLocationNames) {
3240
+ if (!isLikelyScopeMarkerName(candidate))
3241
+ continue;
3242
+ uniqueAdd(out, `${candidate}·${base}`);
3243
+ if (out.length >= 4)
3244
+ break;
3245
+ }
3246
+ return out;
3247
+ }
3248
+ const ACTOR_FORM_MARKERS = [
3249
+ "Q版",
3250
+ "幻影",
3251
+ "兽形",
3252
+ "本体",
3253
+ "真身",
3254
+ "本尊",
3255
+ "伪装",
3256
+ "人形",
3257
+ "神兽",
3258
+ "分身",
3259
+ "影子",
3260
+ "幼年",
3261
+ "老年",
3262
+ "黑化",
3263
+ "魔化",
3264
+ "觉醒",
3265
+ ];
3266
+ function actorFormMarker(nameRaw) {
3267
+ const name = cleanName(nameRaw);
3268
+ for (const marker of ACTOR_FORM_MARKERS) {
3269
+ if (name.includes(marker))
3270
+ return marker;
3271
+ }
3272
+ return "";
3273
+ }
3274
+ function compactEvidenceText(asset, evidence) {
3275
+ const parts = [
3276
+ strOf(asset["description"]),
3277
+ ...asList(asset["aliases"]).map((alias) => strOf(alias)),
3278
+ ...asList(asset["states"]).flatMap((state) => [strOf(state["state_name"]), strOf(state["description"])]),
3279
+ ...evidence.examples,
3280
+ ...evidence.dialogueExamples,
3281
+ ];
3282
+ return parts.join(" ");
3283
+ }
3284
+ function actorIdentityCandidates(script, sourceId, sourceNameRaw, evidence) {
3285
+ const sourceName = cleanName(sourceNameRaw);
3286
+ if (!sourceId || !sourceName)
3287
+ return [];
3288
+ const sourceAsset = assetMapById(script, "actor").get(sourceId) ?? {};
3289
+ const evidenceText = compactEvidenceText(sourceAsset, evidence);
3290
+ const marker = actorFormMarker(sourceName);
3291
+ const out = [];
3292
+ for (const actor of asList(script["actors"])) {
3293
+ if (!isDict(actor))
3294
+ continue;
3295
+ const id = strOf(actor["actor_id"]);
3296
+ const name = cleanName(strOf(actor["actor_name"]));
3297
+ if (!id || id === sourceId || !name)
3298
+ continue;
3299
+ const candidateMarker = actorFormMarker(name);
3300
+ const sourceWrapsCandidate = sourceName !== name && sourceName.includes(name) && (marker || sourceName.length > name.length + 1);
3301
+ const candidateWrapsSource = sourceName !== name && name.includes(sourceName) && (candidateMarker || name.length > sourceName.length + 1);
3302
+ const textMentionsCandidate = name.length >= 2 && evidenceText.includes(name);
3303
+ const nicknameCandidate = sourceName.length === 2 &&
3304
+ /^(?:小|老|阿)/.test(sourceName) &&
3305
+ name.length >= 2 &&
3306
+ name.includes(sourceName.slice(1));
3307
+ if (sourceWrapsCandidate)
3308
+ uniqueAdd(out, `${id}:${name}:form_base`);
3309
+ else if (candidateWrapsSource)
3310
+ uniqueAdd(out, `${id}:${name}:form_variant`);
3311
+ else if (textMentionsCandidate)
3312
+ uniqueAdd(out, `${id}:${name}:mentioned_in_evidence`);
3313
+ else if (nicknameCandidate)
3314
+ uniqueAdd(out, `${id}:${name}:nickname_candidate`);
3315
+ if (out.length >= 6)
3316
+ break;
3317
+ }
3318
+ return out;
3319
+ }
3320
+ function propContinuityHints(asset, evidence) {
3321
+ const hints = [];
3322
+ if (asList(asset["states"]).length > 0)
3323
+ uniqueAdd(hints, "stateful");
3324
+ if (evidence.sceneRefs.size >= 2)
3325
+ uniqueAdd(hints, "multi_scene");
3326
+ if (evidence.actionRefs.size >= 2)
3327
+ uniqueAdd(hints, "action_referenced");
3328
+ if (propHasSingleOwnerActor(evidence))
3329
+ uniqueAdd(hints, "single_owner_actor");
3330
+ if (evidence.sceneRefs.size <= 1 && evidence.actionRefs.size <= 1 && asList(asset["states"]).length === 0)
3331
+ uniqueAdd(hints, "low_continuity");
3332
+ return hints;
3333
+ }
3334
+ function propBoundaryHint(asset, evidence) {
3335
+ if (asList(asset["states"]).length > 0)
3336
+ return "hero_candidate_stateful";
3337
+ if (evidence.sceneRefs.size >= 3 || evidence.actionRefs.size >= 4)
3338
+ return "hero_candidate_recurring";
3339
+ if (evidence.sceneRefs.size >= 2 && evidence.actionRefs.size >= 2)
3340
+ return "possible_continuity_prop";
3341
+ if (propHasSingleOwnerActor(evidence))
3342
+ return "actor_bound_review";
3343
+ return "minor_or_context_only";
3344
+ }
3345
+ function curationDecisionRequirement(kind, sceneCount, relatedNames, name, coLocationNames, extraSignals = []) {
3346
+ if (kind === "prop")
3347
+ return "prop_exhaustive";
3348
+ if (kind === "actor" && sceneCount <= 1)
3349
+ return "low_frequency_actor";
3350
+ if (kind === "actor" && extraSignals.length > 0)
3351
+ return "actor_identity_or_form_candidate";
3352
+ if (kind === "location" && hasScopedLocationCue(name, coLocationNames))
3353
+ return "scoped_location";
3354
+ if (kind === "location" && extraSignals.length > 0)
3355
+ return "location_granularity_review";
3356
+ if (kind === "location" && relatedNames.length > 0)
3357
+ return "clustered_location";
3358
+ return "";
3359
+ }
3360
+ function compactSet(values, limit) {
3361
+ return [...values].filter((value) => value).slice(0, limit);
3362
+ }
3363
+ function singleIdNameValue(values) {
3364
+ const items = [...values].filter((value) => value.trim());
3365
+ return items.length === 1 ? items[0] : "";
3366
+ }
3367
+ function compactAssetForCuration(script, kind, asset, usage, evidenceByKind, relatedById, crossKindNames) {
3368
+ const id = strOf(asset[assetIdKey(kind)]);
3369
+ const itemUsage = usage.get(id) ?? { episodes: new Set(), scenes: new Set() };
3370
+ const evidence = evidenceByKind[kind].get(id) ?? emptyCurationEvidence();
3371
+ const relatedNames = relatedById.get(id) ?? [];
3372
+ const name = strOf(asset[assetNameKey(kind)]).trim();
3373
+ const coLocationNames = kind === "location" ? idNameValuesToNames(evidence.coLocations) : [];
3374
+ const actorIdentity = kind === "actor" ? actorIdentityCandidates(script, id, name, evidence) : [];
3375
+ const locationParents = kind === "location" ? locationParentCandidates(script, id, name) : [];
3376
+ const sceneCount = sceneCountFromUsage(itemUsage);
3377
+ return {
3378
+ kind,
3379
+ id,
3380
+ name: asset[assetNameKey(kind)],
3381
+ aliases: asset["aliases"],
3382
+ description: asset["description"],
3383
+ states: stateCatalog(asset),
3384
+ scene_count: sceneCount,
3385
+ episode_count: itemUsage.episodes.size,
3386
+ state_count: stateCatalog(asset).length,
3387
+ dialogue_count: evidence.dialogueCount,
3388
+ speaking_scene_count: evidence.speakingScenes.size,
3389
+ action_mention_count: evidence.actionMentionCount,
3390
+ action_ref_count: evidence.actionRefs.size,
3391
+ speaker_ids: compactSet(evidence.speakerIds, 6),
3392
+ scene_refs: compactSet(evidence.sceneRefs, 8),
3393
+ related_names: relatedNames,
3394
+ related_assets: kind === "location" ? locationParents : [],
3395
+ identity_candidates: actorIdentity,
3396
+ form_marker: kind === "actor" ? actorFormMarker(name) : "",
3397
+ scope_candidates: kind === "location" ? scopeCandidatesForLocation(name, relatedNames, coLocationNames) : [],
3398
+ scope_rename_suggestions: kind === "location" ? scopedLocationRenameSuggestions(name, coLocationNames) : [],
3399
+ parent_locations: locationParents,
3400
+ granularity_hint: kind === "location" ? locationGranularityHint(name, locationParents) : "",
3401
+ cross_kind_same_names: crossKindNames.get(`${kind}:${id}`) ?? [],
3402
+ co_actors: compactSet(evidence.coActors, 5),
3403
+ owner_actor: kind === "prop" ? singleIdNameValue(evidence.coActors) : "",
3404
+ co_locations: compactSet(evidence.coLocations, 5),
3405
+ co_props: compactSet(evidence.coProps, 5),
3406
+ continuity_hints: kind === "prop" ? propContinuityHints(asset, evidence) : [],
3407
+ prop_boundary_hint: kind === "prop" ? propBoundaryHint(asset, evidence) : "",
3408
+ decision_required: curationDecisionRequirement(kind, sceneCount, relatedNames, name, coLocationNames, kind === "actor" ? actorIdentity : locationParents),
3409
+ examples: actionPreviewForAsset(script, kind, id),
3410
+ action_examples: evidence.examples,
3411
+ dialogue_examples: evidence.dialogueExamples,
3412
+ };
3413
+ }
3414
+ function similarAssetClusters(script, kind) {
3415
+ const items = asList(script[assetListKey(kind)]).filter(isDict);
3416
+ const clusters = [];
3417
+ for (let i = 0; i < items.length; i++) {
3418
+ const left = items[i];
3419
+ const leftName = strOf(left[assetNameKey(kind)]);
3420
+ const leftKey = canonicalAssetNameKey(kind, leftName).split("::")[1] ?? "";
3421
+ const members = [{ id: left[assetIdKey(kind)], name: leftName }];
3422
+ for (let j = i + 1; j < items.length; j++) {
3423
+ const right = items[j];
3424
+ const rightName = strOf(right[assetNameKey(kind)]);
3425
+ const rightKey = canonicalAssetNameKey(kind, rightName).split("::")[1] ?? "";
3426
+ if (!leftKey || !rightKey)
3427
+ continue;
3428
+ if (leftKey.includes(rightKey) || rightKey.includes(leftKey)) {
3429
+ members.push({ id: right[assetIdKey(kind)], name: rightName });
3430
+ }
3431
+ }
3432
+ if (members.length > 1)
3433
+ clusters.push({ kind, members });
3434
+ if (clusters.length >= 80)
3435
+ break;
3436
+ }
3437
+ return clusters;
3438
+ }
3439
+ function crossKindNameHints(script) {
3440
+ const byName = new Map();
3441
+ for (const kind of ["actor", "location", "prop"]) {
3442
+ for (const asset of asList(script[assetListKey(kind)])) {
3443
+ if (!isDict(asset))
3444
+ continue;
3445
+ const id = strOf(asset[assetIdKey(kind)]);
3446
+ const name = strOf(asset[assetNameKey(kind)]).trim();
3447
+ if (!id || !name)
3448
+ continue;
3449
+ const key = canonicalAssetNameKey(kind, name).split("::")[1] ?? name;
3450
+ const item = `${kind}:${id}:${name}`;
3451
+ const list = byName.get(key) ?? [];
3452
+ list.push(item);
3453
+ byName.set(key, list);
3454
+ }
3455
+ }
3456
+ const out = new Map();
3457
+ for (const list of byName.values()) {
3458
+ if (list.length < 2)
3459
+ continue;
3460
+ for (const item of list) {
3461
+ const [kind, id] = item.split(":");
3462
+ if (!kind || !id)
3463
+ continue;
3464
+ out.set(`${kind}:${id}`, list.filter((candidate) => candidate !== item).slice(0, 6));
3465
+ }
3466
+ }
3467
+ return out;
3468
+ }
3469
+ function normalizeAssetKindSelection(kinds) {
3470
+ if (!kinds || kinds.length === 0)
3471
+ return ["actor", "location", "prop"];
3472
+ const out = [];
3473
+ for (const kind of kinds) {
3474
+ if (out.includes(kind))
3475
+ continue;
3476
+ out.push(kind);
3477
+ }
3478
+ return out;
3479
+ }
3480
+ function selectedAssetSections(kinds) {
3481
+ const sections = [];
3482
+ if (kinds.includes("actor"))
3483
+ sections.push(["actors", "actor"]);
3484
+ if (kinds.includes("location"))
3485
+ sections.push(["locations", "location"]);
3486
+ if (kinds.includes("prop"))
3487
+ sections.push(["props", "prop"]);
3488
+ return sections;
3489
+ }
3490
+ export const ASSET_GROUP_MAX_ITEMS = 8;
3491
+ export const ASSET_GROUP_MAX_CONTEXT_CHARS = 22000;
3492
+ function selectedAssetIds(selection, kind) {
3493
+ const ids = selection?.[kind];
3494
+ if (!ids || ids.length === 0)
3495
+ return null;
3496
+ return new Set(ids);
3497
+ }
3498
+ function filterAssetsForCuration(script, kind, selection) {
3499
+ const ids = selectedAssetIds(selection, kind);
3500
+ const idKey = assetIdKey(kind);
3501
+ return asList(script[assetListKey(kind)])
3502
+ .filter(isDict)
3503
+ .filter((asset) => !ids || ids.has(strOf(asset[idKey])));
3504
+ }
3505
+ export function buildAssetCurationContext(script, kinds, selection) {
3506
+ const selectedKinds = normalizeAssetKindSelection(kinds);
3507
+ const actorUsage = sceneAssetUsage(script, "actors", "actor_id");
3508
+ const locationUsage = sceneAssetUsage(script, "locations", "location_id");
3509
+ const propUsage = sceneAssetUsage(script, "props", "prop_id");
3510
+ const evidence = buildCurationEvidence(script);
3511
+ const clusters = selectedKinds.flatMap((kind) => similarAssetClusters(script, kind));
3512
+ const relatedById = clusterMembersByAssetId(clusters);
3513
+ const crossKindNames = crossKindNameHints(script);
3514
+ return {
3515
+ title: script["title"],
3516
+ asset_kinds: selectedKinds,
3517
+ policy: [
3518
+ "Profile: align with a human-refined asset boundary.",
3519
+ "Actors: keep named, speaking, visually identifiable, or plot-functional roles even when low-frequency.",
3520
+ "Actor forms: move Q版/幻影/兽形/本体/真身/伪装 forms into states on the base actor.",
3521
+ "Actor identity hints list possible base actors or aliases; merge/form-state only when evidence supports the same identity.",
3522
+ "Locations: keep distinct production sets; do not blindly merge interiors, doors, rooftops, underground levels, warehouses, fields, pens, or sky/air variants.",
3523
+ "Locations: parent/gran hints mark possible parent-child or micro/transition spaces; merge only non-distinct transitional/angle-only spaces into the parent.",
3524
+ "Location names: prefer world/domain-scoped canonical names when the source distinguishes worlds or realms.",
3525
+ "Location field scopeHint lists world/realm/domain markers co-occurring with an unscoped location; renameHint gives scoped canonical-name suggestions.",
3526
+ "Props: strict refined prop boundary. Most visible or plot-mentioned objects should stay in action text or actor/location state, not standalone props.",
3527
+ `Every KEEP prop reason must start like category=signature_instance; using one of ${propKeepCategoryList()}, followed by evidence.`,
3528
+ "Props: KEEP named/signature physical instances when they have standalone continuity, are exchanged/carried/inspected as the same object, carry readable evidence/rules/secrets, drive transformations/conflicts, or function as recurring character/faction equipment.",
3529
+ "Props: do not KEEP merely because an object is repeated, plot-functional, held, used, UI/display content, ordinary document/bill, food, or set dressing.",
3530
+ "Props: body marks and worn appearance should become actor state; detachable named devices/weapons/tools/accessories with continuity may stay props.",
3531
+ "Prop field cont/boundary gives continuity hints only; the model still decides KEEP/DROP/STATE from the evidence lines.",
3532
+ ],
3533
+ coverage_policy: {
3534
+ props: "every prop line is decision-required",
3535
+ actors: "actors with scene_count <= 1 or identity/form hints are decision-required",
3536
+ locations: "locations with scopeHint, parent/granularity hints, or similar/name clusters are decision-required",
3537
+ },
3538
+ assets: {
3539
+ actors: selectedKinds.includes("actor") ? filterAssetsForCuration(script, "actor", selection).map((asset) => compactAssetForCuration(script, "actor", asset, actorUsage, evidence, relatedById, crossKindNames)) : [],
3540
+ locations: selectedKinds.includes("location") ? filterAssetsForCuration(script, "location", selection).map((asset) => compactAssetForCuration(script, "location", asset, locationUsage, evidence, relatedById, crossKindNames)) : [],
3541
+ props: selectedKinds.includes("prop") ? filterAssetsForCuration(script, "prop", selection).map((asset) => compactAssetForCuration(script, "prop", asset, propUsage, evidence, relatedById, crossKindNames)) : [],
3542
+ },
3543
+ speakers: selectedKinds.includes("actor") ? asList(script["speakers"]).filter(isDict).map((speaker) => ({
3544
+ speaker_id: speaker["speaker_id"],
3545
+ display_name: speaker["display_name"],
3546
+ source_kind: speaker["source_kind"],
3547
+ source_id: speaker["source_id"],
3548
+ })) : [],
3549
+ similar_clusters: clusters,
3550
+ };
3551
+ }
3552
+ function compactCurationText(value, maxChars = 160) {
3553
+ const text = strOf(value)
3554
+ .replace(/\s+/g, " ")
3555
+ .replace(/\|/g, "/")
3556
+ .trim();
3557
+ return text.length > maxChars ? `${text.slice(0, maxChars - 1)}…` : text;
3558
+ }
3559
+ function assetPrefixForCuration(kind) {
3560
+ if (kind === "actor")
3561
+ return "A";
3562
+ if (kind === "location")
3563
+ return "L";
3564
+ if (kind === "prop")
3565
+ return "P";
3566
+ return "X";
3567
+ }
3568
+ function formatCurationStates(states) {
3569
+ const items = asList(states)
3570
+ .filter(isDict)
3571
+ .map((state) => {
3572
+ const id = compactCurationText(state["state_id"], 40);
3573
+ const name = compactCurationText(state["state_name"], 80);
3574
+ if (!id || !name)
3575
+ return "";
3576
+ return `${id}:${name}`;
3577
+ })
3578
+ .filter((item) => item);
3579
+ return items.length > 0 ? items.join(",") : "-";
3580
+ }
3581
+ function formatCurationAliases(aliases) {
3582
+ const items = asList(aliases)
3583
+ .map((item) => compactCurationText(item, 60))
3584
+ .filter((item) => item);
3585
+ return items.length > 0 ? items.join(",") : "-";
3586
+ }
3587
+ function formatCurationList(value, limit = 6) {
3588
+ const items = asList(value)
3589
+ .map((item) => compactCurationText(item, 70))
3590
+ .filter((item) => item)
3591
+ .slice(0, limit);
3592
+ return items.length > 0 ? items.join(",") : "-";
3593
+ }
3594
+ function formatCurationAssetLine(asset) {
3595
+ const kind = strOf(asset["kind"]);
3596
+ const id = compactCurationText(asset["id"], 40);
3597
+ const name = compactCurationText(asset["name"], 80);
3598
+ const sceneCount = Number(asset["scene_count"] ?? 0);
3599
+ const episodeCount = Number(asset["episode_count"] ?? 0);
3600
+ const stateCount = Number(asset["state_count"] ?? 0);
3601
+ const requirement = compactCurationText(asset["decision_required"], 80);
3602
+ const required = Boolean(requirement);
3603
+ const parts = [
3604
+ assetPrefixForCuration(kind),
3605
+ id,
3606
+ name,
3607
+ `req=${requirement ? `Y:${requirement}` : "-"}`,
3608
+ `sc=${Number.isFinite(sceneCount) ? sceneCount : 0}`,
3609
+ `ep=${Number.isFinite(episodeCount) ? episodeCount : 0}`,
3610
+ `st=${Number.isFinite(stateCount) ? stateCount : 0}`,
3611
+ `alias=${formatCurationAliases(asset["aliases"])}`,
3612
+ `states=${formatCurationStates(asset["states"])}`,
3613
+ ];
3614
+ if (kind === "actor") {
3615
+ parts.push(`dlg=${Number(asset["dialogue_count"] ?? 0) || 0}`, `sp_sc=${Number(asset["speaking_scene_count"] ?? 0) || 0}`, `mention=${Number(asset["action_mention_count"] ?? 0) || 0}`, `spk=${formatCurationList(asset["speaker_ids"], 5)}`, `form=${compactCurationText(asset["form_marker"], 40) || "-"}`, `identity=${formatCurationList(asset["identity_candidates"], 6)}`);
3616
+ }
3617
+ else if (kind === "location") {
3618
+ parts.push(`scope=${formatCurationList(asset["scope_candidates"], 4)}`, `scopeHint=${formatCurationList(asset["scope_candidates"], 4)}`, `renameHint=${formatCurationList(asset["scope_rename_suggestions"], 4)}`, `parent=${formatCurationList(asset["parent_locations"], 6)}`, `gran=${compactCurationText(asset["granularity_hint"], 60) || "-"}`, `related=${formatCurationList(asset["related_names"], 6)}`, `coL=${formatCurationList(asset["co_locations"], 4)}`, `coA=${formatCurationList(asset["co_actors"], 4)}`, `coP=${formatCurationList(asset["co_props"], 4)}`);
3619
+ }
3620
+ else if (kind === "prop") {
3621
+ parts.push(`actrefs=${Number(asset["action_ref_count"] ?? 0) || 0}`, `cont=${formatCurationList(asset["continuity_hints"], 5)}`, `boundary=${compactCurationText(asset["prop_boundary_hint"], 60) || "-"}`, `same=${formatCurationList(asset["cross_kind_same_names"], 4)}`, `ownerA=${compactCurationText(asset["owner_actor"], 80) || "-"}`, `coA=${formatCurationList(asset["co_actors"], 4)}`, `coL=${formatCurationList(asset["co_locations"], 4)}`);
3622
+ }
3623
+ parts.push(`desc=${compactCurationText(asset["description"], 120) || "-"}`);
3624
+ const line = parts.join(" | ");
3625
+ const lines = [line];
3626
+ const pushExamples = (label, values, limit) => {
3627
+ if (limit <= 0)
3628
+ return;
3629
+ for (const example of asList(values).slice(0, limit)) {
3630
+ const text = compactCurationText(example, 96);
3631
+ if (text)
3632
+ lines.push(` ${label}=${text}`);
3633
+ }
3634
+ };
3635
+ if (kind === "actor") {
3636
+ pushExamples("dlg_ex", asset["dialogue_examples"], required ? 2 : 0);
3637
+ pushExamples("act_ex", asset["action_examples"], required ? 1 : 0);
3638
+ }
3639
+ else if (kind === "location") {
3640
+ pushExamples("scene_ex", asset["examples"], required ? 1 : 0);
3641
+ pushExamples("act_ex", asset["action_examples"], required ? 1 : 0);
3642
+ }
3643
+ else if (kind === "prop") {
3644
+ pushExamples("act_ex", asset["action_examples"], 1);
3645
+ pushExamples("scene_ex", asset["examples"], 1);
3646
+ }
3647
+ return lines;
3648
+ }
3649
+ function requiredCurationIds(items) {
3650
+ return items
3651
+ .map((asset) => compactCurationText(asset["decision_required"], 80) ? compactCurationText(asset["id"], 40) : "")
3652
+ .filter((id) => id);
3653
+ }
3654
+ function pushRequiredCurationIdLines(lines, label, ids) {
3655
+ if (ids.length === 0) {
3656
+ lines.push(`- ${label}: -`);
3657
+ return;
3658
+ }
3659
+ const chunkSize = 36;
3660
+ for (let index = 0; index < ids.length; index += chunkSize) {
3661
+ const suffix = index === 0 ? "" : "+";
3662
+ lines.push(`- ${label}${suffix}: ${ids.slice(index, index + chunkSize).join(",")}`);
3663
+ }
3664
+ }
3665
+ export function formatAssetCurationContextText(context) {
3666
+ const lines = [];
3667
+ lines.push("# Asset Curation Context");
3668
+ lines.push(`title: ${compactCurationText(context["title"], 100) || "-"}`);
3669
+ const kinds = normalizeAssetKindSelection(asList(context["asset_kinds"]));
3670
+ lines.push(`asset_kinds: ${kinds.join(",")}`);
3671
+ lines.push("");
3672
+ lines.push("policy:");
3673
+ for (const item of asList(context["policy"])) {
3674
+ const text = compactCurationText(item, 160);
3675
+ if (text)
3676
+ lines.push(`- ${text}`);
3677
+ }
3678
+ const coveragePolicy = asDict(context["coverage_policy"]);
3679
+ const coverageLines = [
3680
+ compactCurationText(coveragePolicy["props"], 160),
3681
+ compactCurationText(coveragePolicy["actors"], 160),
3682
+ compactCurationText(coveragePolicy["locations"], 160),
3683
+ ].filter((line) => line);
3684
+ if (coverageLines.length > 0) {
3685
+ lines.push("");
3686
+ lines.push("coverage:");
3687
+ for (const line of coverageLines)
3688
+ lines.push(`- ${line}`);
3689
+ }
3690
+ const assets = asDict(context["assets"]);
3691
+ const sections = selectedAssetSections(kinds).map(([key]) => [key, `${key}:`]);
3692
+ lines.push("");
3693
+ lines.push("required_decision_ids:");
3694
+ for (const [key] of sections) {
3695
+ pushRequiredCurationIdLines(lines, key, requiredCurationIds(asList(assets[key]).filter(isDict)));
3696
+ }
3697
+ for (const [key, header] of sections) {
3698
+ lines.push("");
3699
+ lines.push(header);
3700
+ const items = asList(assets[key]).filter(isDict);
3701
+ if (items.length === 0) {
3702
+ lines.push("-");
3703
+ continue;
3704
+ }
3705
+ for (const asset of items)
3706
+ lines.push(...formatCurationAssetLine(asset));
3707
+ }
3708
+ if (kinds.includes("actor")) {
3709
+ lines.push("");
3710
+ lines.push("speakers:");
3711
+ const speakers = asList(context["speakers"]).filter(isDict);
3712
+ if (speakers.length === 0) {
3713
+ lines.push("-");
3714
+ }
3715
+ else {
3716
+ for (const speaker of speakers) {
3717
+ const sourceKind = compactCurationText(speaker["source_kind"], 40) || "-";
3718
+ const sourceId = compactCurationText(speaker["source_id"], 40) || "-";
3719
+ lines.push([
3720
+ "S",
3721
+ compactCurationText(speaker["speaker_id"], 50),
3722
+ compactCurationText(speaker["display_name"], 80),
3723
+ `${sourceKind}:${sourceId}`,
3724
+ ].join(" | "));
3725
+ }
3726
+ }
3727
+ }
3728
+ lines.push("");
3729
+ lines.push("similar_clusters:");
3730
+ const clusters = asList(context["similar_clusters"]).filter(isDict);
3731
+ if (clusters.length === 0) {
3732
+ lines.push("-");
3733
+ }
3734
+ else {
3735
+ for (const cluster of clusters) {
3736
+ const members = asList(cluster["members"]).filter(isDict).map((member) => {
3737
+ const id = compactCurationText(member["id"], 40);
3738
+ const name = compactCurationText(member["name"], 80);
3739
+ return id && name ? `${id}:${name}` : "";
3740
+ }).filter((member) => member);
3741
+ if (members.length > 0)
3742
+ lines.push(`C ${compactCurationText(cluster["kind"], 20)} | ${members.join(" ~ ")}`);
3743
+ }
3744
+ }
3745
+ return `${lines.join("\n")}\n`;
3746
+ }
3747
+ export function buildAssetCurationContextText(script, kinds, selection) {
3748
+ return formatAssetCurationContextText(buildAssetCurationContext(script, kinds, selection));
3749
+ }
3750
+ export function buildAssetGroupingContextText(script, kind) {
3751
+ const lines = [];
3752
+ lines.push("# Asset Grouping Context");
3753
+ lines.push(`kind: ${kind}`);
3754
+ lines.push("");
3755
+ lines.push("task:");
3756
+ lines.push("- Group asset ids that should be reviewed together in the later full curation step.");
3757
+ lines.push("- This is not the curation decision step. Do not decide keep/drop/state/rename.");
3758
+ lines.push("- Use names only. Omit an id only when it is an obvious duplicate of a retained id with the same normalized name.");
3759
+ lines.push(`- Each <group> may contain at most ${ASSET_GROUP_MAX_ITEMS} ids.`);
3760
+ lines.push("- Every non-duplicate id must appear exactly once.");
3761
+ lines.push("");
3762
+ lines.push("output:");
3763
+ lines.push(`- One line per group: <group>${kind} <id> [id...] # short reason</group>`);
3764
+ lines.push("- No JSON, markdown tables, headings, or prose outside <group> tags.");
3765
+ lines.push("");
3766
+ lines.push("assets:");
3767
+ const idKey = assetIdKey(kind);
3768
+ const nameKey = assetNameKey(kind);
3769
+ for (const asset of asList(script[assetListKey(kind)])) {
3770
+ if (!isDict(asset))
3771
+ continue;
3772
+ const id = compactCurationText(asset[idKey], 40);
3773
+ const name = compactCurationText(asset[nameKey], 100);
3774
+ if (id && name)
3775
+ lines.push(`${assetPrefixForCuration(kind)} | ${id} | ${name}`);
3776
+ }
3777
+ return `${lines.join("\n")}\n`;
3778
+ }
3779
+ function assetIdsForCurationKind(script, kind) {
3780
+ const idKey = assetIdKey(kind);
3781
+ return asList(script[assetListKey(kind)])
3782
+ .filter(isDict)
3783
+ .map((asset) => strOf(asset[idKey]).trim())
3784
+ .filter((id) => id);
3785
+ }
3786
+ function selectionForCurationIds(kind, ids) {
3787
+ if (kind === "actor")
3788
+ return { actor: ids };
3789
+ if (kind === "location")
3790
+ return { location: ids };
3791
+ return { prop: ids };
3792
+ }
3793
+ export function buildPrimaryAssetCurationChunks(script, kind, options = { maxItems: ASSET_GROUP_MAX_ITEMS, maxContextChars: ASSET_GROUP_MAX_CONTEXT_CHARS }) {
3794
+ const ids = assetIdsForCurationKind(script, kind);
3795
+ const chunks = [];
3796
+ let current = [];
3797
+ const maxItems = Math.max(1, Math.floor(options.maxItems) || 1);
3798
+ const maxContextChars = Math.max(1000, Math.floor(options.maxContextChars) || 1000);
3799
+ const contextLength = (values) => buildAssetCurationContextText(script, [kind], selectionForCurationIds(kind, values)).length;
3800
+ for (const id of ids) {
3801
+ const next = [...current, id];
3802
+ const tooMany = next.length > maxItems;
3803
+ const tooLong = current.length > 0 && contextLength(next) > maxContextChars;
3804
+ if (tooMany || tooLong) {
3805
+ chunks.push(current);
3806
+ current = [id];
3807
+ }
3808
+ else {
3809
+ current = next;
3810
+ }
3811
+ if (current.length === 1 && contextLength(current) > maxContextChars) {
3812
+ chunks.push(current);
3813
+ current = [];
3814
+ }
3815
+ }
3816
+ if (current.length > 0)
3817
+ chunks.push(current);
3818
+ return chunks;
3819
+ }
3820
+ export function extractAssetGroupingExpressions(rawText) {
3821
+ const groups = [];
3822
+ const re = /<group>([\s\S]*?)<\/group>/g;
3823
+ let outside = "";
3824
+ let lastIndex = 0;
3825
+ let match;
3826
+ while ((match = re.exec(rawText)) !== null) {
3827
+ outside += rawText.slice(lastIndex, match.index);
3828
+ groups.push(strOf(match[1]).trim());
3829
+ lastIndex = match.index + match[0].length;
3830
+ }
3831
+ outside += rawText.slice(lastIndex);
3832
+ if (outside.trim()) {
3833
+ curationBlocked("Asset grouping response must contain only <group>...</group> lines.", [outside.trim().slice(0, 200)]);
3834
+ }
3835
+ if (groups.length === 0) {
3836
+ curationBlocked("Asset grouping response did not contain any <group> expressions.", [rawText.slice(0, 200)]);
3837
+ }
3838
+ return groups;
3839
+ }
3840
+ function parseAssetGroupingExpression(expression) {
3841
+ const hashIndex = expression.lastIndexOf("#");
3842
+ if (hashIndex < 0)
3843
+ curationBlocked("Asset grouping expression is missing reason.", [expression]);
3844
+ const command = expression.slice(0, hashIndex).trim();
3845
+ const reason = expression.slice(hashIndex + 1).trim();
3846
+ if (!reason)
3847
+ curationBlocked("Asset grouping expression is missing reason.", [expression]);
3848
+ const tokens = command.split(/\s+/).filter((token) => token);
3849
+ const kind = strOf(tokens[0]).toLowerCase();
3850
+ if (kind !== "actor" && kind !== "location" && kind !== "prop") {
3851
+ curationBlocked("Asset grouping expression has unknown kind.", [expression]);
3852
+ }
3853
+ const ids = tokens.slice(1);
3854
+ if (ids.length === 0)
3855
+ curationBlocked("Asset grouping expression needs at least one id.", [expression]);
3856
+ if (ids.length > ASSET_GROUP_MAX_ITEMS) {
3857
+ curationBlocked(`Asset grouping expression exceeds ${ASSET_GROUP_MAX_ITEMS} ids.`, [expression]);
3858
+ }
3859
+ return { kind, ids, reason };
3860
+ }
3861
+ export function parseAssetGroupingExpressions(rawText) {
3862
+ const expressions = extractAssetGroupingExpressions(rawText);
3863
+ const groups = expressions.map((expression) => parseAssetGroupingExpression(expression));
3864
+ return {
3865
+ version: 1,
3866
+ format: "asset-grouping-v1",
3867
+ expression_text: rawText,
3868
+ expressions,
3869
+ groups,
3870
+ };
3871
+ }
3872
+ function assetGroupingNameKey(kind, rawName) {
3873
+ return canonicalAssetNameKey(kind, rawName).split("::")[1] ?? cleanName(rawName);
3874
+ }
3875
+ export function validateAssetGroupingPlan(script, kind, rawPlan) {
3876
+ const inputIds = assetIdsForCurationKind(script, kind);
3877
+ const inputIdSet = new Set(inputIds);
3878
+ const assetsById = assetMapById(script, kind);
3879
+ const groups = [];
3880
+ const mentioned = new Set();
3881
+ const normalizations = [];
3882
+ const invalid = [];
3883
+ for (const group of asList(rawPlan["groups"]).filter(isDict)) {
3884
+ const groupKind = strOf(group["kind"]);
3885
+ if (groupKind !== kind) {
3886
+ invalid.push(`wrong kind ${groupKind || "<empty>"} for ${kind}`);
3887
+ continue;
3888
+ }
3889
+ const rawIds = asList(group["ids"]).map((id) => strOf(id).trim()).filter((id) => id);
3890
+ if (rawIds.length === 0 || rawIds.length > ASSET_GROUP_MAX_ITEMS) {
3891
+ invalid.push(`${kind} group size ${rawIds.length}`);
3892
+ continue;
3893
+ }
3894
+ const ids = [];
3895
+ const seenInGroup = new Set();
3896
+ for (const id of rawIds) {
3897
+ if (!inputIdSet.has(id)) {
3898
+ normalizations.push({ kind, id, action: "drop_unknown_id", reason: "grouping expression referenced an id not present in the asset table" });
3899
+ continue;
3900
+ }
3901
+ if (seenInGroup.has(id) || mentioned.has(id)) {
3902
+ normalizations.push({ kind, id, action: "drop_duplicate_mention", reason: "grouping expression mentioned this id more than once; first mention kept" });
3903
+ continue;
3904
+ }
3905
+ seenInGroup.add(id);
3906
+ mentioned.add(id);
3907
+ ids.push(id);
3908
+ }
3909
+ if (ids.length === 0) {
3910
+ normalizations.push({ kind, action: "drop_empty_group", reason: "all ids in the group were already mentioned or invalid" });
3911
+ continue;
3912
+ }
3913
+ groups.push({ kind, ids, reason: strOf(group["reason"]).trim() || "name-related group" });
3914
+ }
3915
+ if (invalid.length > 0)
3916
+ curationBlocked("Asset grouping plan is invalid.", invalid.slice(0, 40));
3917
+ const retainedByName = new Map();
3918
+ for (const id of mentioned) {
3919
+ const asset = assetsById.get(id);
3920
+ if (!asset)
3921
+ continue;
3922
+ const key = assetGroupingNameKey(kind, asset[assetNameKey(kind)]);
3923
+ if (!key)
3924
+ continue;
3925
+ const list = retainedByName.get(key) ?? [];
3926
+ list.push(id);
3927
+ retainedByName.set(key, list);
3928
+ }
3929
+ const duplicateMerges = [];
3930
+ const omittedSingletons = [];
3931
+ for (const id of inputIds) {
3932
+ if (mentioned.has(id))
3933
+ continue;
3934
+ const asset = assetsById.get(id);
3935
+ if (!asset)
3936
+ continue;
3937
+ const key = assetGroupingNameKey(kind, asset[assetNameKey(kind)]);
3938
+ const targets = key ? retainedByName.get(key) ?? [] : [];
3939
+ if (targets.length === 0) {
3940
+ groups.push({
3941
+ kind,
3942
+ ids: [id],
3943
+ reason: "omitted by grouping; review as standalone asset",
3944
+ });
3945
+ mentioned.add(id);
3946
+ omittedSingletons.push(id);
3947
+ continue;
3948
+ }
3949
+ duplicateMerges.push({
3950
+ kind,
3951
+ source_id: id,
3952
+ decision: "merge",
3953
+ target_kind: kind,
3954
+ target_id: targets[0],
3955
+ reason: "name-level duplicate omitted by asset grouping",
3956
+ });
3957
+ }
3958
+ return {
3959
+ version: 1,
3960
+ format: "asset-grouping-v1",
3961
+ kind,
3962
+ expression_text: rawPlan["expression_text"],
3963
+ expressions: rawPlan["expressions"],
3964
+ groups,
3965
+ duplicate_merge_decisions: duplicateMerges,
3966
+ normalizations,
3967
+ summary: {
3968
+ input_count: inputIds.length,
3969
+ group_count: groups.length,
3970
+ mentioned_count: mentioned.size,
3971
+ omitted_duplicate_count: duplicateMerges.length,
3972
+ omitted_singleton_count: omittedSingletons.length,
3973
+ normalization_count: normalizations.length,
3974
+ },
3975
+ };
3976
+ }
3977
+ export function groupedAssetCurationChunks(script, kind, groups, options = { maxItems: ASSET_GROUP_MAX_ITEMS, maxContextChars: ASSET_GROUP_MAX_CONTEXT_CHARS }) {
3978
+ const chunks = [];
3979
+ const seen = new Set();
3980
+ const existing = new Set(assetIdsForCurationKind(script, kind));
3981
+ const pushSplitGroup = (ids) => {
3982
+ let current = [];
3983
+ const maxItems = Math.max(1, Math.floor(options.maxItems) || 1);
3984
+ const maxContextChars = Math.max(1000, Math.floor(options.maxContextChars) || 1000);
3985
+ const contextLength = (values) => buildAssetCurationContextText(script, [kind], selectionForCurationIds(kind, values)).length;
3986
+ for (const id of ids) {
3987
+ const next = [...current, id];
3988
+ if (current.length > 0 && (next.length > maxItems || contextLength(next) > maxContextChars)) {
3989
+ chunks.push(current);
3990
+ current = [id];
3991
+ }
3992
+ else {
3993
+ current = next;
3994
+ }
3995
+ if (current.length === 1 && contextLength(current) > maxContextChars) {
3996
+ chunks.push(current);
3997
+ current = [];
3998
+ }
3999
+ }
4000
+ if (current.length > 0)
4001
+ chunks.push(current);
4002
+ };
4003
+ for (const group of groups) {
4004
+ if (strOf(group["kind"]) !== kind)
4005
+ continue;
4006
+ const ids = asList(group["ids"]).map((id) => strOf(id).trim()).filter((id) => id && existing.has(id) && !seen.has(id));
4007
+ for (const id of ids)
4008
+ seen.add(id);
4009
+ if (ids.length > 0)
4010
+ pushSplitGroup(ids);
4011
+ }
4012
+ const missing = assetIdsForCurationKind(script, kind).filter((id) => !seen.has(id));
4013
+ if (missing.length > 0)
4014
+ pushSplitGroup(missing);
4015
+ return chunks;
4016
+ }
4017
+ export function buildAssetCurationRepairContextText(opts) {
4018
+ const lines = [];
4019
+ lines.push("# Asset Curation Repair Context");
4020
+ lines.push("");
4021
+ lines.push("Task: repair only the listed broken or missing curation expressions.");
4022
+ lines.push("- Output only <exp>...</exp> lines.");
4023
+ lines.push("- Output expressions only for repair_required_source_keys.");
4024
+ lines.push("- Keep already accepted decisions unless the same source is listed for repair.");
4025
+ lines.push("- Use the same expression grammar and policy as the base context.");
4026
+ lines.push("- If no listed source needs a replacement expression, output exactly <exp>NOOP # no asset curation changes</exp>.");
4027
+ lines.push("");
4028
+ lines.push("repair_required_source_keys:");
4029
+ if (opts.repairSourceKeys.length === 0)
4030
+ lines.push("-");
4031
+ else
4032
+ for (const key of opts.repairSourceKeys)
4033
+ lines.push(`- ${key}`);
4034
+ lines.push("");
4035
+ lines.push("rejected_expressions:");
4036
+ if (opts.rejectedExpressions.length === 0) {
4037
+ lines.push("-");
4038
+ }
4039
+ else {
4040
+ const repairKeys = new Set(opts.repairSourceKeys);
4041
+ for (const item of opts.rejectedExpressions) {
4042
+ const sourceKey = strOf(item["source_key"]);
4043
+ lines.push(`- index=${strOf(item["index"])} source=${sourceKey || "-"} error=${compactCurationText(item["error"], 220)}`);
4044
+ if (sourceKey && repairKeys.has(sourceKey)) {
4045
+ lines.push(` raw=${compactCurationText(item["raw"], 360)}`);
4046
+ }
4047
+ else {
4048
+ lines.push(" raw=[omitted: invalid output was not tied to one repair source; use repair_required_source_keys and focused context below]");
4049
+ }
4050
+ }
4051
+ }
4052
+ const repairKeys = new Set(opts.repairSourceKeys);
4053
+ const acceptedForSameSource = opts.acceptedDecisions.filter((decision) => repairKeys.has(curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]))));
4054
+ lines.push("");
4055
+ lines.push("accepted_decisions_for_repair_sources:");
4056
+ if (acceptedForSameSource.length === 0) {
4057
+ lines.push("-");
4058
+ }
4059
+ else {
4060
+ for (const decision of acceptedForSameSource)
4061
+ lines.push(`- ${renderAssetCurationDecisionExpression(decision)}`);
4062
+ }
4063
+ lines.push("");
4064
+ lines.push("base_context:");
4065
+ lines.push(opts.baseContextText.trim());
4066
+ return `${lines.join("\n")}\n`;
4067
+ }
4068
+ function curationCoverageRequirements(script) {
4069
+ const requirements = {
4070
+ actor: new Map(),
4071
+ location: new Map(),
4072
+ prop: new Map(),
4073
+ };
4074
+ const evidence = buildCurationEvidence(script);
4075
+ const actorUsage = sceneAssetUsage(script, "actors", "actor_id");
4076
+ for (const actor of asList(script["actors"])) {
4077
+ if (!isDict(actor))
4078
+ continue;
4079
+ const actorId = strOf(actor["actor_id"]);
4080
+ if (!actorId)
4081
+ continue;
4082
+ const actorEvidence = evidence.actor.get(actorId) ?? emptyCurationEvidence();
4083
+ const identityCandidates = actorIdentityCandidates(script, actorId, strOf(actor["actor_name"]), actorEvidence);
4084
+ const sceneCount = sceneCountFromUsage(actorUsage.get(actorId) ?? { episodes: new Set(), scenes: new Set() });
4085
+ const requirement = curationDecisionRequirement("actor", sceneCount, [], strOf(actor["actor_name"]).trim(), [], identityCandidates);
4086
+ if (requirement)
4087
+ requirements.actor.set(actorId, requirement);
4088
+ }
4089
+ for (const prop of asList(script["props"])) {
4090
+ if (!isDict(prop))
4091
+ continue;
4092
+ const propId = strOf(prop["prop_id"]);
4093
+ if (propId)
4094
+ requirements.prop.set(propId, "prop_exhaustive");
4095
+ }
4096
+ const locationRelatedById = clusterMembersByAssetId(similarAssetClusters(script, "location"));
4097
+ const locationUsage = sceneAssetUsage(script, "locations", "location_id");
4098
+ for (const location of asList(script["locations"])) {
4099
+ if (!isDict(location))
4100
+ continue;
4101
+ const locationId = strOf(location["location_id"]);
4102
+ if (!locationId)
4103
+ continue;
4104
+ const relatedNames = locationRelatedById.get(locationId) ?? [];
4105
+ const locationEvidence = evidence.location.get(locationId) ?? emptyCurationEvidence();
4106
+ const name = strOf(location["location_name"]).trim();
4107
+ const sceneCount = sceneCountFromUsage(locationUsage.get(locationId) ?? { episodes: new Set(), scenes: new Set() });
4108
+ const parentCandidates = locationParentCandidates(script, locationId, name);
4109
+ const requirement = curationDecisionRequirement("location", sceneCount, relatedNames, name, idNameValuesToNames(locationEvidence.coLocations), parentCandidates);
4110
+ if (requirement)
4111
+ requirements.location.set(locationId, requirement);
4112
+ }
4113
+ return requirements;
4114
+ }
4115
+ function implicitAuditCandidates(script, decisions) {
4116
+ const requirements = curationCoverageRequirements(script);
4117
+ const decided = curationDecisionSourceSet(decisions);
4118
+ const evidence = buildCurationEvidence(script);
4119
+ const candidates = [];
4120
+ for (const actor of asList(script["actors"])) {
4121
+ if (!isDict(actor))
4122
+ continue;
4123
+ const actorId = strOf(actor["actor_id"]);
4124
+ if (!actorId || decided.has(curationSourceKey("actor", actorId)))
4125
+ continue;
4126
+ if (requirements.actor.has(actorId))
4127
+ continue;
4128
+ if (hasEnumeratedActorName(strOf(actor["actor_name"]))) {
4129
+ candidates.push({ kind: "actor", id: actorId, reason: "implicit_keep_enumerated_actor_review" });
4130
+ }
4131
+ }
4132
+ for (const location of asList(script["locations"])) {
4133
+ if (!isDict(location))
4134
+ continue;
4135
+ const locationId = strOf(location["location_id"]);
4136
+ if (!locationId || decided.has(curationSourceKey("location", locationId)))
4137
+ continue;
4138
+ if (requirements.location.has(locationId))
4139
+ continue;
4140
+ const item = evidence.location.get(locationId) ?? emptyCurationEvidence();
4141
+ if (hasScopedLocationCue(strOf(location["location_name"]), idNameValuesToNames(item.coLocations))) {
4142
+ candidates.push({ kind: "location", id: locationId, reason: "implicit_keep_scoped_location_canonical_review" });
4143
+ }
4144
+ }
4145
+ return candidates;
4146
+ }
4147
+ function curationDecisionSourceSet(decisions) {
4148
+ const out = new Set();
4149
+ for (const decision of decisions) {
4150
+ const kind = strOf(decision["kind"]);
4151
+ const sourceId = strOf(decision["source_id"]);
4152
+ if (kind && sourceId)
4153
+ out.add(curationSourceKey(kind, sourceId));
4154
+ }
4155
+ return out;
4156
+ }
4157
+ function missingCurationRequirements(script, decisions) {
4158
+ const requirements = curationCoverageRequirements(script);
4159
+ const decided = curationDecisionSourceSet(decisions);
4160
+ const missing = [];
4161
+ for (const kind of ["actor", "location", "prop"]) {
4162
+ for (const [id, reason] of requirements[kind]) {
4163
+ if (!decided.has(curationSourceKey(kind, id)))
4164
+ missing.push({ kind, id, reason });
4165
+ }
4166
+ }
4167
+ return missing;
4168
+ }
4169
+ export function missingAssetCurationRequiredSourceKeys(script, rawPlan) {
4170
+ return missingCurationRequirements(script, normalizedCurationDecisions(rawPlan))
4171
+ .map((item) => curationSourceKey(item.kind, item.id));
4172
+ }
4173
+ export function assetCurationAuditCandidateSourceKeys(script, rawPlan) {
4174
+ return implicitAuditCandidates(script, normalizedCurationDecisions(rawPlan))
4175
+ .map((item) => curationSourceKey(item.kind, item.id));
4176
+ }
4177
+ export function assetCurationAuditSourceKeys(script, rawPlan) {
4178
+ const decisions = normalizedCurationDecisions(rawPlan);
4179
+ normalizeCurationPlanTargets(script, decisions);
4180
+ const evidence = buildCurationEvidence(script);
4181
+ const clusters = [
4182
+ ...similarAssetClusters(script, "actor"),
4183
+ ...similarAssetClusters(script, "location"),
4184
+ ...similarAssetClusters(script, "prop"),
4185
+ ];
4186
+ const relatedById = clusterMembersByAssetId(clusters);
4187
+ const keys = [];
4188
+ for (const decision of decisions) {
4189
+ const kind = strOf(decision["kind"]);
4190
+ if (kind !== "actor" && kind !== "location" && kind !== "prop")
4191
+ continue;
4192
+ const sourceId = strOf(decision["source_id"]);
4193
+ if (!sourceId || !auditCandidateReason(script, decision, evidence, relatedById))
4194
+ continue;
4195
+ uniqueAdd(keys, curationSourceKey(kind, sourceId));
4196
+ }
4197
+ for (const missing of missingCurationRequirements(script, decisions)) {
4198
+ uniqueAdd(keys, curationSourceKey(missing.kind, missing.id));
4199
+ }
4200
+ for (const candidate of implicitAuditCandidates(script, decisions)) {
4201
+ uniqueAdd(keys, curationSourceKey(candidate.kind, candidate.id));
4202
+ }
4203
+ return keys;
4204
+ }
4205
+ function assertCurationCoverage(script, decisions) {
4206
+ const requirements = curationCoverageRequirements(script);
4207
+ const decided = curationDecisionSourceSet(decisions);
4208
+ const missing = [];
4209
+ const invalidPropKeeps = [];
4210
+ const requiredCounts = {};
4211
+ const missingCounts = {};
4212
+ const propKeepCategories = {};
4213
+ for (const kind of ["actor", "location", "prop"]) {
4214
+ requiredCounts[kind] = requirements[kind].size;
4215
+ missingCounts[kind] = 0;
4216
+ for (const [id, reason] of requirements[kind]) {
4217
+ if (decided.has(curationSourceKey(kind, id)))
4218
+ continue;
4219
+ missingCounts[kind] += 1;
4220
+ missing.push(`${kind}:${id} (${reason})`);
4221
+ }
4222
+ }
4223
+ for (const decision of decisions) {
4224
+ if (strOf(decision["kind"]) !== "prop" || strOf(decision["decision"]) !== "keep")
4225
+ continue;
4226
+ const category = propKeepCategory(decision["reason"]);
4227
+ const sourceId = strOf(decision["source_id"]);
4228
+ if (!category) {
4229
+ invalidPropKeeps.push(`prop:${sourceId || "<empty>"} (${strOf(decision["reason"]).slice(0, 120) || "missing category"})`);
4230
+ continue;
4231
+ }
4232
+ propKeepCategories[category] = (propKeepCategories[category] ?? 0) + 1;
4233
+ }
4234
+ if (missing.length > 0) {
4235
+ curationBlocked("Curation plan is missing required asset decisions.", missing.slice(0, 80));
4236
+ }
4237
+ if (invalidPropKeeps.length > 0) {
4238
+ curationBlocked(`Prop KEEP decisions must include category=<${propKeepCategoryList()}> in the reason.`, invalidPropKeeps.slice(0, 80));
4239
+ }
4240
+ return {
4241
+ required: requiredCounts,
4242
+ missing: missingCounts,
4243
+ prop_keep_categories: propKeepCategories,
4244
+ policy: {
4245
+ props: "exhaustive",
4246
+ actors: "scene_count <= 1",
4247
+ locations: "scoped locations and similar/name cluster members",
4248
+ },
4249
+ };
4250
+ }
4251
+ function normalizedCurationDecisions(rawPlan) {
4252
+ const payload = isDict(rawPlan) ? rawPlan : {};
4253
+ return dedupeCurationDecisions(asList(payload["decisions"]).filter(isDict).map((item) => normalizeCurationDecision(item)));
4254
+ }
4255
+ function hasEnumeratedActorName(nameRaw) {
4256
+ const name = cleanName(nameRaw);
4257
+ return name.length > 1 && /(?:[甲乙丙丁戊己庚辛壬癸]|[A-Z]|[0-9]+)$/.test(name);
4258
+ }
4259
+ const PROP_KEEP_CATEGORIES = new Set([
4260
+ "readable_evidence",
4261
+ "container_device",
4262
+ "transformation_key",
4263
+ "exchanged_object",
4264
+ "signature_instance",
4265
+ "character_bound_core",
4266
+ "signature_weapon",
4267
+ "visual_symbol",
4268
+ "set_device",
4269
+ ]);
4270
+ function propKeepCategory(reasonRaw) {
4271
+ const reason = strOf(reasonRaw);
4272
+ const match = /\bcategory=([a-z_]+)\b/.exec(reason);
4273
+ if (!match)
4274
+ return "";
4275
+ const category = match[1];
4276
+ return PROP_KEEP_CATEGORIES.has(category) ? category : "";
4277
+ }
4278
+ function propKeepCategoryList() {
4279
+ return [...PROP_KEEP_CATEGORIES].join("|");
4280
+ }
4281
+ function propHasSingleOwnerActor(item) {
4282
+ return singleIdNameValue(item.coActors) !== "";
4283
+ }
4284
+ function propHasHeroRescueEvidence(asset, item) {
4285
+ if (propHasSingleOwnerActor(item)) {
4286
+ return asList(asset["states"]).length > 0 || (item.sceneRefs.size >= 2 && item.actionRefs.size >= 2) || item.actionRefs.size >= 4;
4287
+ }
4288
+ return asList(asset["states"]).length > 0 || item.sceneRefs.size >= 3 || (item.sceneRefs.size >= 2 && item.actionRefs.size >= 2);
4289
+ }
4290
+ function auditCandidateReason(script, decision, evidence, relatedById) {
4291
+ const kind = strOf(decision["kind"]);
4292
+ const sourceId = strOf(decision["source_id"]);
4293
+ const action = strOf(decision["decision"]);
4294
+ if (kind === "actor") {
4295
+ const item = evidence.actor.get(sourceId) ?? emptyCurationEvidence();
4296
+ const asset = assetMapById(script, "actor").get(sourceId);
4297
+ if (action === "drop" || action === "move_to_speaker") {
4298
+ if (item.dialogueCount > 0 || item.speakingScenes.size > 0)
4299
+ return "speaking_actor_removed_or_speaker";
4300
+ }
4301
+ if (action === "keep" && asset) {
4302
+ if (hasEnumeratedActorName(strOf(asset["actor_name"])))
4303
+ return "kept_enumerated_actor_review";
4304
+ const identity = actorIdentityCandidates(script, sourceId, strOf(asset["actor_name"]), item);
4305
+ if (identity.length > 0)
4306
+ return "kept_actor_identity_or_form_review";
4307
+ if (item.sceneRefs.size <= 1 && item.dialogueCount === 0 && item.actionMentionCount === 0)
4308
+ return "kept_low_evidence_actor_review";
4309
+ }
4310
+ }
4311
+ if (kind === "location" && (action === "drop" || action === "merge")) {
4312
+ const asset = assetMapById(script, "location").get(sourceId);
4313
+ if (!asset)
4314
+ return "";
4315
+ const item = evidence.location.get(sourceId) ?? emptyCurationEvidence();
4316
+ if (asList(asset["states"]).length > 0 || item.sceneRefs.size > 1)
4317
+ return "stateful_or_multiscene_location_removed_or_merged";
4318
+ }
4319
+ if (kind === "location" && action === "keep") {
4320
+ const asset = assetMapById(script, "location").get(sourceId);
4321
+ if (!asset)
4322
+ return "";
4323
+ const item = evidence.location.get(sourceId) ?? emptyCurationEvidence();
4324
+ const relatedNames = relatedById.get(sourceId) ?? [];
4325
+ if (hasScopedLocationCue(strOf(asset["location_name"]), idNameValuesToNames(item.coLocations)))
4326
+ return "kept_scoped_location_canonical_review";
4327
+ if (locationParentCandidates(script, sourceId, strOf(asset["location_name"])).length > 0)
4328
+ return "kept_parent_child_location_review";
4329
+ if (relatedNames.length > 0 && item.sceneRefs.size <= 2)
4330
+ return "kept_clustered_location_canonical_review";
4331
+ }
4332
+ if (kind === "prop" && action === "keep") {
4333
+ const item = evidence.prop.get(sourceId) ?? emptyCurationEvidence();
4334
+ if (!propKeepCategory(decision["reason"]))
4335
+ return "kept_prop_missing_keep_category_review";
4336
+ if (propHasSingleOwnerActor(item))
4337
+ return "kept_owner_bound_prop_state_review";
4338
+ return "kept_prop_strict_review";
4339
+ }
4340
+ if (kind === "prop" && (action === "drop" || action === "move_to_state" || action === "merge")) {
4341
+ const asset = assetMapById(script, "prop").get(sourceId);
4342
+ if (!asset)
4343
+ return "";
4344
+ const item = evidence.prop.get(sourceId) ?? emptyCurationEvidence();
4345
+ if (propHasHeroRescueEvidence(asset, item))
4346
+ return "dropped_or_moved_prop_hero_rescue_review";
4347
+ }
4348
+ return "";
4349
+ }
4350
+ export function buildAssetCurationAuditContextText(script, primaryPlan, selectedSourceKeys) {
4351
+ const decisions = normalizedCurationDecisions(primaryPlan);
4352
+ normalizeCurationPlanTargets(script, decisions);
4353
+ const missingRequired = missingCurationRequirements(script, decisions);
4354
+ const implicitAudit = implicitAuditCandidates(script, decisions);
4355
+ const selected = selectedSourceKeys && selectedSourceKeys.length > 0 ? new Set(selectedSourceKeys) : null;
4356
+ const includeCandidate = (kind, id) => !selected || selected.has(curationSourceKey(kind, id));
4357
+ const evidence = buildCurationEvidence(script);
4358
+ const actorUsage = sceneAssetUsage(script, "actors", "actor_id");
4359
+ const locationUsage = sceneAssetUsage(script, "locations", "location_id");
4360
+ const propUsage = sceneAssetUsage(script, "props", "prop_id");
4361
+ const clusters = [
4362
+ ...similarAssetClusters(script, "actor"),
4363
+ ...similarAssetClusters(script, "location"),
4364
+ ...similarAssetClusters(script, "prop"),
4365
+ ];
4366
+ const relatedById = clusterMembersByAssetId(clusters);
4367
+ const crossKindNames = crossKindNameHints(script);
4368
+ const usageByKind = {
4369
+ actor: actorUsage,
4370
+ location: locationUsage,
4371
+ prop: propUsage,
4372
+ };
4373
+ const lines = [];
4374
+ lines.push("# Asset Curation Audit Context");
4375
+ lines.push("mode: audit-overrides-only");
4376
+ lines.push("policy:");
4377
+ lines.push("- Return only <exp>...</exp> lines. The first character of the response must be <.");
4378
+ lines.push("- Do not write headings, bullet explanations, candidate reviews, or prose outside expressions.");
4379
+ lines.push("- Output expressions only for listed candidates when the primary decision should be corrected or when the candidate is marked primary=MISSING.");
4380
+ lines.push("- Otherwise output exactly <exp>NOOP # no asset curation changes</exp>.");
4381
+ lines.push("- Audit expressions must reference source ids listed here. Existing primary sources are overridden; primary=MISSING and primary=IMPLICIT_KEEP sources are added.");
4382
+ lines.push("- For primary KEEP actor candidates, output SPEAKER/MERGE/RENAME only when the kept actor is a generic numbered role, anonymous crowd, pure voice label, or duplicate identity.");
4383
+ lines.push("- For primary KEEP location candidates, output RENAME/MERGE only when canonical scope or duplicate/parent-child granularity is wrong; otherwise leave it unchanged.");
4384
+ lines.push(`- For primary KEEP prop candidates, output DROP/MERGE/STATE/RENAME when the prop is too broad; output KEEP only to add/replace a valid category reason from ${propKeepCategoryList()}.`);
4385
+ lines.push("- For primary DROP/MERGE/STATE prop candidates, output KEEP/RENAME/MERGE only when standalone hero-prop continuity is stronger than the primary decision.");
4386
+ lines.push(`- Every KEEP prop reason must start like category=signature_instance; using one of ${propKeepCategoryList()}, followed by concrete evidence.`);
4387
+ lines.push("- Review props with a strict refined prop boundary: keep named/signature physical instances with continuity, including readable evidence, devices, transformation keys, exchanged objects, signature weapons/tools, visual symbols, and recurring character-bound core props.");
4388
+ lines.push("- Drop UI, ordinary documents, food, generic set dressing, and context-only objects.");
4389
+ lines.push("- When a prop line has ownerA=actor_id:name, use STATE only for body marks or worn appearance; detachable named devices/weapons/tools/accessories with continuity may remain KEEP.");
4390
+ lines.push("- For STATE/PROP/MERGE targets, use only ids visible in the candidate line/context such as identity=, parent=, same=, coA=, coL=, or cluster ids. If no valid target id is visible, choose DROP instead of guessing.");
4391
+ lines.push("");
4392
+ lines.push("candidates:");
4393
+ let count = 0;
4394
+ for (const decision of decisions) {
4395
+ const kind = strOf(decision["kind"]);
4396
+ if (kind !== "actor" && kind !== "location" && kind !== "prop")
4397
+ continue;
4398
+ const sourceId = strOf(decision["source_id"]);
4399
+ if (!includeCandidate(kind, sourceId))
4400
+ continue;
4401
+ const reason = auditCandidateReason(script, decision, evidence, relatedById);
4402
+ if (!sourceId || !reason)
4403
+ continue;
4404
+ const asset = assetMapById(script, kind).get(sourceId);
4405
+ if (!asset)
4406
+ continue;
4407
+ count += 1;
4408
+ lines.push(`candidate ${count}: primary=${renderAssetCurationDecisionExpression(decision)} | audit_reason=${reason}`);
4409
+ const compact = compactAssetForCuration(script, kind, asset, usageByKind[kind], evidence, relatedById, crossKindNames);
4410
+ for (const line of formatCurationAssetLine(compact))
4411
+ lines.push(` ${line}`);
4412
+ }
4413
+ for (const missing of missingRequired) {
4414
+ if (!includeCandidate(missing.kind, missing.id))
4415
+ continue;
4416
+ const asset = assetMapById(script, missing.kind).get(missing.id);
4417
+ if (!asset)
4418
+ continue;
4419
+ count += 1;
4420
+ lines.push(`candidate ${count}: primary=MISSING ${missing.kind}:${missing.id} | audit_reason=missing_required_decision:${missing.reason}`);
4421
+ const compact = compactAssetForCuration(script, missing.kind, asset, usageByKind[missing.kind], evidence, relatedById, crossKindNames);
4422
+ for (const line of formatCurationAssetLine(compact))
4423
+ lines.push(` ${line}`);
4424
+ }
4425
+ for (const candidate of implicitAudit) {
4426
+ if (!includeCandidate(candidate.kind, candidate.id))
4427
+ continue;
4428
+ const asset = assetMapById(script, candidate.kind).get(candidate.id);
4429
+ if (!asset)
4430
+ continue;
4431
+ count += 1;
4432
+ lines.push(`candidate ${count}: primary=IMPLICIT_KEEP ${candidate.kind}:${candidate.id} | audit_reason=${candidate.reason}`);
4433
+ const compact = compactAssetForCuration(script, candidate.kind, asset, usageByKind[candidate.kind], evidence, relatedById, crossKindNames);
4434
+ for (const line of formatCurationAssetLine(compact))
4435
+ lines.push(` ${line}`);
4436
+ }
4437
+ if (count === 0)
4438
+ lines.push("-");
4439
+ lines.push("");
4440
+ lines.push(`candidate_count: ${count}`);
4441
+ return `${lines.join("\n")}\n`;
4442
+ }
4443
+ function curationDecisionBySource(decisions) {
4444
+ const out = new Map();
4445
+ for (const decision of decisions) {
4446
+ const key = curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]));
4447
+ if (key)
4448
+ out.set(key, decision);
4449
+ }
4450
+ return out;
4451
+ }
4452
+ function locationCanonicalizationCandidates(script, decisions) {
4453
+ const evidence = buildCurationEvidence(script);
4454
+ const relatedById = clusterMembersByAssetId(similarAssetClusters(script, "location"));
4455
+ const bySource = curationDecisionBySource(decisions);
4456
+ const out = [];
4457
+ for (const location of asList(script["locations"])) {
4458
+ if (!isDict(location))
4459
+ continue;
4460
+ const id = strOf(location["location_id"]);
4461
+ if (!id)
4462
+ continue;
4463
+ const item = evidence.location.get(id) ?? emptyCurationEvidence();
4464
+ const relatedNames = relatedById.get(id) ?? [];
4465
+ const hasScope = hasScopedLocationCue(strOf(location["location_name"]), idNameValuesToNames(item.coLocations));
4466
+ const hasParentGranularity = locationParentCandidates(script, id, strOf(location["location_name"])).length > 0;
4467
+ const hasCluster = relatedNames.length > 0;
4468
+ if (!hasScope && !hasParentGranularity && !hasCluster)
4469
+ continue;
4470
+ const decision = bySource.get(curationSourceKey("location", id));
4471
+ const reason = hasScope
4472
+ ? "location_scope_canonicalization"
4473
+ : hasParentGranularity
4474
+ ? "location_granularity_canonicalization"
4475
+ : "location_cluster_canonicalization";
4476
+ out.push({
4477
+ id,
4478
+ reason,
4479
+ primary: decision ? renderAssetCurationDecisionExpression(decision) : `IMPLICIT_KEEP location:${id}`,
4480
+ });
4481
+ }
4482
+ return out;
4483
+ }
4484
+ export function locationCanonicalizationCandidateSourceKeys(script, rawPlan) {
4485
+ const decisions = normalizedCurationDecisions(rawPlan);
4486
+ return locationCanonicalizationCandidates(script, decisions).map((item) => curationSourceKey("location", item.id));
4487
+ }
4488
+ function locationCanonicalizationCoverage(script, decisions) {
4489
+ const candidates = locationCanonicalizationCandidates(script, decisions);
4490
+ const decided = curationDecisionSourceSet(decisions);
4491
+ const requiredScoped = candidates.filter((candidate) => candidate.reason === "location_scope_canonicalization");
4492
+ const missing = requiredScoped
4493
+ .map((candidate) => curationSourceKey("location", candidate.id))
4494
+ .filter((key) => key && !decided.has(key));
4495
+ if (missing.length > 0) {
4496
+ curationBlocked("Location canonicalization is missing required scoped location coverage.", missing.slice(0, 80));
4497
+ }
4498
+ return {
4499
+ location_canonical_candidates: candidates.length,
4500
+ location_canonical_required_scoped: requiredScoped.length,
4501
+ location_canonical_missing_required_scoped: missing.length,
4502
+ };
4503
+ }
4504
+ export function buildLocationCanonicalizationAuditContextText(script, rawPlan, selectedSourceKeys) {
4505
+ const decisions = normalizedCurationDecisions(rawPlan);
4506
+ normalizeCurationPlanTargets(script, decisions);
4507
+ const evidence = buildCurationEvidence(script);
4508
+ const locationUsage = sceneAssetUsage(script, "locations", "location_id");
4509
+ const clusters = similarAssetClusters(script, "location");
4510
+ const relatedById = clusterMembersByAssetId(clusters);
4511
+ const crossKindNames = crossKindNameHints(script);
4512
+ const selected = selectedSourceKeys && selectedSourceKeys.length > 0 ? new Set(selectedSourceKeys) : null;
4513
+ const candidates = locationCanonicalizationCandidates(script, decisions)
4514
+ .filter((candidate) => !selected || selected.has(curationSourceKey("location", candidate.id)));
4515
+ const lines = [];
4516
+ lines.push("# Location Canonicalization Audit Context");
4517
+ lines.push("mode: location-rename-merge-only");
4518
+ lines.push("policy:");
4519
+ lines.push("- Return only <exp>...</exp> lines. The first character of the response must be <.");
4520
+ lines.push("- Output only RENAME location, MERGE location, or NOOP. Do not output KEEP, DROP, STATE, PROP, SPEAKER, actor, or prop operations.");
4521
+ lines.push("- For unchanged candidates, output nothing. KEEP is invalid in this pass and must not be used to confirm a location.");
4522
+ lines.push("- Audit expressions must reference source ids listed here. Existing primary sources are overridden; primary=IMPLICIT_KEEP sources are added.");
4523
+ lines.push("- Prefer scoped canonical names when a location co-occurs with a world/realm/domain marker and the unscoped name would be ambiguous.");
4524
+ lines.push("- For candidates with scopeHint, prefer RENAME to `<scopeHint>·<name>` unless it should MERGE into an existing scoped duplicate or the name is intentionally cross-scope.");
4525
+ lines.push("- Use RENAME for the same production set whose name lacks useful scope. Use MERGE only for duplicate aliases of the same production set.");
4526
+ lines.push("- For candidates with parent/gran hints, MERGE only when the child is a non-distinct transition, doorway, angle, nearby, or overhead view of the parent; leave distinct shootable sets unchanged.");
4527
+ lines.push("- If no listed candidate needs canonicalization, output exactly <exp>NOOP # no location canonicalization changes</exp>.");
4528
+ lines.push("");
4529
+ lines.push("required_scoped_location_ids:");
4530
+ pushRequiredCurationIdLines(lines, "locations", candidates.filter((candidate) => candidate.reason === "location_scope_canonicalization").map((candidate) => candidate.id));
4531
+ lines.push("");
4532
+ lines.push("candidates:");
4533
+ let count = 0;
4534
+ for (const candidate of candidates) {
4535
+ const asset = assetMapById(script, "location").get(candidate.id);
4536
+ if (!asset)
4537
+ continue;
4538
+ count += 1;
4539
+ lines.push(`candidate ${count}: primary=${candidate.primary} | audit_reason=${candidate.reason}`);
4540
+ const compact = compactAssetForCuration(script, "location", asset, locationUsage, evidence, relatedById, crossKindNames);
4541
+ for (const line of formatCurationAssetLine(compact))
4542
+ lines.push(` ${line}`);
4543
+ }
4544
+ if (count === 0)
4545
+ lines.push("-");
4546
+ lines.push("");
4547
+ lines.push("similar_location_clusters:");
4548
+ if (clusters.length === 0) {
4549
+ lines.push("-");
4550
+ }
4551
+ else {
4552
+ for (const cluster of clusters) {
4553
+ const members = asList(cluster["members"]).filter(isDict).map((member) => {
4554
+ const id = compactCurationText(member["id"], 40);
4555
+ const name = compactCurationText(member["name"], 80);
4556
+ return id && name ? `${id}:${name}` : "";
4557
+ }).filter((member) => member);
4558
+ if (members.length > 0)
4559
+ lines.push(`C location | ${members.join(" ~ ")}`);
4560
+ }
4561
+ }
4562
+ lines.push("");
4563
+ lines.push(`candidate_count: ${count}`);
4564
+ return `${lines.join("\n")}\n`;
4565
+ }
4566
+ export function assertLocationCanonicalizationPlan(rawPlan) {
4567
+ const invalid = [];
4568
+ for (const decision of normalizedCurationDecisions(rawPlan)) {
4569
+ const kind = strOf(decision["kind"]);
4570
+ const action = strOf(decision["decision"]);
4571
+ const sourceId = strOf(decision["source_id"]);
4572
+ if (kind !== "location" || (action !== "rename" && action !== "merge")) {
4573
+ invalid.push(`${kind || "<empty>"}:${sourceId || "<empty>"} ${action || "<empty>"}`);
4574
+ }
4575
+ }
4576
+ if (invalid.length > 0) {
4577
+ curationBlocked("Location canonicalization audit may only output location RENAME/MERGE expressions or NOOP.", invalid);
4578
+ }
4579
+ }
4580
+ export function combineLocationCanonicalizationPlan(script, basePlan, locationPlan, allowedNewLocationSources = []) {
4581
+ assertLocationCanonicalizationPlan(locationPlan);
4582
+ const baseDecisions = asList(basePlan["decisions"]).filter(isDict);
4583
+ const locationDecisions = asList(locationPlan["decisions"]).filter(isDict);
4584
+ const baseKeys = curationDecisionSourceSet(baseDecisions);
4585
+ const allowedNewKeys = new Set(allowedNewLocationSources);
4586
+ let addedLocationDecisions = 0;
4587
+ for (const decision of locationDecisions) {
4588
+ const key = curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]));
4589
+ if (!baseKeys.has(key) && allowedNewKeys.has(key))
4590
+ addedLocationDecisions += 1;
4591
+ }
4592
+ const combined = combineAssetCurationPlans(basePlan, locationPlan, allowedNewLocationSources);
4593
+ const locationCoverage = locationCanonicalizationCoverage(script, normalizedCurationDecisions(combined));
4594
+ const primaryExpressionText = strOf(basePlan["primary_expression_text"]).trim();
4595
+ const auditExpressionText = strOf(basePlan["audit_expression_text"]).trim();
4596
+ const locationExpressionText = strOf(locationPlan["expression_text"]).trim();
4597
+ const expressionTexts = [
4598
+ strOf(basePlan["expression_text"]).trim(),
4599
+ locationExpressionText,
4600
+ ].filter((text) => text);
4601
+ const auditSummary = isDict(basePlan["audit_summary"]) ? { ...basePlan["audit_summary"] } : {};
4602
+ auditSummary["location_canonical_decisions"] = locationDecisions.length;
4603
+ auditSummary["location_canonical_added_decisions"] = addedLocationDecisions;
4604
+ auditSummary["location_canonical_noop"] = locationDecisions.length === 0;
4605
+ Object.assign(auditSummary, locationCoverage);
4606
+ return {
4607
+ ...combined,
4608
+ primary_expression_text: primaryExpressionText || strOf(basePlan["expression_text"]),
4609
+ audit_expression_text: auditExpressionText,
4610
+ location_canonical_expression_text: locationExpressionText,
4611
+ expression_text: expressionTexts.join("\n"),
4612
+ primary_expressions: asList(basePlan["primary_expressions"]),
4613
+ audit_expressions: asList(basePlan["audit_expressions"]),
4614
+ location_canonical_expressions: asList(locationPlan["expressions"]),
4615
+ expressions: [...asList(basePlan["expressions"]), ...asList(locationPlan["expressions"])],
4616
+ audit_summary: auditSummary,
4617
+ };
4618
+ }
4619
+ function compactStateNames(asset) {
4620
+ const out = [];
4621
+ for (const state of asList(asset["states"])) {
4622
+ if (!isDict(state))
4623
+ continue;
4624
+ const n = strOf(state["state_name"]).trim();
4625
+ if (n)
4626
+ out.push(n);
4627
+ }
4628
+ return out;
4629
+ }
4630
+ function compactAssetRefs(refs, idKey, replacements, droppedIds) {
4631
+ const compacted = [];
4632
+ const seen = new Set();
4633
+ for (const ref of asList(refs)) {
4634
+ if (!isDict(ref))
4635
+ continue;
4636
+ const rawId = strOf(ref[idKey]);
4637
+ const hasReplacement = replacements.has(rawId);
4638
+ if (!rawId || (!hasReplacement && droppedIds.has(rawId)))
4639
+ continue;
4640
+ const nextId = replacements.get(rawId) ?? rawId;
4641
+ const stateId = nextId !== rawId ? null : ref["state_id"];
4642
+ const key = `${nextId}::${strOf(stateId)}`;
4643
+ if (seen.has(key))
4644
+ continue;
4645
+ seen.add(key);
4646
+ compacted.push({ [idKey]: nextId, state_id: stateId });
4647
+ }
4648
+ return compacted;
4649
+ }
4650
+ function assetListKey(kind) {
4651
+ if (kind === "actor")
4652
+ return "actors";
4653
+ if (kind === "location")
4654
+ return "locations";
4655
+ return "props";
4656
+ }
4657
+ function assetIdKey(kind) {
4658
+ if (kind === "actor")
4659
+ return "actor_id";
4660
+ if (kind === "location")
4661
+ return "location_id";
4662
+ return "prop_id";
4663
+ }
4664
+ function assetNameKey(kind) {
4665
+ if (kind === "actor")
4666
+ return "actor_name";
4667
+ if (kind === "location")
4668
+ return "location_name";
4669
+ return "prop_name";
4670
+ }
4671
+ function addAssetAlias(target, kind, aliasRaw) {
4672
+ const alias = cleanName(aliasRaw);
4673
+ const targetName = strOf(target[assetNameKey(kind)]).trim();
4674
+ if (!alias || alias === targetName)
4675
+ return;
4676
+ if (!isList(target["aliases"]))
4677
+ target["aliases"] = [];
4678
+ uniqueAdd(target["aliases"], alias);
4679
+ }
4680
+ function assetMapById(script, kind) {
4681
+ const out = new Map();
4682
+ for (const item of asList(script[assetListKey(kind)])) {
4683
+ if (!isDict(item))
4684
+ continue;
4685
+ const id = strOf(item[assetIdKey(kind)]).trim();
4686
+ if (id)
4687
+ out.set(id, item);
4688
+ }
4689
+ return out;
4690
+ }
4691
+ function nextNumericId(existing, prefix) {
4692
+ let max = 0;
4693
+ const re = new RegExp(`^${prefix}_(\\d+)$`);
4694
+ for (const id of existing) {
4695
+ const match = re.exec(id);
4696
+ if (!match)
4697
+ continue;
4698
+ max = Math.max(max, Number(match[1] ?? 0));
4699
+ }
4700
+ return fmtId(prefix, max + 1);
4701
+ }
4702
+ function nextAssetId(script, kind) {
4703
+ const prefix = kind === "actor" ? "act" : kind === "location" ? "loc" : "prp";
4704
+ return nextNumericId(assetMapById(script, kind).keys(), prefix);
4705
+ }
4706
+ function nextStateId(script) {
4707
+ const ids = [];
4708
+ for (const kind of ["actor", "location", "prop"]) {
4709
+ for (const asset of asList(script[assetListKey(kind)])) {
4710
+ for (const state of asList(asset["states"])) {
4711
+ const id = strOf(state["state_id"]).trim();
4712
+ if (id)
4713
+ ids.push(id);
4714
+ }
4715
+ }
4716
+ }
4717
+ return nextNumericId(ids, "st");
4718
+ }
4719
+ function appendState(asset, script, stateNameRaw, descriptionRaw = "") {
4720
+ const stateName = strOf(stateNameRaw).trim();
4721
+ if (!stateName) {
4722
+ throw new CliError("DIRECT CURATION BLOCKED: invalid state name", "Invalid state name.", {
4723
+ exitCode: EXIT_NEEDS_AGENT,
4724
+ required: ["non-empty state_name"],
4725
+ received: ["empty state_name"],
4726
+ nextSteps: ["Inspect asset_curation.json and rerun direct init after fixing the provider plan."],
4727
+ });
4728
+ }
4729
+ if (!isList(asset["states"]))
4730
+ asset["states"] = [];
4731
+ for (const state of asList(asset["states"])) {
4732
+ if (strOf(state["state_name"]).trim() === stateName)
4733
+ return strOf(state["state_id"]);
4734
+ }
4735
+ const state = { state_id: nextStateId(script), state_name: stateName };
4736
+ const description = strOf(descriptionRaw).trim();
4737
+ if (description)
4738
+ state["description"] = description;
4739
+ asset["states"].push(state);
4740
+ return strOf(state["state_id"]);
4741
+ }
4742
+ function speakerIdForActor(actorId) {
4743
+ return `spk_${actorId}`;
4744
+ }
4745
+ function nextSpeakerId(script, sourceKind) {
4746
+ const prefix = `spk_${sourceKind}`;
4747
+ let max = 0;
4748
+ const re = new RegExp(`^${prefix}_(\\d+)$`);
4749
+ for (const speaker of asList(script["speakers"])) {
4750
+ const id = strOf(speaker["speaker_id"]);
4751
+ const match = re.exec(id);
4752
+ if (!match)
4753
+ continue;
4754
+ max = Math.max(max, Number(match[1] ?? 0));
4755
+ }
4756
+ return `${prefix}_${String(max + 1).padStart(3, "0")}`;
4757
+ }
4758
+ function addLocationAlias(target, aliasRaw) {
4759
+ const alias = cleanName(aliasRaw);
4760
+ const targetName = strOf(target["location_name"]).trim();
4761
+ if (!alias || alias === targetName)
4762
+ return;
4763
+ if (!isList(target["aliases"]))
4764
+ target["aliases"] = [];
4765
+ uniqueAdd(target["aliases"], alias);
4766
+ }
4767
+ export function applyLocationMerges(script, replacements) {
4768
+ if (replacements.size === 0)
4769
+ return;
4770
+ const locations = asList(script["locations"]);
4771
+ const byId = new Map();
4772
+ for (const loc of locations) {
4773
+ if (isDict(loc))
4774
+ byId.set(strOf(loc["location_id"]), loc);
4775
+ }
4776
+ for (const [oldId, targetId] of replacements) {
4777
+ const old = byId.get(oldId);
4778
+ const target = byId.get(targetId);
4779
+ if (!old || !target)
4780
+ continue;
4781
+ addLocationAlias(target, strOf(old["location_name"]));
4782
+ for (const alias of asList(old["aliases"])) {
4783
+ addLocationAlias(target, strOf(alias));
4784
+ }
4785
+ }
4786
+ script["locations"] = locations.filter((loc) => isDict(loc) && !replacements.has(strOf(loc["location_id"])));
4787
+ }
4788
+ function sceneCountFromUsage(usage) {
4789
+ return usage.scenes.size;
4790
+ }
4791
+ export function actorCurationDecisions(script) {
4792
+ const usage = sceneAssetUsage(script, "actors", "actor_id");
4793
+ const decisions = [];
4794
+ for (const actor of asList(script["actors"])) {
4795
+ if (!isDict(actor))
4796
+ continue;
4797
+ const actorId = strOf(actor["actor_id"]);
4798
+ const u = usage.get(actorId) ?? { episodes: new Set(), scenes: new Set() };
4799
+ const sceneCount = sceneCountFromUsage(u);
4800
+ const decision = "keep";
4801
+ const reason = `保留:出现在 ${sceneCount} 个 scene。`;
4802
+ decisions.push({ actor_id: actorId, name: actor["actor_name"], scene_count: sceneCount, decision, reason });
4803
+ }
4804
+ return decisions;
4805
+ }
4806
+ export function propCurationDecisions(script) {
4807
+ const usage = sceneAssetUsage(script, "props", "prop_id");
4808
+ const decisions = [];
4809
+ for (const prop of asList(script["props"])) {
4810
+ if (!isDict(prop))
4811
+ continue;
4812
+ const propId = strOf(prop["prop_id"]);
4813
+ const u = usage.get(propId) ?? { episodes: new Set(), scenes: new Set() };
4814
+ const sceneCount = sceneCountFromUsage(u);
4815
+ const decision = "keep";
4816
+ const reason = `保留:出现在 ${sceneCount} 个 scene。`;
4817
+ decisions.push({ prop_id: propId, name: prop["prop_name"], scene_count: sceneCount, decision, reason });
4818
+ }
4819
+ return decisions;
4820
+ }
4821
+ function rewriteStateChanges(changes, locationReplacements, droppedPropIds, droppedActorIds) {
4822
+ const rewritten = [];
4823
+ for (const change of asList(changes)) {
4824
+ if (!isDict(change))
4825
+ continue;
4826
+ const targetKind = strOf(change["target_kind"]);
4827
+ const targetId = strOf(change["target_id"]);
4828
+ if (targetKind === "actor" && droppedActorIds.has(targetId))
4829
+ continue;
4830
+ if (targetKind === "prop" && droppedPropIds.has(targetId))
4831
+ continue;
4832
+ const next = { ...change };
4833
+ if (targetKind === "location" && locationReplacements.has(targetId)) {
4834
+ next["target_id"] = locationReplacements.get(targetId);
4835
+ delete next["from_state_id"];
4836
+ delete next["to_state_id"];
4837
+ }
4838
+ rewritten.push(next);
4839
+ }
4840
+ return rewritten;
4841
+ }
4842
+ function rewriteTransitionPrompt(transition, locationReplacements, droppedPropIds, droppedActorIds) {
4843
+ if (!isDict(transition))
4844
+ return transition;
4845
+ const targetKind = strOf(transition["target_kind"]);
4846
+ const targetId = strOf(transition["target_id"]);
4847
+ if (targetKind === "actor" && droppedActorIds.has(targetId))
4848
+ return null;
4849
+ if (targetKind === "prop" && droppedPropIds.has(targetId))
4850
+ return null;
4851
+ const next = { ...transition };
4852
+ if (targetKind === "location" && locationReplacements.has(targetId)) {
4853
+ next["target_id"] = locationReplacements.get(targetId);
4854
+ }
4855
+ return next;
4856
+ }
4857
+ function applyAssetCurationRefs(script, locationReplacements, droppedPropIds, droppedActorIds) {
4858
+ if (locationReplacements.size === 0 && droppedPropIds.size === 0 && droppedActorIds.size === 0)
4859
+ return;
4860
+ for (const ep of asList(script["episodes"])) {
4861
+ for (const scene of asList(ep["scenes"])) {
4862
+ const ctx = sceneContext(scene);
4863
+ if (locationReplacements.size > 0) {
4864
+ ctx["locations"] = compactAssetRefs(ctx["locations"], "location_id", locationReplacements, new Set());
4865
+ }
4866
+ if (droppedActorIds.size > 0) {
4867
+ ctx["actors"] = compactAssetRefs(ctx["actors"], "actor_id", new Map(), droppedActorIds);
4868
+ }
4869
+ if (droppedPropIds.size > 0) {
4870
+ ctx["props"] = compactAssetRefs(ctx["props"], "prop_id", new Map(), droppedPropIds);
4871
+ }
4872
+ setSceneContext(scene, ctx);
4873
+ for (const action of asList(scene["actions"])) {
4874
+ if (!isDict(action))
4875
+ continue;
4876
+ if (droppedActorIds.has(strOf(action["actor_id"])))
4877
+ delete action["actor_id"];
4878
+ if (action["state_changes"] !== undefined && action["state_changes"] !== null) {
4879
+ action["state_changes"] = rewriteStateChanges(action["state_changes"], locationReplacements, droppedPropIds, droppedActorIds);
4880
+ }
4881
+ if (action["transition_prompt"] !== undefined && action["transition_prompt"] !== null) {
4882
+ const transition = rewriteTransitionPrompt(action["transition_prompt"], locationReplacements, droppedPropIds, droppedActorIds);
4883
+ if (transition === null)
4884
+ delete action["transition_prompt"];
4885
+ else
4886
+ action["transition_prompt"] = transition;
4887
+ }
4888
+ }
4889
+ }
4890
+ }
4891
+ for (const speaker of asList(script["speakers"])) {
4892
+ if (!isDict(speaker))
4893
+ continue;
4894
+ const sourceKind = strOf(speaker["source_kind"]);
4895
+ const sourceId = strOf(speaker["source_id"]);
4896
+ if (sourceKind === "actor" && droppedActorIds.has(sourceId)) {
4897
+ speaker["source_kind"] = "other";
4898
+ speaker["source_id"] = null;
4899
+ }
4900
+ else if (sourceKind === "location" && locationReplacements.has(sourceId)) {
4901
+ speaker["source_id"] = locationReplacements.get(sourceId);
4902
+ }
4903
+ else if (sourceKind === "prop" && droppedPropIds.has(sourceId)) {
4904
+ speaker["source_kind"] = "other";
4905
+ speaker["source_id"] = null;
4906
+ }
4907
+ }
4908
+ }
4909
+ function curationBlocked(message, received) {
4910
+ throw new CliError("DIRECT CURATION BLOCKED: invalid provider plan", message, {
4911
+ exitCode: EXIT_NEEDS_AGENT,
4912
+ required: ["valid asset curation plan referencing existing ids"],
4913
+ received,
4914
+ nextSteps: ["Inspect asset_curation.json, adjust the draft manually, or rerun direct init after provider plan fixes."],
4915
+ });
4916
+ }
4917
+ function quoteCurationExpressionString(value) {
4918
+ return `"${strOf(value).replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
4919
+ }
4920
+ function unquoteCurationExpressionString(value) {
4921
+ let out = "";
4922
+ let escaped = false;
4923
+ for (const ch of value) {
4924
+ if (escaped) {
4925
+ if (ch !== "\\" && ch !== "\"") {
4926
+ curationBlocked("Unsupported escape sequence in curation expression string.", [`\\${ch}`]);
4927
+ }
4928
+ out += ch;
4929
+ escaped = false;
4930
+ }
4931
+ else if (ch === "\\") {
4932
+ escaped = true;
4933
+ }
4934
+ else {
4935
+ out += ch;
4936
+ }
4937
+ }
4938
+ if (escaped)
4939
+ curationBlocked("Unterminated escape sequence in curation expression string.", [value]);
4940
+ return out;
4941
+ }
4942
+ function splitCurationExpressionReason(expression) {
4943
+ let inQuote = false;
4944
+ let escaped = false;
4945
+ for (let i = 0; i < expression.length; i++) {
4946
+ const ch = expression[i];
4947
+ if (escaped) {
4948
+ escaped = false;
4949
+ continue;
4950
+ }
4951
+ if (ch === "\\") {
4952
+ escaped = true;
4953
+ continue;
4954
+ }
4955
+ if (ch === "\"") {
4956
+ inQuote = !inQuote;
4957
+ continue;
4958
+ }
4959
+ if (ch === "#" && !inQuote) {
4960
+ const command = expression.slice(0, i).trim();
4961
+ const reason = expression.slice(i + 1).trim();
4962
+ if (!command)
4963
+ curationBlocked("Curation expression is missing an operation before # reason.", [expression]);
4964
+ if (!reason)
4965
+ curationBlocked("Curation expression is missing reason text after #.", [expression]);
4966
+ return { command, reason };
4967
+ }
4968
+ }
4969
+ curationBlocked("Curation expression must include `# reason`.", [expression]);
4970
+ }
4971
+ function parseCurationExpression(expression) {
4972
+ if (expression.includes("\n") || expression.includes("\r")) {
4973
+ curationBlocked("Each curation expression must be a single line.", [expression]);
4974
+ }
4975
+ const { command, reason } = splitCurationExpressionReason(expression.trim());
4976
+ if (/^NOOP$/i.test(command))
4977
+ return null;
4978
+ let match = /^MERGE\s+(actor|location|prop)\s+(\S+)\s+->\s+(\S+)$/i.exec(command);
4979
+ if (match) {
4980
+ const kind = match[1].toLowerCase();
4981
+ return { kind, source_id: match[2], decision: "merge", target_kind: kind, target_id: match[3], reason };
4982
+ }
4983
+ match = /^MERGE\s+(actor|location|prop)\s+(\S+)\s+->\s+(actor|location|prop)\s+(\S+)$/i.exec(command);
4984
+ if (match) {
4985
+ const kind = match[1].toLowerCase();
4986
+ const targetKind = match[3].toLowerCase();
4987
+ if (targetKind === kind) {
4988
+ return { kind, source_id: match[2], decision: "merge", target_kind: kind, target_id: match[4], reason };
4989
+ }
4990
+ if (targetKind === "prop") {
4991
+ return {
4992
+ kind,
4993
+ source_id: match[2],
4994
+ decision: "move_to_prop",
4995
+ target_kind: "prop",
4996
+ target_id: match[4],
4997
+ new_name: "",
4998
+ reason,
4999
+ };
5000
+ }
5001
+ curationBlocked("Cross-kind MERGE is only supported when the target is an existing prop.", [expression]);
5002
+ }
5003
+ match = /^DROP\s+(actor|location|prop)\s+(\S+)$/i.exec(command);
5004
+ if (match)
5005
+ return { kind: match[1].toLowerCase(), source_id: match[2], decision: "drop", reason };
5006
+ match = /^KEEP\s+(actor|location|prop)\s+(\S+)$/i.exec(command);
5007
+ if (match)
5008
+ return { kind: match[1].toLowerCase(), source_id: match[2], decision: "keep", reason };
5009
+ match = /^RENAME\s+(actor|location|prop)\s+(\S+)\s+"((?:\\.|[^"\\])*)"$/i.exec(command);
5010
+ if (match) {
5011
+ return {
5012
+ kind: match[1].toLowerCase(),
5013
+ source_id: match[2],
5014
+ decision: "rename",
5015
+ new_name: unquoteCurationExpressionString(match[3]),
5016
+ reason,
5017
+ };
5018
+ }
5019
+ match = /^STATE\s+(actor|location|prop)\s+(\S+)\s+->\s+(actor|location|prop)\s+(\S+)\s+"((?:\\.|[^"\\])*)"$/i.exec(command);
5020
+ if (match) {
5021
+ return {
5022
+ kind: match[1].toLowerCase(),
5023
+ source_id: match[2],
5024
+ decision: "move_to_state",
5025
+ target_kind: match[3].toLowerCase(),
5026
+ target_id: match[4],
5027
+ state_name: unquoteCurationExpressionString(match[5]),
5028
+ reason,
5029
+ };
5030
+ }
5031
+ match = /^SPEAKER\s+(actor|location|prop)\s+(\S+)\s+->\s+(system|broadcast|group|other)\s+"((?:\\.|[^"\\])*)"$/i.exec(command);
5032
+ if (match) {
5033
+ return {
5034
+ kind: match[1].toLowerCase(),
5035
+ source_id: match[2],
5036
+ decision: "move_to_speaker",
5037
+ speaker_kind: match[3].toLowerCase(),
5038
+ new_name: unquoteCurationExpressionString(match[4]),
5039
+ reason,
5040
+ };
5041
+ }
5042
+ match = /^SPEAKER\s+(actor|location|prop)\s+(\S+)\s+(system|broadcast|group|other)\s+"((?:\\.|[^"\\])*)"$/i.exec(command);
5043
+ if (match) {
5044
+ return {
5045
+ kind: match[1].toLowerCase(),
5046
+ source_id: match[2],
5047
+ decision: "move_to_speaker",
5048
+ speaker_kind: match[3].toLowerCase(),
5049
+ new_name: unquoteCurationExpressionString(match[4]),
5050
+ reason,
5051
+ };
5052
+ }
5053
+ match = /^PROP\s+(actor|location|prop)\s+(\S+)\s+->\s+(\S+)\s+"((?:\\.|[^"\\])*)"$/i.exec(command);
5054
+ if (match) {
5055
+ const target = match[3];
5056
+ const normalizedTarget = target.toLowerCase();
5057
+ const targetPrefix = /^([A-Za-z]+):(.+)$/.exec(target);
5058
+ if ((targetPrefix && targetPrefix[1].toLowerCase() !== "prop") ||
5059
+ normalizedTarget === "actor" ||
5060
+ normalizedTarget === "location" ||
5061
+ normalizedTarget === "prop") {
5062
+ curationBlocked("PROP target must be NEW or an existing prop id.", [expression]);
5063
+ }
5064
+ const decision = {
5065
+ kind: match[1].toLowerCase(),
5066
+ source_id: match[2],
5067
+ decision: "move_to_prop",
5068
+ target_kind: "prop",
5069
+ new_name: unquoteCurationExpressionString(match[4]),
5070
+ reason,
5071
+ };
5072
+ if (target.toUpperCase() !== "NEW")
5073
+ decision["target_id"] = targetPrefix ? targetPrefix[2] : target;
5074
+ return decision;
5075
+ }
5076
+ curationBlocked("Unknown or malformed curation expression.", [expression]);
5077
+ }
5078
+ export function extractAssetCurationExpressions(rawText) {
5079
+ const expressions = [];
5080
+ const re = /<exp>([\s\S]*?)<\/exp>/g;
5081
+ let outside = "";
5082
+ let lastIndex = 0;
5083
+ let match;
5084
+ while ((match = re.exec(rawText)) !== null) {
5085
+ outside += rawText.slice(lastIndex, match.index);
5086
+ expressions.push(strOf(match[1]).trim());
5087
+ lastIndex = match.index + match[0].length;
5088
+ }
5089
+ outside += rawText.slice(lastIndex);
5090
+ if (outside.trim()) {
5091
+ curationBlocked("Asset curation response must contain only <exp>...</exp> lines.", [outside.trim().slice(0, 200)]);
5092
+ }
5093
+ if (expressions.length === 0) {
5094
+ curationBlocked("Asset curation response did not contain any <exp> expressions.", [rawText.slice(0, 200)]);
5095
+ }
5096
+ return expressions;
5097
+ }
5098
+ function curationErrorText(error) {
5099
+ const e = error;
5100
+ return `${e?.name ?? "Error"}: ${e?.message ?? ""}`.trim();
5101
+ }
5102
+ export function guessAssetCurationExpressionSourceKey(rawExpression) {
5103
+ const match = /^\s*(?:KEEP|MERGE|DROP|RENAME|STATE|SPEAKER|PROP)\s+(actor|location|prop)\s+(\S+)/i.exec(rawExpression);
5104
+ if (!match)
5105
+ return null;
5106
+ const kind = match[1].toLowerCase();
5107
+ const sourceId = strOf(match[2]).trim();
5108
+ return sourceId ? curationSourceKey(kind, sourceId) : null;
5109
+ }
5110
+ function extractAssetCurationExpressionsTolerant(rawText) {
5111
+ const expressions = [];
5112
+ const rejected = [];
5113
+ const re = /<exp>([\s\S]*?)<\/exp>/g;
5114
+ let outside = "";
5115
+ let lastIndex = 0;
5116
+ let match;
5117
+ while ((match = re.exec(rawText)) !== null) {
5118
+ outside += rawText.slice(lastIndex, match.index);
5119
+ expressions.push({ index: expressions.length, raw: strOf(match[1]).trim() });
5120
+ lastIndex = match.index + match[0].length;
5121
+ }
5122
+ outside += rawText.slice(lastIndex);
5123
+ if (outside.trim()) {
5124
+ rejected.push({
5125
+ index: -1,
5126
+ raw: outside.trim().slice(0, 2000),
5127
+ error: "Asset curation response must contain only <exp>...</exp> lines.",
5128
+ source_key: guessAssetCurationExpressionSourceKey(outside),
5129
+ });
5130
+ }
5131
+ if (expressions.length === 0) {
5132
+ rejected.push({
5133
+ index: -1,
5134
+ raw: rawText.slice(0, 2000),
5135
+ error: "Asset curation response did not contain any <exp> expressions.",
5136
+ source_key: guessAssetCurationExpressionSourceKey(rawText),
5137
+ });
5138
+ }
5139
+ return { expressions, rejected };
5140
+ }
5141
+ export function parseAssetCurationExpressionsTolerant(rawText) {
5142
+ const extracted = extractAssetCurationExpressionsTolerant(rawText);
5143
+ const decisions = [];
5144
+ const acceptedExpressions = [];
5145
+ const rejected = [...extracted.rejected];
5146
+ const noopIndexes = [];
5147
+ for (const expression of extracted.expressions) {
5148
+ try {
5149
+ const decision = parseCurationExpression(expression.raw);
5150
+ if (decision === null) {
5151
+ noopIndexes.push(expression.index);
5152
+ }
5153
+ else {
5154
+ decisions.push(decision);
5155
+ acceptedExpressions.push(expression.raw);
5156
+ }
5157
+ }
5158
+ catch (error) {
5159
+ rejected.push({
5160
+ index: expression.index,
5161
+ raw: expression.raw,
5162
+ error: curationErrorText(error),
5163
+ source_key: guessAssetCurationExpressionSourceKey(expression.raw),
5164
+ });
5165
+ }
5166
+ }
5167
+ if (noopIndexes.length > 0 && decisions.length > 0) {
5168
+ for (const index of noopIndexes) {
5169
+ const expression = extracted.expressions.find((item) => item.index === index);
5170
+ rejected.push({
5171
+ index,
5172
+ raw: expression?.raw ?? "NOOP",
5173
+ error: "NOOP cannot be combined with actionable curation expressions.",
5174
+ source_key: null,
5175
+ });
5176
+ }
5177
+ }
5178
+ else if (noopIndexes.length > 1) {
5179
+ for (const index of noopIndexes.slice(1)) {
5180
+ const expression = extracted.expressions.find((item) => item.index === index);
5181
+ rejected.push({
5182
+ index,
5183
+ raw: expression?.raw ?? "NOOP",
5184
+ error: "Curation response can contain at most one NOOP expression.",
5185
+ source_key: null,
5186
+ });
5187
+ }
5188
+ acceptedExpressions.push("NOOP # no asset curation changes");
5189
+ }
5190
+ else if (noopIndexes.length === 1) {
5191
+ const expression = extracted.expressions.find((item) => item.index === noopIndexes[0]);
5192
+ acceptedExpressions.push(expression?.raw ?? "NOOP # no asset curation changes");
5193
+ }
5194
+ return {
5195
+ version: 3,
5196
+ format: "asset-curation-exp-v1",
5197
+ expression_text: rawText,
5198
+ expressions: acceptedExpressions.length > 0 ? acceptedExpressions : extracted.expressions.map((item) => item.raw),
5199
+ accepted_expressions: acceptedExpressions,
5200
+ rejected_expressions: rejected,
5201
+ decisions,
5202
+ partial: rejected.length > 0,
5203
+ };
5204
+ }
5205
+ export function parseAssetCurationExpressions(rawText) {
5206
+ const parsed = parseAssetCurationExpressionsTolerant(rawText);
5207
+ const rejected = asList(parsed["rejected_expressions"]).filter(isDict);
5208
+ if (rejected.length > 0) {
5209
+ const first = rejected[0];
5210
+ curationBlocked(strOf(first["error"]) || "Curation expression parse failed.", [strOf(first["raw"]).slice(0, 200)]);
5211
+ }
5212
+ const expressions = asList(parsed["accepted_expressions"]);
5213
+ const decisions = asList(parsed["decisions"]).filter(isDict);
5214
+ return {
5215
+ version: 3,
5216
+ format: "asset-curation-exp-v1",
5217
+ expression_text: rawText,
5218
+ expressions,
5219
+ decisions,
5220
+ };
5221
+ }
5222
+ function renderAssetCurationDecisionExpression(decision) {
5223
+ const kind = strOf(decision["kind"]);
5224
+ const sourceId = strOf(decision["source_id"]);
5225
+ const action = strOf(decision["decision"]);
5226
+ const reason = strOf(decision["reason"]).trim() || "curation decision";
5227
+ if (action === "merge")
5228
+ return `MERGE ${kind} ${sourceId} -> ${strOf(decision["target_id"])} # ${reason}`;
5229
+ if (action === "drop")
5230
+ return `DROP ${kind} ${sourceId} # ${reason}`;
5231
+ if (action === "keep")
5232
+ return `KEEP ${kind} ${sourceId} # ${reason}`;
5233
+ if (action === "rename")
5234
+ return `RENAME ${kind} ${sourceId} ${quoteCurationExpressionString(decision["new_name"])} # ${reason}`;
5235
+ if (action === "move_to_state") {
5236
+ return `STATE ${kind} ${sourceId} -> ${strOf(decision["target_kind"])} ${strOf(decision["target_id"])} ${quoteCurationExpressionString(decision["state_name"])} # ${reason}`;
5237
+ }
5238
+ if (action === "move_to_speaker") {
5239
+ return `SPEAKER ${kind} ${sourceId} -> ${strOf(decision["speaker_kind"])} ${quoteCurationExpressionString(decision["new_name"])} # ${reason}`;
5240
+ }
5241
+ if (action === "move_to_prop") {
5242
+ const target = strOf(decision["target_id"]).trim() || "NEW";
5243
+ return `PROP ${kind} ${sourceId} -> ${target} ${quoteCurationExpressionString(decision["new_name"])} # ${reason}`;
5244
+ }
5245
+ curationBlocked("Cannot render unknown curation decision.", [`decision: ${action || "<empty>"}`]);
5246
+ }
5247
+ export function formatAssetCurationDecisionExpressions(plan) {
5248
+ const decisions = asList(plan["decisions"]).filter(isDict);
5249
+ if (decisions.length === 0)
5250
+ return "<exp>NOOP # no asset curation changes</exp>";
5251
+ return `${decisions.map((decision) => `<exp>${renderAssetCurationDecisionExpression(decision)}</exp>`).join("\n")}\n`;
5252
+ }
5253
+ export function combineAssetCurationExpressionPlans(plans) {
5254
+ const decisions = plans.flatMap((plan) => asList(plan["decisions"]).filter(isDict));
5255
+ const rawExpressions = plans.flatMap((plan) => asList(plan["expressions"]));
5256
+ const actionableExpressions = rawExpressions.filter((expression) => !/^NOOP(?:\s|#|$)/.test(expression.trim()));
5257
+ const expressions = actionableExpressions.length > 0 ? actionableExpressions : ["NOOP # no asset curation changes"];
5258
+ return {
5259
+ version: 3,
5260
+ format: "asset-curation-exp-v1",
5261
+ expression_text: formatAssetCurationDecisionExpressions({ decisions }),
5262
+ expressions,
5263
+ decisions,
5264
+ };
5265
+ }
5266
+ export function combinePrimaryAssetCurationPlans(plans) {
5267
+ const combined = combineAssetCurationExpressionPlans(plans);
5268
+ const expressionTextByPlan = plans
5269
+ .map((plan) => strOf(plan["expression_text"]).trim())
5270
+ .filter((text) => text);
5271
+ return {
5272
+ ...combined,
5273
+ primary_parts: plans.map((plan) => ({
5274
+ expression_text: strOf(plan["expression_text"]),
5275
+ expressions: asList(plan["expressions"]),
5276
+ decision_count: asList(plan["decisions"]).filter(isDict).length,
5277
+ })),
5278
+ primary_part_expression_text: expressionTextByPlan.join("\n"),
5279
+ };
5280
+ }
5281
+ export function combineAssetCurationPlans(primaryPlan, auditPlan, allowedNewAuditSources = []) {
5282
+ const primaryDecisions = asList(primaryPlan["decisions"]).filter(isDict);
5283
+ const auditDecisions = asList(auditPlan["decisions"]).filter(isDict);
5284
+ const primaryKeys = curationDecisionSourceSet(primaryDecisions);
5285
+ const allowedNewKeys = new Set(allowedNewAuditSources);
5286
+ const invalidAuditSources = [];
5287
+ let addedAuditDecisions = 0;
5288
+ for (const decision of auditDecisions) {
5289
+ const key = curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]));
5290
+ if (primaryKeys.has(key))
5291
+ continue;
5292
+ if (allowedNewKeys.has(key)) {
5293
+ addedAuditDecisions += 1;
5294
+ continue;
5295
+ }
5296
+ invalidAuditSources.push(key);
5297
+ }
5298
+ if (invalidAuditSources.length > 0) {
5299
+ curationBlocked("Audit curation expressions may only override primary decision sources or listed missing required sources.", invalidAuditSources);
5300
+ }
5301
+ const primaryExpressions = asList(primaryPlan["expressions"]);
5302
+ const auditExpressions = asList(auditPlan["expressions"]);
5303
+ return {
5304
+ version: 3,
5305
+ format: "asset-curation-exp-v1",
5306
+ primary_expression_text: strOf(primaryPlan["expression_text"]),
5307
+ audit_expression_text: strOf(auditPlan["expression_text"]),
5308
+ expression_text: [strOf(primaryPlan["expression_text"]).trim(), strOf(auditPlan["expression_text"]).trim()].filter((text) => text).join("\n"),
5309
+ primary_expressions: primaryExpressions,
5310
+ audit_expressions: auditExpressions,
5311
+ expressions: [...primaryExpressions, ...auditExpressions],
5312
+ decisions: [...primaryDecisions, ...auditDecisions],
5313
+ audit_summary: {
5314
+ primary_decisions: primaryDecisions.length,
5315
+ audit_decisions: auditDecisions.length,
5316
+ audit_added_decisions: addedAuditDecisions,
5317
+ audit_noop: auditDecisions.length === 0,
5318
+ },
5319
+ };
5320
+ }
5321
+ function normalizeCurationDecision(item) {
5322
+ const kind = strOf(item["kind"]);
5323
+ const sourceId = strOf(item["source_id"]);
5324
+ const decision = strOf(item["decision"]);
5325
+ const reason = strOf(item["reason"]).trim();
5326
+ if (kind !== "actor" && kind !== "location" && kind !== "prop")
5327
+ curationBlocked("Unknown curation kind.", [`kind: ${kind || "<empty>"}`]);
5328
+ if (!sourceId)
5329
+ curationBlocked("Curation decision is missing source_id.", [`decision: ${decision || "<empty>"}`]);
5330
+ const validDecisions = new Set(["keep", "merge", "drop", "move_to_speaker", "move_to_prop", "move_to_state", "rename"]);
5331
+ if (!validDecisions.has(decision))
5332
+ curationBlocked("Unknown curation decision.", [`decision: ${decision || "<empty>"}`]);
5333
+ if (!reason)
5334
+ curationBlocked("Curation decision is missing reason.", [`${kind}:${sourceId}`]);
5335
+ return {
5336
+ kind,
5337
+ source_id: sourceId,
5338
+ decision,
5339
+ target_id: strOf(item["target_id"]).trim(),
5340
+ target_kind: strOf(item["target_kind"]).trim(),
5341
+ new_name: cleanName(item["new_name"]),
5342
+ speaker_kind: strOf(item["speaker_kind"]).trim(),
5343
+ state_name: strOf(item["state_name"]).trim(),
5344
+ reason,
5345
+ };
5346
+ }
5347
+ function assertAssetExists(script, kind, id) {
5348
+ const asset = assetMapById(script, kind).get(id);
5349
+ if (!asset)
5350
+ curationBlocked("Curation decision references an unknown asset.", [`${kind}:${id}`]);
5351
+ return asset;
5352
+ }
5353
+ function assertMergeAcyclic(decisions) {
5354
+ const edges = new Map();
5355
+ for (const decision of decisions) {
5356
+ if (decision["decision"] !== "merge")
5357
+ continue;
5358
+ const kind = strOf(decision["kind"]);
5359
+ const sourceId = strOf(decision["source_id"]);
5360
+ const targetId = strOf(decision["target_id"]);
5361
+ if (sourceId && targetId)
5362
+ edges.set(`${kind}:${sourceId}`, `${kind}:${targetId}`);
5363
+ }
5364
+ for (const start of edges.keys()) {
5365
+ const seen = new Set();
5366
+ let cursor = start;
5367
+ while (edges.has(cursor)) {
5368
+ if (seen.has(cursor))
5369
+ curationBlocked("Curation merge plan contains a cycle.", [`cycle at ${cursor}`]);
5370
+ seen.add(cursor);
5371
+ cursor = edges.get(cursor);
5372
+ }
5373
+ }
5374
+ }
5375
+ function isAssetKindValue(value) {
5376
+ return value === "actor" || value === "location" || value === "prop";
5377
+ }
5378
+ function curationSourceKey(kind, sourceId) {
5379
+ return `${kind}:${sourceId}`;
5380
+ }
5381
+ function buildCurationDecisionIndex(decisions) {
5382
+ const decisionBySource = new Map();
5383
+ const bySource = new Map();
5384
+ for (const decision of decisions) {
5385
+ const key = curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]));
5386
+ if (decisionBySource.has(key))
5387
+ curationBlocked("Curation plan has duplicate decisions for one source asset.", [key]);
5388
+ decisionBySource.set(key, strOf(decision["decision"]));
5389
+ bySource.set(key, decision);
5390
+ }
5391
+ return bySource;
5392
+ }
5393
+ function dedupeCurationDecisions(decisions) {
5394
+ const lastIndexBySource = new Map();
5395
+ const duplicateCounts = new Map();
5396
+ for (let index = 0; index < decisions.length; index++) {
5397
+ const decision = decisions[index];
5398
+ const key = curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]));
5399
+ if (lastIndexBySource.has(key))
5400
+ duplicateCounts.set(key, (duplicateCounts.get(key) ?? 1) + 1);
5401
+ lastIndexBySource.set(key, index);
5402
+ }
5403
+ const out = [];
5404
+ for (let index = 0; index < decisions.length; index++) {
5405
+ const decision = decisions[index];
5406
+ const key = curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]));
5407
+ if (lastIndexBySource.get(key) !== index)
5408
+ continue;
5409
+ if (duplicateCounts.has(key)) {
5410
+ appendCurationNormalizationReason(decision, "deterministic duplicate normalization kept the last decision for this source asset.");
5411
+ }
5412
+ out.push(decision);
5413
+ }
5414
+ return out;
5415
+ }
5416
+ function mergeTargetKind(decision, fallbackKind) {
5417
+ const raw = strOf(decision["target_kind"]) || fallbackKind;
5418
+ return isAssetKindValue(raw) ? raw : null;
5419
+ }
5420
+ function chooseCanonicalMergeTarget(script, kind, ids) {
5421
+ const usage = sceneAssetUsage(script, assetListKey(kind), assetIdKey(kind));
5422
+ const byId = assetMapById(script, kind);
5423
+ const sorted = [...ids].sort((left, right) => {
5424
+ const leftUsage = usage.get(left) ?? { episodes: new Set(), scenes: new Set() };
5425
+ const rightUsage = usage.get(right) ?? { episodes: new Set(), scenes: new Set() };
5426
+ const sceneDelta = rightUsage.scenes.size - leftUsage.scenes.size;
5427
+ if (sceneDelta !== 0)
5428
+ return sceneDelta;
5429
+ const episodeDelta = rightUsage.episodes.size - leftUsage.episodes.size;
5430
+ if (episodeDelta !== 0)
5431
+ return episodeDelta;
5432
+ const leftName = strOf(byId.get(left)?.[assetNameKey(kind)]).trim();
5433
+ const rightName = strOf(byId.get(right)?.[assetNameKey(kind)]).trim();
5434
+ const lengthDelta = leftName.length - rightName.length;
5435
+ if (lengthDelta !== 0)
5436
+ return lengthDelta;
5437
+ return left.localeCompare(right);
5438
+ });
5439
+ return sorted[0] ?? ids[0] ?? "";
5440
+ }
5441
+ function normalizeMergeCycles(script, decisions) {
5442
+ const decisionsBySource = buildCurationDecisionIndex(decisions);
5443
+ let changed = true;
5444
+ while (changed) {
5445
+ changed = false;
5446
+ for (const decision of decisions) {
5447
+ if (decision["decision"] !== "merge")
5448
+ continue;
5449
+ const kindRaw = strOf(decision["kind"]);
5450
+ if (!isAssetKindValue(kindRaw))
5451
+ continue;
5452
+ const path = [];
5453
+ const seenIndex = new Map();
5454
+ let cursor = curationSourceKey(kindRaw, strOf(decision["source_id"]));
5455
+ while (cursor) {
5456
+ if (seenIndex.has(cursor)) {
5457
+ const cycleKeys = path.slice(seenIndex.get(cursor));
5458
+ const cycleIds = cycleKeys.map((key) => key.split(":")[1] ?? "").filter((id) => id);
5459
+ const canonicalId = chooseCanonicalMergeTarget(script, kindRaw, cycleIds);
5460
+ for (const key of cycleKeys) {
5461
+ const cycleDecision = decisionsBySource.get(key);
5462
+ if (!cycleDecision)
5463
+ continue;
5464
+ const sourceId = strOf(cycleDecision["source_id"]);
5465
+ if (sourceId === canonicalId) {
5466
+ cycleDecision["decision"] = "keep";
5467
+ cycleDecision["target_id"] = "";
5468
+ cycleDecision["target_kind"] = "";
5469
+ cycleDecision["reason"] = `${strOf(cycleDecision["reason"])};deterministic cycle normalization kept the canonical target.`;
5470
+ ensureNormalizedPropKeepCategory(cycleDecision, "deterministic merge-cycle canonical prop.");
5471
+ }
5472
+ else {
5473
+ cycleDecision["target_kind"] = kindRaw;
5474
+ cycleDecision["target_id"] = canonicalId;
5475
+ cycleDecision["reason"] = `${strOf(cycleDecision["reason"])};deterministic cycle normalization selected ${kindRaw}:${canonicalId}.`;
5476
+ }
5477
+ }
5478
+ changed = true;
5479
+ break;
5480
+ }
5481
+ seenIndex.set(cursor, path.length);
5482
+ path.push(cursor);
5483
+ const current = decisionsBySource.get(cursor);
5484
+ if (!current || current["decision"] !== "merge")
5485
+ break;
5486
+ const targetKind = mergeTargetKind(current, kindRaw);
5487
+ if (targetKind !== kindRaw)
5488
+ break;
5489
+ const targetId = strOf(current["target_id"]);
5490
+ if (!targetId)
5491
+ break;
5492
+ cursor = curationSourceKey(kindRaw, targetId);
5493
+ }
5494
+ if (changed)
5495
+ break;
5496
+ }
5497
+ }
5498
+ }
5499
+ function resolveStableCurationTarget(decisionsBySource, targetKind, targetId, sourceLabel) {
5500
+ let cursor = targetId;
5501
+ const seen = new Set();
5502
+ while (cursor) {
5503
+ const key = curationSourceKey(targetKind, cursor);
5504
+ if (seen.has(key))
5505
+ curationBlocked("Curation merge plan contains a cycle.", [`cycle at ${key}`]);
5506
+ seen.add(key);
5507
+ const targetDecision = decisionsBySource.get(key);
5508
+ if (!targetDecision)
5509
+ return cursor;
5510
+ const action = strOf(targetDecision["decision"]);
5511
+ if (action === "keep" || action === "rename")
5512
+ return cursor;
5513
+ if (isSelfStateDecision(targetDecision))
5514
+ return cursor;
5515
+ if (action !== "merge") {
5516
+ curationBlocked("Curation target is not stable.", [`${sourceLabel} -> ${targetKind}:${targetId} (${action})`]);
5517
+ }
5518
+ const nextKind = strOf(targetDecision["target_kind"]) || targetKind;
5519
+ if (nextKind !== targetKind) {
5520
+ curationBlocked("merge decision target_kind must match source kind.", [`${targetKind}:${cursor} -> ${nextKind}:${targetDecision["target_id"]}`]);
5521
+ }
5522
+ const nextId = strOf(targetDecision["target_id"]);
5523
+ if (!nextId || nextId === cursor) {
5524
+ curationBlocked("merge decision requires a different target_id.", [`${targetKind}:${cursor}`]);
5525
+ }
5526
+ cursor = nextId;
5527
+ }
5528
+ return targetId;
5529
+ }
5530
+ function isSelfStateDecision(decision) {
5531
+ if (strOf(decision["decision"]) !== "move_to_state")
5532
+ return false;
5533
+ const kind = strOf(decision["kind"]);
5534
+ const sourceId = strOf(decision["source_id"]);
5535
+ const targetKind = strOf(decision["target_kind"]);
5536
+ const targetId = strOf(decision["target_id"]);
5537
+ return kind === targetKind && sourceId === targetId && isAssetKindValue(kind) && sourceId.length > 0;
5538
+ }
5539
+ function resolveStableCurationStateTarget(decisionsBySource, targetKind, targetId, sourceLabel) {
5540
+ let cursorKind = targetKind;
5541
+ let cursorId = targetId;
5542
+ const seen = new Set();
5543
+ while (cursorId) {
5544
+ const key = curationSourceKey(cursorKind, cursorId);
5545
+ if (seen.has(key))
5546
+ curationBlocked("Curation state target plan contains a cycle.", [`cycle at ${key}`]);
5547
+ seen.add(key);
5548
+ const targetDecision = decisionsBySource.get(key);
5549
+ if (!targetDecision)
5550
+ return { kind: cursorKind, id: cursorId };
5551
+ const action = strOf(targetDecision["decision"]);
5552
+ if (action === "keep" || action === "rename")
5553
+ return { kind: cursorKind, id: cursorId };
5554
+ if (isSelfStateDecision(targetDecision))
5555
+ return { kind: cursorKind, id: cursorId };
5556
+ if (action === "merge") {
5557
+ const nextKind = strOf(targetDecision["target_kind"]) || cursorKind;
5558
+ if (nextKind !== cursorKind) {
5559
+ curationBlocked("merge decision target_kind must match source kind.", [`${cursorKind}:${cursorId} -> ${nextKind}:${targetDecision["target_id"]}`]);
5560
+ }
5561
+ const nextId = strOf(targetDecision["target_id"]);
5562
+ if (!nextId || nextId === cursorId) {
5563
+ curationBlocked("merge decision requires a different target_id.", [`${cursorKind}:${cursorId}`]);
5564
+ }
5565
+ cursorId = nextId;
5566
+ continue;
5567
+ }
5568
+ if (action === "move_to_state") {
5569
+ const nextKind = strOf(targetDecision["target_kind"]);
5570
+ const nextId = strOf(targetDecision["target_id"]);
5571
+ if (!isAssetKindValue(nextKind) || !nextId) {
5572
+ curationBlocked("move_to_state requires target_kind and target_id.", [`${sourceLabel} -> ${nextKind}:${nextId}`]);
5573
+ }
5574
+ if (nextKind === cursorKind && nextId === cursorId) {
5575
+ curationBlocked("move_to_state requires a different target asset.", [`${cursorKind}:${cursorId}`]);
5576
+ }
5577
+ cursorKind = nextKind;
5578
+ cursorId = nextId;
5579
+ continue;
5580
+ }
5581
+ curationBlocked("Curation state target is not stable.", [`${sourceLabel} -> ${cursorKind}:${targetId} (${action})`]);
5582
+ }
5583
+ return { kind: targetKind, id: targetId };
5584
+ }
5585
+ function appendCurationNormalizationReason(decision, detail) {
5586
+ const reason = strOf(decision["reason"]).trim();
5587
+ decision["reason"] = reason ? `${reason};${detail}` : detail;
5588
+ }
5589
+ function ensureNormalizedPropKeepCategory(decision, detail) {
5590
+ if (strOf(decision["kind"]) !== "prop" || strOf(decision["decision"]) !== "keep")
5591
+ return;
5592
+ if (propKeepCategory(decision["reason"]))
5593
+ return;
5594
+ const reason = strOf(decision["reason"]).trim();
5595
+ decision["reason"] = `category=signature_instance; ${detail}${reason ? ` (${reason})` : ""}`;
5596
+ }
5597
+ function clearCurationTargetFields(decision) {
5598
+ decision["target_id"] = "";
5599
+ decision["target_kind"] = "";
5600
+ }
5601
+ function normalizeMergeToDownstreamAction(decisionsBySource, decision, targetDecision, sourceLabel) {
5602
+ const downstreamAction = strOf(targetDecision["decision"]);
5603
+ if (downstreamAction === "drop") {
5604
+ decision["decision"] = "drop";
5605
+ clearCurationTargetFields(decision);
5606
+ appendCurationNormalizationReason(decision, "deterministic target normalization: merge target is dropped, so source is dropped too.");
5607
+ return true;
5608
+ }
5609
+ if (downstreamAction === "move_to_speaker") {
5610
+ decision["decision"] = "move_to_speaker";
5611
+ clearCurationTargetFields(decision);
5612
+ decision["speaker_kind"] = targetDecision["speaker_kind"];
5613
+ decision["new_name"] = targetDecision["new_name"];
5614
+ appendCurationNormalizationReason(decision, "deterministic target normalization: merge target moves to speaker, so source follows.");
5615
+ return true;
5616
+ }
5617
+ if (downstreamAction === "move_to_prop") {
5618
+ const targetKind = strOf(targetDecision["target_kind"]) || "prop";
5619
+ if (targetKind !== "prop") {
5620
+ curationBlocked("move_to_prop target_kind must be prop.", [`${sourceLabel} -> ${targetKind}:${targetDecision["target_id"]}`]);
5621
+ }
5622
+ const targetId = strOf(targetDecision["target_id"]);
5623
+ decision["decision"] = "move_to_prop";
5624
+ decision["target_kind"] = "prop";
5625
+ decision["target_id"] = targetId
5626
+ ? resolveStableCurationTarget(decisionsBySource, "prop", targetId, sourceLabel)
5627
+ : "";
5628
+ decision["new_name"] = targetDecision["new_name"];
5629
+ appendCurationNormalizationReason(decision, "deterministic target normalization: merge target moves to prop, so source follows.");
5630
+ return true;
5631
+ }
5632
+ if (downstreamAction === "move_to_state") {
5633
+ const targetKind = strOf(targetDecision["target_kind"]);
5634
+ const targetId = strOf(targetDecision["target_id"]);
5635
+ if (!isAssetKindValue(targetKind) || !targetId) {
5636
+ curationBlocked("move_to_state requires target_kind and target_id.", [`${sourceLabel} -> ${targetKind}:${targetId}`]);
5637
+ }
5638
+ decision["decision"] = "move_to_state";
5639
+ decision["target_kind"] = targetKind;
5640
+ decision["target_id"] = resolveStableCurationTarget(decisionsBySource, targetKind, targetId, sourceLabel);
5641
+ decision["state_name"] = targetDecision["state_name"];
5642
+ decision["new_name"] = targetDecision["new_name"];
5643
+ appendCurationNormalizationReason(decision, "deterministic target normalization: merge target moves to state, so source follows.");
5644
+ return true;
5645
+ }
5646
+ return false;
5647
+ }
5648
+ function findMergeDownstreamAction(decisionsBySource, targetKind, targetId) {
5649
+ let cursor = targetId;
5650
+ const seen = new Set();
5651
+ while (cursor) {
5652
+ const key = curationSourceKey(targetKind, cursor);
5653
+ if (seen.has(key))
5654
+ curationBlocked("Curation merge plan contains a cycle.", [`cycle at ${key}`]);
5655
+ seen.add(key);
5656
+ const targetDecision = decisionsBySource.get(key);
5657
+ if (!targetDecision)
5658
+ return null;
5659
+ const targetAction = strOf(targetDecision["decision"]);
5660
+ if (targetAction === "keep" || targetAction === "rename")
5661
+ return null;
5662
+ if (isSelfStateDecision(targetDecision))
5663
+ return null;
5664
+ if (targetAction !== "merge")
5665
+ return targetDecision;
5666
+ const nextKind = strOf(targetDecision["target_kind"]) || targetKind;
5667
+ if (nextKind !== targetKind) {
5668
+ curationBlocked("merge decision target_kind must match source kind.", [`${targetKind}:${cursor} -> ${nextKind}:${targetDecision["target_id"]}`]);
5669
+ }
5670
+ const nextId = strOf(targetDecision["target_id"]);
5671
+ if (!nextId || nextId === cursor) {
5672
+ curationBlocked("merge decision requires a different target_id.", [`${targetKind}:${cursor}`]);
5673
+ }
5674
+ cursor = nextId;
5675
+ }
5676
+ return null;
5677
+ }
5678
+ function normalizeCurationPlanTargets(script, decisions) {
5679
+ normalizeMergeCycles(script, decisions);
5680
+ const decisionsBySource = buildCurationDecisionIndex(decisions);
5681
+ for (const decision of decisions) {
5682
+ const action = strOf(decision["decision"]);
5683
+ if (action !== "merge" && action !== "move_to_state" && action !== "move_to_prop")
5684
+ continue;
5685
+ const targetId = strOf(decision["target_id"]);
5686
+ if (!targetId)
5687
+ continue;
5688
+ const targetKindRaw = strOf(decision["target_kind"]) || (action === "move_to_prop" ? "prop" : strOf(decision["kind"]));
5689
+ if (!isAssetKindValue(targetKindRaw))
5690
+ continue;
5691
+ const sourceLabel = curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]));
5692
+ if (action === "merge") {
5693
+ const downstreamAction = findMergeDownstreamAction(decisionsBySource, targetKindRaw, targetId);
5694
+ if (downstreamAction && normalizeMergeToDownstreamAction(decisionsBySource, decision, downstreamAction, sourceLabel)) {
5695
+ continue;
5696
+ }
5697
+ }
5698
+ if (action === "move_to_state") {
5699
+ const resolved = resolveStableCurationStateTarget(decisionsBySource, targetKindRaw, targetId, sourceLabel);
5700
+ if (resolved.kind !== targetKindRaw || resolved.id !== targetId) {
5701
+ appendCurationNormalizationReason(decision, `deterministic state target normalization: target resolved to ${resolved.kind}:${resolved.id}.`);
5702
+ }
5703
+ decision["target_kind"] = resolved.kind;
5704
+ decision["target_id"] = resolved.id;
5705
+ continue;
5706
+ }
5707
+ const resolvedTargetId = resolveStableCurationTarget(decisionsBySource, targetKindRaw, targetId, sourceLabel);
5708
+ decision["target_kind"] = targetKindRaw;
5709
+ decision["target_id"] = resolvedTargetId;
5710
+ }
5711
+ }
5712
+ function rewriteSpeakerRefs(value, speakerReplacements) {
5713
+ if (!isList(value) || speakerReplacements.size === 0)
5714
+ return value;
5715
+ const out = [];
5716
+ const seen = new Set();
5717
+ for (const item of asList(value)) {
5718
+ if (!isDict(item))
5719
+ continue;
5720
+ const raw = strOf(item["speaker_id"]);
5721
+ const speakerId = speakerReplacements.get(raw) ?? raw;
5722
+ if (!speakerId || seen.has(speakerId))
5723
+ continue;
5724
+ seen.add(speakerId);
5725
+ out.push({ ...item, speaker_id: speakerId });
5726
+ }
5727
+ return out;
5728
+ }
5729
+ function compactRefsWithStateMap(refs, idKey, replacements, droppedIds, stateReplacements) {
5730
+ const compacted = [];
5731
+ const seen = new Set();
5732
+ for (const ref of asList(refs)) {
5733
+ if (!isDict(ref))
5734
+ continue;
5735
+ const rawId = strOf(ref[idKey]);
5736
+ const hasReplacement = replacements.has(rawId);
5737
+ if (!rawId || (!hasReplacement && droppedIds.has(rawId)))
5738
+ continue;
5739
+ const nextId = replacements.get(rawId) ?? rawId;
5740
+ const rawState = strOf(ref["state_id"]);
5741
+ const stateId = stateReplacements.get(rawState) ?? (nextId !== rawId ? "" : rawState);
5742
+ const key = `${nextId}::${stateId}`;
5743
+ if (seen.has(key))
5744
+ continue;
5745
+ seen.add(key);
5746
+ compacted.push({ [idKey]: nextId, state_id: stateId || null });
5747
+ }
5748
+ return compacted;
5749
+ }
5750
+ function rewriteCurationStateChanges(changes, replacementsByKind, droppedByKind, stateReplacements) {
5751
+ const rewritten = [];
5752
+ for (const change of asList(changes)) {
5753
+ if (!isDict(change))
5754
+ continue;
5755
+ const targetKind = strOf(change["target_kind"]);
5756
+ if (targetKind !== "actor" && targetKind !== "location" && targetKind !== "prop")
5757
+ continue;
5758
+ const targetId = strOf(change["target_id"]);
5759
+ const nextTargetId = replacementsByKind[targetKind].get(targetId) ?? targetId;
5760
+ if (nextTargetId === targetId && droppedByKind[targetKind].has(targetId))
5761
+ continue;
5762
+ const next = { ...change, target_id: nextTargetId };
5763
+ const fromStateId = strOf(change["from_state_id"]);
5764
+ const toStateId = strOf(change["to_state_id"]);
5765
+ if (fromStateId) {
5766
+ const mapped = stateReplacements.get(fromStateId);
5767
+ if (mapped)
5768
+ next["from_state_id"] = mapped;
5769
+ else if (nextTargetId !== targetId)
5770
+ delete next["from_state_id"];
5771
+ }
5772
+ if (toStateId) {
5773
+ const mapped = stateReplacements.get(toStateId);
5774
+ if (mapped)
5775
+ next["to_state_id"] = mapped;
5776
+ else if (nextTargetId !== targetId)
5777
+ delete next["to_state_id"];
5778
+ }
5779
+ rewritten.push(next);
5780
+ }
5781
+ return rewritten;
5782
+ }
5783
+ function rewriteCurationTransition(transition, replacementsByKind, droppedByKind) {
5784
+ if (!isDict(transition))
5785
+ return transition;
5786
+ const targetKind = strOf(transition["target_kind"]);
5787
+ if (targetKind !== "actor" && targetKind !== "location" && targetKind !== "prop")
5788
+ return transition;
5789
+ const targetId = strOf(transition["target_id"]);
5790
+ const nextTargetId = replacementsByKind[targetKind].get(targetId) ?? targetId;
5791
+ if (nextTargetId === targetId && droppedByKind[targetKind].has(targetId))
5792
+ return null;
5793
+ return { ...transition, target_id: nextTargetId };
5794
+ }
5795
+ function rewriteCurationRefs(script, replacementsByKind, droppedByKind, stateReplacements, speakerReplacements) {
5796
+ for (const ep of asList(script["episodes"])) {
5797
+ for (const scene of asList(ep["scenes"])) {
5798
+ const ctx = sceneContext(scene);
5799
+ ctx["actors"] = compactRefsWithStateMap(ctx["actors"], "actor_id", replacementsByKind.actor, droppedByKind.actor, stateReplacements);
5800
+ ctx["locations"] = compactRefsWithStateMap(ctx["locations"], "location_id", replacementsByKind.location, droppedByKind.location, stateReplacements);
5801
+ ctx["props"] = compactRefsWithStateMap(ctx["props"], "prop_id", replacementsByKind.prop, droppedByKind.prop, stateReplacements);
5802
+ setSceneContext(scene, ctx);
5803
+ for (const action of asList(scene["actions"])) {
5804
+ if (!isDict(action))
5805
+ continue;
5806
+ const actorId = strOf(action["actor_id"]);
5807
+ if (replacementsByKind.actor.has(actorId))
5808
+ action["actor_id"] = replacementsByKind.actor.get(actorId);
5809
+ else if (droppedByKind.actor.has(actorId))
5810
+ delete action["actor_id"];
5811
+ const speakerId = strOf(action["speaker_id"]);
5812
+ if (speakerReplacements.has(speakerId))
5813
+ action["speaker_id"] = speakerReplacements.get(speakerId);
5814
+ action["speakers"] = rewriteSpeakerRefs(action["speakers"], speakerReplacements);
5815
+ action["lines"] = rewriteSpeakerRefs(action["lines"], speakerReplacements);
5816
+ if (action["state_changes"] !== undefined && action["state_changes"] !== null) {
5817
+ action["state_changes"] = rewriteCurationStateChanges(action["state_changes"], replacementsByKind, droppedByKind, stateReplacements);
5818
+ if (asList(action["state_changes"]).length === 0)
5819
+ delete action["state_changes"];
5820
+ }
5821
+ if (action["transition_prompt"] !== undefined && action["transition_prompt"] !== null) {
5822
+ const transition = rewriteCurationTransition(action["transition_prompt"], replacementsByKind, droppedByKind);
5823
+ if (transition === null)
5824
+ delete action["transition_prompt"];
5825
+ else
5826
+ action["transition_prompt"] = transition;
5827
+ }
5828
+ }
5829
+ }
5830
+ }
5831
+ }
5832
+ function copyStatesForMerge(script, kind, source, target, stateReplacements) {
5833
+ for (const state of asList(source["states"])) {
5834
+ const sourceStateId = strOf(state["state_id"]);
5835
+ const stateName = strOf(state["state_name"]).trim();
5836
+ if (!sourceStateId || !stateName)
5837
+ continue;
5838
+ const targetStateId = appendState(target, script, stateName, state["description"]);
5839
+ stateReplacements.set(sourceStateId, targetStateId);
5840
+ }
5841
+ addAssetAlias(target, kind, strOf(source[assetNameKey(kind)]));
5842
+ for (const alias of asList(source["aliases"]))
5843
+ addAssetAlias(target, kind, alias);
5844
+ }
5845
+ function upsertPropFromAsset(script, source, sourceKind, decision) {
5846
+ const targetId = strOf(decision["target_id"]);
5847
+ if (targetId) {
5848
+ assertAssetExists(script, "prop", targetId);
5849
+ return targetId;
5850
+ }
5851
+ const propId = nextAssetId(script, "prop");
5852
+ const name = cleanName(decision["new_name"]) || strOf(source[assetNameKey(sourceKind)]);
5853
+ const prop = { prop_id: propId, prop_name: name, description: source["description"] ?? "" };
5854
+ script["props"].push(prop);
5855
+ return propId;
5856
+ }
5857
+ function upsertSpeakerForMove(script, source, sourceKind, decision) {
5858
+ const sourceId = strOf(source[assetIdKey(sourceKind)]);
5859
+ const sourceKindForSpeaker = strOf(decision["speaker_kind"]) || "other";
5860
+ if (!SPEAKER_SOURCE_KINDS.has(sourceKindForSpeaker) || sourceKindForSpeaker === "actor" || sourceKindForSpeaker === "location" || sourceKindForSpeaker === "prop") {
5861
+ curationBlocked("move_to_speaker requires system/broadcast/group/other speaker_kind.", [`speaker_kind: ${sourceKindForSpeaker || "<empty>"}`]);
5862
+ }
5863
+ const displayName = cleanName(decision["new_name"]) || strOf(source[assetNameKey(sourceKind)]);
5864
+ const existingSameSpeaker = asList(script["speakers"]).find((speaker) => strOf(speaker["display_name"]).trim() === displayName && strOf(speaker["source_kind"]).trim() === sourceKindForSpeaker);
5865
+ if (existingSameSpeaker)
5866
+ return strOf(existingSameSpeaker["speaker_id"]);
5867
+ if (sourceKind === "actor") {
5868
+ const actorSpeakerId = speakerIdForActor(sourceId);
5869
+ const existingActorSpeaker = asList(script["speakers"]).find((speaker) => strOf(speaker["speaker_id"]) === actorSpeakerId);
5870
+ if (existingActorSpeaker) {
5871
+ existingActorSpeaker["source_kind"] = sourceKindForSpeaker;
5872
+ existingActorSpeaker["source_id"] = null;
5873
+ existingActorSpeaker["display_name"] = displayName;
5874
+ return actorSpeakerId;
5875
+ }
5876
+ }
5877
+ const speakerId = nextSpeakerId(script, sourceKindForSpeaker);
5878
+ const speaker = {
5879
+ speaker_id: speakerId,
5880
+ display_name: displayName,
5881
+ source_kind: sourceKindForSpeaker,
5882
+ source_id: null,
5883
+ voice_desc: "",
5884
+ };
5885
+ if (!isList(script["speakers"]))
5886
+ script["speakers"] = [];
5887
+ script["speakers"].push(speaker);
5888
+ return speakerId;
5889
+ }
5890
+ function replaceAssetListsAfterCuration(script, droppedByKind) {
5891
+ for (const kind of ["actor", "location", "prop"]) {
5892
+ const idKey = assetIdKey(kind);
5893
+ script[assetListKey(kind)] = asList(script[assetListKey(kind)]).filter((asset) => isDict(asset) && !droppedByKind[kind].has(strOf(asset[idKey])));
5894
+ }
5895
+ }
5896
+ function summarizeActionableCuration(decisions) {
5897
+ const summary = {};
5898
+ for (const decision of decisions) {
5899
+ const key = `${decision["kind"]}_${decision["decision"]}`;
5900
+ summary[key] = (summary[key] ?? 0) + 1;
5901
+ }
5902
+ return summary;
5903
+ }
5904
+ function applyAssetCurationDecisions(script, decisions, coverageSummary, defaultPolicy) {
5905
+ const actorsBefore = asList(script["actors"]).length;
5906
+ const locationsBefore = asList(script["locations"]).length;
5907
+ const propsBefore = asList(script["props"]).length;
5908
+ if (!isList(script["props"]))
5909
+ script["props"] = [];
5910
+ if (!isList(script["speakers"]))
5911
+ script["speakers"] = [];
5912
+ const replacementsByKind = {
5913
+ actor: new Map(),
5914
+ location: new Map(),
5915
+ prop: new Map(),
5916
+ };
5917
+ const droppedByKind = {
5918
+ actor: new Set(),
5919
+ location: new Set(),
5920
+ prop: new Set(),
5921
+ };
5922
+ const stateReplacements = new Map();
5923
+ const speakerReplacements = new Map();
5924
+ for (const decision of decisions) {
5925
+ const kind = strOf(decision["kind"]);
5926
+ const sourceId = strOf(decision["source_id"]);
5927
+ const source = assertAssetExists(script, kind, sourceId);
5928
+ const action = strOf(decision["decision"]);
5929
+ if (action === "rename") {
5930
+ const newName = cleanName(decision["new_name"]);
5931
+ if (!newName)
5932
+ curationBlocked("rename decision requires new_name.", [`${kind}:${sourceId}`]);
5933
+ const oldName = strOf(source[assetNameKey(kind)]);
5934
+ source[assetNameKey(kind)] = newName;
5935
+ addAssetAlias(source, kind, oldName);
5936
+ }
5937
+ else if (action === "merge") {
5938
+ const targetId = strOf(decision["target_id"]);
5939
+ const targetKind = strOf(decision["target_kind"]) || kind;
5940
+ if (targetKind !== kind)
5941
+ curationBlocked("merge decision target_kind must match source kind.", [`${kind}:${sourceId} -> ${targetKind}:${targetId}`]);
5942
+ if (!targetId || targetId === sourceId)
5943
+ curationBlocked("merge decision requires a different target_id.", [`${kind}:${sourceId}`]);
5944
+ const target = assertAssetExists(script, kind, targetId);
5945
+ copyStatesForMerge(script, kind, source, target, stateReplacements);
5946
+ replacementsByKind[kind].set(sourceId, targetId);
5947
+ droppedByKind[kind].add(sourceId);
5948
+ if (kind === "actor")
5949
+ speakerReplacements.set(speakerIdForActor(sourceId), speakerIdForActor(targetId));
5950
+ }
5951
+ else if (action === "drop") {
5952
+ droppedByKind[kind].add(sourceId);
5953
+ }
5954
+ else if (action === "move_to_prop") {
5955
+ const propId = upsertPropFromAsset(script, source, kind, decision);
5956
+ if (kind === "prop") {
5957
+ replacementsByKind.prop.set(sourceId, propId);
5958
+ }
5959
+ else {
5960
+ droppedByKind[kind].add(sourceId);
5961
+ }
5962
+ if (kind === "actor")
5963
+ speakerReplacements.set(speakerIdForActor(sourceId), speakerIdForActor(sourceId));
5964
+ addAssetAlias(assertAssetExists(script, "prop", propId), "prop", strOf(source[assetNameKey(kind)]));
5965
+ }
5966
+ else if (action === "move_to_speaker") {
5967
+ const speakerId = upsertSpeakerForMove(script, source, kind, decision);
5968
+ if (kind === "actor")
5969
+ speakerReplacements.set(speakerIdForActor(sourceId), speakerId);
5970
+ droppedByKind[kind].add(sourceId);
5971
+ }
5972
+ else if (action === "move_to_state") {
5973
+ const targetKind = strOf(decision["target_kind"]);
5974
+ const targetId = strOf(decision["target_id"]);
5975
+ if (targetKind !== "actor" && targetKind !== "location" && targetKind !== "prop")
5976
+ curationBlocked("move_to_state requires target_kind actor/location/prop.", [`${kind}:${sourceId}`]);
5977
+ const target = assertAssetExists(script, targetKind, targetId);
5978
+ const stateName = strOf(decision["state_name"]) || cleanName(decision["new_name"]) || strOf(source[assetNameKey(kind)]);
5979
+ const stateId = appendState(target, script, stateName, source["description"]);
5980
+ if (targetKind === kind && targetId === sourceId)
5981
+ continue;
5982
+ addAssetAlias(target, targetKind, strOf(source[assetNameKey(kind)]));
5983
+ for (const alias of asList(source["aliases"]))
5984
+ addAssetAlias(target, targetKind, alias);
5985
+ if (targetKind === kind)
5986
+ replacementsByKind[kind].set(sourceId, targetId);
5987
+ droppedByKind[kind].add(sourceId);
5988
+ for (const state of asList(source["states"])) {
5989
+ const sourceStateId = strOf(state["state_id"]);
5990
+ if (sourceStateId)
5991
+ stateReplacements.set(sourceStateId, stateId);
5992
+ }
5993
+ if (kind === "actor" && targetKind === "actor")
5994
+ speakerReplacements.set(speakerIdForActor(sourceId), speakerIdForActor(targetId));
5995
+ }
5996
+ }
5997
+ rewriteCurationRefs(script, replacementsByKind, droppedByKind, stateReplacements, speakerReplacements);
5998
+ replaceAssetListsAfterCuration(script, droppedByKind);
5999
+ const replacedSpeakerIds = new Set();
6000
+ for (const [sourceSpeakerId, targetSpeakerId] of speakerReplacements) {
6001
+ if (sourceSpeakerId && targetSpeakerId && sourceSpeakerId !== targetSpeakerId)
6002
+ replacedSpeakerIds.add(sourceSpeakerId);
6003
+ }
6004
+ if (replacedSpeakerIds.size > 0) {
6005
+ script["speakers"] = asList(script["speakers"]).filter((speaker) => isDict(speaker) && !replacedSpeakerIds.has(strOf(speaker["speaker_id"])));
6006
+ }
6007
+ for (const speaker of asList(script["speakers"])) {
6008
+ if (!isDict(speaker))
6009
+ continue;
6010
+ const sourceKind = strOf(speaker["source_kind"]);
6011
+ const sourceId = strOf(speaker["source_id"]);
6012
+ if (sourceKind === "actor" || sourceKind === "location" || sourceKind === "prop") {
6013
+ if (droppedByKind[sourceKind].has(sourceId)) {
6014
+ speaker["source_kind"] = "other";
6015
+ speaker["source_id"] = null;
6016
+ }
6017
+ else if (replacementsByKind[sourceKind].has(sourceId)) {
6018
+ speaker["source_id"] = replacementsByKind[sourceKind].get(sourceId);
6019
+ }
6020
+ }
6021
+ }
6022
+ const actorsAfter = asList(script["actors"]).length;
6023
+ const locationsAfter = asList(script["locations"]).length;
6024
+ const propsAfter = asList(script["props"]).length;
6025
+ const totalBefore = actorsBefore + locationsBefore + propsBefore;
6026
+ const explicitKeepCount = decisions.filter((decision) => decision["decision"] === "keep").length;
6027
+ return {
6028
+ version: 3,
6029
+ summary: {
6030
+ actors_before: actorsBefore,
6031
+ actors_after: actorsAfter,
6032
+ actors_removed: actorsBefore - actorsAfter,
6033
+ props_before: propsBefore,
6034
+ props_after: propsAfter,
6035
+ props_removed: propsBefore - propsAfter,
6036
+ locations_before: locationsBefore,
6037
+ locations_after: locationsAfter,
6038
+ locations_merged: locationsBefore - locationsAfter,
6039
+ decisions: decisions.length,
6040
+ explicit_keep_count: explicitKeepCount,
6041
+ implicit_keep_count: Math.max(0, totalBefore - decisions.length),
6042
+ default_policy: defaultPolicy,
6043
+ coverage: coverageSummary,
6044
+ actions: summarizeActionableCuration(decisions),
6045
+ },
6046
+ decisions,
6047
+ };
6048
+ }
6049
+ export function applyAssetCurationPlan(script, rawPlan) {
6050
+ const decisions = normalizedCurationDecisions(rawPlan);
6051
+ normalizeCurationPlanTargets(script, decisions);
6052
+ const coverageSummary = assertCurationCoverage(script, decisions);
6053
+ assertMergeAcyclic(decisions);
6054
+ return applyAssetCurationDecisions(script, decisions, coverageSummary, "required assets must have explicit decisions; optional omitted actors/locations are kept");
6055
+ }
6056
+ export function applyAssetGroupingDuplicateMerges(script, rawPlan) {
6057
+ const decisions = normalizedCurationDecisions(rawPlan);
6058
+ const invalid = decisions
6059
+ .filter((decision) => decision["decision"] !== "merge")
6060
+ .map((decision) => `${strOf(decision["kind"])}:${strOf(decision["source_id"])}:${strOf(decision["decision"])}`);
6061
+ if (invalid.length > 0)
6062
+ curationBlocked("Asset grouping can only apply duplicate merge decisions.", invalid.slice(0, 40));
6063
+ normalizeCurationPlanTargets(script, decisions);
6064
+ assertMergeAcyclic(decisions);
6065
+ return applyAssetCurationDecisions(script, decisions, { skipped: true, reason: "asset grouping duplicate merge runs before full curation coverage" }, "asset grouping may omit only exact normalized-name duplicates; omitted duplicates are merged before full curation");
6066
+ }
6067
+ export function normalizeAssetCuration(script, rawCuration) {
6068
+ const payload = isDict(rawCuration) ? rawCuration : {};
6069
+ const rawLocations = new Map();
6070
+ for (const item of asList(payload["locations"])) {
6071
+ if (!isDict(item))
6072
+ continue;
6073
+ const id = strOf(item["location_id"]);
6074
+ if (id)
6075
+ rawLocations.set(id, item);
6076
+ }
6077
+ const locationUsage = sceneAssetUsage(script, "locations", "location_id");
6078
+ const validLocationIds = new Set();
6079
+ for (const loc of asList(script["locations"])) {
6080
+ if (isDict(loc)) {
6081
+ const id = strOf(loc["location_id"]);
6082
+ if (id)
6083
+ validLocationIds.add(id);
6084
+ }
6085
+ }
6086
+ const rawLocationDecisions = new Map();
6087
+ for (const [locId, item] of rawLocations) {
6088
+ rawLocationDecisions.set(locId, strOf(item["decision"]).trim());
6089
+ }
6090
+ const locations = [];
6091
+ for (const loc of asList(script["locations"])) {
6092
+ if (!isDict(loc))
6093
+ continue;
6094
+ const locationId = strOf(loc["location_id"]);
6095
+ const sceneCount = sceneCountFromUsage(locationUsage.get(locationId) ?? { episodes: new Set(), scenes: new Set() });
6096
+ const item = rawLocations.get(locationId) ?? {};
6097
+ let decision = strOf(item["decision"]).trim();
6098
+ let targetLocationId = strOf(item["target_location_id"]).trim() || null;
6099
+ let reason = strOf(item["reason"]).trim() || "provider 基于剧本上下文给出决策。";
6100
+ if (decision === "merge") {
6101
+ if (targetLocationId === null ||
6102
+ !validLocationIds.has(targetLocationId) ||
6103
+ targetLocationId === locationId ||
6104
+ rawLocationDecisions.get(targetLocationId) === "merge") {
6105
+ decision = "keep";
6106
+ targetLocationId = null;
6107
+ reason = "保留:provider 给出的地点合并目标无效。";
6108
+ }
6109
+ }
6110
+ else if (decision !== "keep") {
6111
+ decision = "keep";
6112
+ targetLocationId = null;
6113
+ reason = "保留:provider 未给出有效地点裁剪决策。";
6114
+ }
6115
+ else {
6116
+ targetLocationId = null;
6117
+ }
3107
6118
  locations.push({
3108
6119
  location_id: locationId,
3109
6120
  name: loc["location_name"],
@@ -3111,61 +6122,1063 @@ export function normalizeAssetCuration(script, rawCuration) {
3111
6122
  decision,
3112
6123
  target_location_id: targetLocationId,
3113
6124
  reason,
3114
- });
6125
+ });
6126
+ }
6127
+ return {
6128
+ version: 2,
6129
+ actors: actorCurationDecisions(script),
6130
+ props: propCurationDecisions(script),
6131
+ locations,
6132
+ };
6133
+ }
6134
+ export function curateScriptAssets(script, rawCuration = null) {
6135
+ if (isDict(rawCuration) && isList(rawCuration["decisions"])) {
6136
+ return applyAssetCurationPlan(script, rawCuration);
6137
+ }
6138
+ const actorsBefore = asList(script["actors"]).length;
6139
+ const propsBefore = asList(script["props"]).length;
6140
+ const locationsBefore = asList(script["locations"]).length;
6141
+ const normalized = normalizeAssetCuration(script, rawCuration);
6142
+ const actorDecisions = normalized["actors"];
6143
+ const propDecisions = normalized["props"];
6144
+ const locationDecisions = normalized["locations"];
6145
+ const droppedActorIds = new Set(actorDecisions.filter((it) => it["decision"] === "remove").map((it) => strOf(it["actor_id"])).filter((s) => s));
6146
+ const droppedPropIds = new Set(propDecisions.filter((it) => it["decision"] === "remove").map((it) => strOf(it["prop_id"])).filter((s) => s));
6147
+ if (droppedActorIds.size > 0) {
6148
+ script["actors"] = asList(script["actors"]).filter((a) => isDict(a) && !droppedActorIds.has(strOf(a["actor_id"])));
6149
+ }
6150
+ if (droppedPropIds.size > 0) {
6151
+ script["props"] = asList(script["props"]).filter((p) => isDict(p) && !droppedPropIds.has(strOf(p["prop_id"])));
6152
+ }
6153
+ const locationReplacements = new Map();
6154
+ for (const item of locationDecisions) {
6155
+ if (item["decision"] === "merge") {
6156
+ const fromId = strOf(item["location_id"]);
6157
+ const toId = strOf(item["target_location_id"]);
6158
+ if (fromId && toId)
6159
+ locationReplacements.set(fromId, toId);
6160
+ }
6161
+ }
6162
+ applyLocationMerges(script, locationReplacements);
6163
+ applyAssetCurationRefs(script, locationReplacements, droppedPropIds, droppedActorIds);
6164
+ const actorsAfter = asList(script["actors"]).length;
6165
+ const propsAfter = asList(script["props"]).length;
6166
+ const locationsAfter = asList(script["locations"]).length;
6167
+ return {
6168
+ version: normalized["version"],
6169
+ summary: {
6170
+ actors_before: actorsBefore,
6171
+ actors_after: actorsAfter,
6172
+ actors_removed: actorsBefore - actorsAfter,
6173
+ props_before: propsBefore,
6174
+ props_after: propsAfter,
6175
+ props_removed: propsBefore - propsAfter,
6176
+ locations_before: locationsBefore,
6177
+ locations_after: locationsAfter,
6178
+ locations_merged: locationsBefore - locationsAfter,
6179
+ },
6180
+ actors: actorDecisions,
6181
+ props: propDecisions,
6182
+ locations: locationDecisions,
6183
+ };
6184
+ }
6185
+ // ---------------------------------------------------------------------------
6186
+ // State curation
6187
+ // ---------------------------------------------------------------------------
6188
+ export const STATE_CURATION_MAX_ASSETS = 4;
6189
+ export const STATE_CURATION_MAX_CONTEXT_CHARS = 28000;
6190
+ function stateCurationBlocked(message, received) {
6191
+ throw new CliError("DIRECT STATE CURATION BLOCKED: invalid provider plan", message, {
6192
+ exitCode: EXIT_NEEDS_AGENT,
6193
+ required: ["valid state curation expressions referencing existing asset/state ids"],
6194
+ received,
6195
+ nextSteps: ["Inspect state_curation.json, adjust the draft manually, or rerun direct init after provider plan fixes."],
6196
+ });
6197
+ }
6198
+ function stateCurationSourceKey(kind, assetId, stateId) {
6199
+ return `${kind}:${assetId}/${stateId}`;
6200
+ }
6201
+ function stateCurationDecisionSourceKey(decision) {
6202
+ return stateCurationSourceKey(strOf(decision["kind"]), strOf(decision["asset_id"]), strOf(decision["state_id"]));
6203
+ }
6204
+ function stateCurationUsageFor(script) {
6205
+ const usage = new Map();
6206
+ const ensure = (kind, assetId, stateId) => {
6207
+ const key = stateCurationSourceKey(kind, assetId, stateId);
6208
+ const current = usage.get(key);
6209
+ if (current)
6210
+ return current;
6211
+ const next = { sceneRefs: new Set(), stateChanges: new Set() };
6212
+ usage.set(key, next);
6213
+ return next;
6214
+ };
6215
+ for (const ep of asList(script["episodes"])) {
6216
+ const episodeId = strOf(ep["episode_id"]);
6217
+ for (const scene of asList(ep["scenes"])) {
6218
+ const sceneId = strOf(scene["scene_id"]);
6219
+ const sceneRef = [episodeId, sceneId].filter((item) => item).join("/");
6220
+ const ctx = sceneContext(scene);
6221
+ for (const kind of ["actor", "location", "prop"]) {
6222
+ const idKey = assetIdKey(kind);
6223
+ const refKey = assetListKey(kind);
6224
+ for (const ref of asList(ctx[refKey])) {
6225
+ const assetId = strOf(ref[idKey]);
6226
+ const stateId = strOf(ref["state_id"]);
6227
+ if (assetId && stateId && sceneRef)
6228
+ ensure(kind, assetId, stateId).sceneRefs.add(sceneRef);
6229
+ }
6230
+ }
6231
+ for (const [actionIndex, action] of asList(scene["actions"]).entries()) {
6232
+ if (!isDict(action))
6233
+ continue;
6234
+ for (const change of asList(action["state_changes"])) {
6235
+ if (!isDict(change))
6236
+ continue;
6237
+ const kind = strOf(change["target_kind"]);
6238
+ if (!isAssetKindValue(kind))
6239
+ continue;
6240
+ const assetId = strOf(change["target_id"]);
6241
+ if (!assetId)
6242
+ continue;
6243
+ const actionRef = `${sceneRef}#${actionIndex}`;
6244
+ const fromStateId = strOf(change["from_state_id"]);
6245
+ const toStateId = strOf(change["to_state_id"]);
6246
+ if (fromStateId)
6247
+ ensure(kind, assetId, fromStateId).stateChanges.add(actionRef);
6248
+ if (toStateId)
6249
+ ensure(kind, assetId, toStateId).stateChanges.add(actionRef);
6250
+ }
6251
+ }
6252
+ }
6253
+ }
6254
+ return usage;
6255
+ }
6256
+ function statefulAssetsForCuration(script) {
6257
+ const refs = [];
6258
+ for (const kind of ["actor", "location", "prop"]) {
6259
+ for (const asset of asList(script[assetListKey(kind)])) {
6260
+ if (!isDict(asset))
6261
+ continue;
6262
+ const assetId = strOf(asset[assetIdKey(kind)]).trim();
6263
+ if (!assetId || asList(asset["states"]).filter(isDict).length === 0)
6264
+ continue;
6265
+ refs.push({ kind, asset_id: assetId });
6266
+ }
6267
+ }
6268
+ return refs;
6269
+ }
6270
+ function stateCurationAssetByRef(script, ref) {
6271
+ return assetMapById(script, ref.kind).get(ref.asset_id) ?? null;
6272
+ }
6273
+ function compactStateCurationText(value, maxChars = 120) {
6274
+ const text = strOf(value).replace(/\s+/g, " ").replace(/\|/g, "/").trim();
6275
+ return text.length > maxChars ? `${text.slice(0, maxChars - 1)}…` : text;
6276
+ }
6277
+ function formatStateCurationUsage(usage) {
6278
+ if (!usage)
6279
+ return "refs=0 changes=0";
6280
+ return `refs=${usage.sceneRefs.size} changes=${usage.stateChanges.size}`;
6281
+ }
6282
+ function stateCurationPrefix(kind) {
6283
+ if (kind === "actor")
6284
+ return "A";
6285
+ if (kind === "location")
6286
+ return "L";
6287
+ return "P";
6288
+ }
6289
+ function formatStateCurationAssetBlock(script, ref, usageByKey) {
6290
+ const asset = stateCurationAssetByRef(script, ref);
6291
+ if (!asset)
6292
+ return [];
6293
+ const name = compactStateCurationText(asset[assetNameKey(ref.kind)], 80);
6294
+ const description = compactStateCurationText(asset["description"], 160);
6295
+ const states = asList(asset["states"]).filter(isDict);
6296
+ const lines = [
6297
+ `${stateCurationPrefix(ref.kind)} | ${ref.kind} | ${ref.asset_id} | ${name || "-"} | states=${states.length} | desc=${description || "-"}`,
6298
+ ];
6299
+ for (const state of states) {
6300
+ const stateId = compactStateCurationText(state["state_id"], 40);
6301
+ const stateName = compactStateCurationText(state["state_name"], 80);
6302
+ if (!stateId || !stateName)
6303
+ continue;
6304
+ const usage = usageByKey.get(stateCurationSourceKey(ref.kind, ref.asset_id, stateId));
6305
+ lines.push(` S | ${stateId} | ${stateName} | ${formatStateCurationUsage(usage)} | desc=${compactStateCurationText(state["description"], 180) || "-"}`);
6306
+ }
6307
+ return lines;
6308
+ }
6309
+ export function buildStateCurationContextText(script, refs) {
6310
+ const selectedRefs = refs && refs.length > 0 ? refs : statefulAssetsForCuration(script);
6311
+ const usage = stateCurationUsageFor(script);
6312
+ const lines = [];
6313
+ lines.push("# State Curation Context");
6314
+ lines.push(`title: ${compactStateCurationText(script["title"], 120) || "-"}`);
6315
+ lines.push("");
6316
+ lines.push("Goal: compress each asset's states to reusable production visual states.");
6317
+ lines.push("");
6318
+ lines.push("Policy:");
6319
+ lines.push("- A state is a large reusable visual asset form, not a plot/action/emotion/pose label.");
6320
+ lines.push("- KEEP only states that require a visibly different reusable asset: major form transformation, Q version, non-human/beast/god/giant form, durable outfit/appearance redesign, major damage/repair condition, or a prop/location physical condition that must recur.");
6321
+ lines.push("- MERGE states that are only degree/progress variants of the same major appearance. Rename one representative to the broad canonical state, then merge the variants into it.");
6322
+ lines.push("- DROP or ACTION_STATE transient states: emotions, poses, unconscious/asleep/lying/sitting, holding/carrying an item, temporary glow/VFX intensity, minor mark brightness/thickness/spread, one-off ordinary clothing, camera effects, or scene-only actions.");
6323
+ lines.push("- For actor states, held objects are not actor states. If the state is just holding/using an item, use ACTION_STATE unless the item must remain a separate prop.");
6324
+ lines.push("- Every S line below must receive exactly one expression.");
6325
+ lines.push("- Use only state_id values listed under the same asset block. Do not invent ids or cross-asset merge targets.");
6326
+ lines.push("- If a broad canonical state name is needed, RENAME_STATE one existing representative state and MERGE_STATE the variants into it.");
6327
+ lines.push("");
6328
+ lines.push("Expression grammar:");
6329
+ lines.push("- <exp>KEEP_STATE <kind> <asset_id> <state_id> # reason</exp>");
6330
+ lines.push("- <exp>MERGE_STATE <kind> <asset_id> <state_id> -> <target_state_id> # reason</exp>");
6331
+ lines.push("- <exp>DROP_STATE <kind> <asset_id> <state_id> # reason</exp>");
6332
+ lines.push("- <exp>ACTION_STATE <kind> <asset_id> <state_id> # reason</exp>");
6333
+ lines.push("- <exp>RENAME_STATE <kind> <asset_id> <state_id> \"<new_name>\" # reason</exp>");
6334
+ lines.push("- <kind> is one of actor, location, prop. Use double quotes for names; escape only \\\" and \\\\.");
6335
+ lines.push("");
6336
+ lines.push("Assets:");
6337
+ for (const ref of selectedRefs) {
6338
+ const block = formatStateCurationAssetBlock(script, ref, usage);
6339
+ if (block.length > 0)
6340
+ lines.push(...block);
6341
+ }
6342
+ lines.push("");
6343
+ lines.push("required_state_ids:");
6344
+ for (const ref of selectedRefs) {
6345
+ const asset = stateCurationAssetByRef(script, ref);
6346
+ if (!asset)
6347
+ continue;
6348
+ for (const state of asList(asset["states"])) {
6349
+ const stateId = strOf(state["state_id"]);
6350
+ if (stateId)
6351
+ lines.push(`- ${stateCurationSourceKey(ref.kind, ref.asset_id, stateId)}`);
6352
+ }
6353
+ }
6354
+ return `${lines.join("\n")}\n`;
6355
+ }
6356
+ export function buildStateCurationChunks(script, options = {}) {
6357
+ const maxAssets = Math.max(1, Math.floor(options.maxAssets ?? STATE_CURATION_MAX_ASSETS));
6358
+ const maxContextChars = Math.max(2000, Math.floor(options.maxContextChars ?? STATE_CURATION_MAX_CONTEXT_CHARS));
6359
+ const refs = statefulAssetsForCuration(script);
6360
+ const chunks = [];
6361
+ let current = [];
6362
+ const flush = () => {
6363
+ if (current.length === 0)
6364
+ return;
6365
+ chunks.push({ part_id: `part.${String(chunks.length + 1).padStart(3, "0")}`, refs: current });
6366
+ current = [];
6367
+ };
6368
+ for (const ref of refs) {
6369
+ const singletonLength = buildStateCurationContextText(script, [ref]).length;
6370
+ if (current.length === 0) {
6371
+ current.push(ref);
6372
+ if (singletonLength > maxContextChars)
6373
+ flush();
6374
+ continue;
6375
+ }
6376
+ const candidate = [...current, ref];
6377
+ if (candidate.length > maxAssets || buildStateCurationContextText(script, candidate).length > maxContextChars) {
6378
+ flush();
6379
+ current.push(ref);
6380
+ if (singletonLength > maxContextChars)
6381
+ flush();
6382
+ }
6383
+ else {
6384
+ current = candidate;
6385
+ }
6386
+ }
6387
+ flush();
6388
+ return chunks;
6389
+ }
6390
+ function splitStateCurationExpressionReason(expression) {
6391
+ let inQuote = false;
6392
+ let escaped = false;
6393
+ for (let index = 0; index < expression.length; index += 1) {
6394
+ const ch = expression[index];
6395
+ if (escaped) {
6396
+ escaped = false;
6397
+ continue;
6398
+ }
6399
+ if (ch === "\\") {
6400
+ escaped = true;
6401
+ continue;
6402
+ }
6403
+ if (ch === "\"") {
6404
+ inQuote = !inQuote;
6405
+ continue;
6406
+ }
6407
+ if (ch === "#" && !inQuote) {
6408
+ const command = expression.slice(0, index).trim();
6409
+ const reason = expression.slice(index + 1).trim();
6410
+ if (!command)
6411
+ stateCurationBlocked("State curation expression is missing an operation before # reason.", [expression]);
6412
+ if (!reason)
6413
+ stateCurationBlocked("State curation expression is missing reason text after #.", [expression]);
6414
+ return { command, reason };
6415
+ }
6416
+ }
6417
+ stateCurationBlocked("State curation expression must include `# reason`.", [expression]);
6418
+ }
6419
+ function unquoteStateCurationExpressionString(value) {
6420
+ let out = "";
6421
+ let escaped = false;
6422
+ for (const ch of value) {
6423
+ if (escaped) {
6424
+ if (ch !== "\\" && ch !== "\"")
6425
+ stateCurationBlocked("Unsupported escape sequence in state curation expression string.", [`\\${ch}`]);
6426
+ out += ch;
6427
+ escaped = false;
6428
+ }
6429
+ else if (ch === "\\") {
6430
+ escaped = true;
6431
+ }
6432
+ else {
6433
+ out += ch;
6434
+ }
6435
+ }
6436
+ if (escaped)
6437
+ stateCurationBlocked("Unterminated escape sequence in state curation expression string.", [value]);
6438
+ return out;
6439
+ }
6440
+ function parseStateCurationExpression(expression) {
6441
+ if (expression.includes("\n") || expression.includes("\r")) {
6442
+ stateCurationBlocked("Each state curation expression must be a single line.", [expression]);
6443
+ }
6444
+ const { command, reason } = splitStateCurationExpressionReason(expression.trim());
6445
+ if (/^NOOP$/i.test(command))
6446
+ return null;
6447
+ let match = /^KEEP_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)$/i.exec(command);
6448
+ if (match) {
6449
+ return { kind: match[1].toLowerCase(), asset_id: match[2], state_id: match[3], decision: "keep", reason };
6450
+ }
6451
+ match = /^MERGE_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)\s+->\s+(\S+)$/i.exec(command);
6452
+ if (match) {
6453
+ return {
6454
+ kind: match[1].toLowerCase(),
6455
+ asset_id: match[2],
6456
+ state_id: match[3],
6457
+ decision: "merge",
6458
+ target_state_id: match[4],
6459
+ reason,
6460
+ };
6461
+ }
6462
+ match = /^DROP_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)$/i.exec(command);
6463
+ if (match) {
6464
+ return { kind: match[1].toLowerCase(), asset_id: match[2], state_id: match[3], decision: "drop", reason };
6465
+ }
6466
+ match = /^ACTION_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)$/i.exec(command);
6467
+ if (match) {
6468
+ return { kind: match[1].toLowerCase(), asset_id: match[2], state_id: match[3], decision: "action_desc", reason };
6469
+ }
6470
+ match = /^RENAME_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)\s+"((?:\\.|[^"\\])*)"$/i.exec(command);
6471
+ if (match) {
6472
+ return {
6473
+ kind: match[1].toLowerCase(),
6474
+ asset_id: match[2],
6475
+ state_id: match[3],
6476
+ decision: "rename",
6477
+ new_name: unquoteStateCurationExpressionString(match[4]),
6478
+ reason,
6479
+ };
6480
+ }
6481
+ stateCurationBlocked("Unknown or malformed state curation expression.", [expression]);
6482
+ }
6483
+ function extractStateCurationExpressions(rawText) {
6484
+ const expressions = [];
6485
+ const re = /<exp>([\s\S]*?)<\/exp>/g;
6486
+ let outside = "";
6487
+ let lastIndex = 0;
6488
+ let match;
6489
+ while ((match = re.exec(rawText)) !== null) {
6490
+ outside += rawText.slice(lastIndex, match.index);
6491
+ expressions.push(strOf(match[1]).trim());
6492
+ lastIndex = match.index + match[0].length;
6493
+ }
6494
+ outside += rawText.slice(lastIndex);
6495
+ if (outside.trim()) {
6496
+ stateCurationBlocked("State curation response must contain only <exp>...</exp> lines.", [outside.trim().slice(0, 200)]);
6497
+ }
6498
+ if (expressions.length === 0) {
6499
+ stateCurationBlocked("State curation response did not contain any <exp> expressions.", [rawText.slice(0, 200)]);
6500
+ }
6501
+ return expressions;
6502
+ }
6503
+ export function parseStateCurationExpressions(rawText) {
6504
+ const decisions = [];
6505
+ const expressions = extractStateCurationExpressions(rawText);
6506
+ let noop = false;
6507
+ for (const expression of expressions) {
6508
+ const decision = parseStateCurationExpression(expression);
6509
+ if (decision === null)
6510
+ noop = true;
6511
+ else
6512
+ decisions.push(decision);
6513
+ }
6514
+ if (noop && decisions.length > 0) {
6515
+ stateCurationBlocked("NOOP cannot be combined with actionable state curation expressions.", [rawText.slice(0, 200)]);
3115
6516
  }
3116
6517
  return {
3117
- version: 2,
3118
- actors: actorCurationDecisions(script),
3119
- props: propCurationDecisions(script),
3120
- locations,
6518
+ version: 1,
6519
+ format: "state-curation-exp-v1",
6520
+ expression_text: rawText,
6521
+ expressions,
6522
+ decisions,
3121
6523
  };
3122
6524
  }
3123
- export function curateScriptAssets(script, rawCuration = null) {
3124
- const actorsBefore = asList(script["actors"]).length;
3125
- const propsBefore = asList(script["props"]).length;
3126
- const locationsBefore = asList(script["locations"]).length;
3127
- const normalized = normalizeAssetCuration(script, rawCuration);
3128
- const actorDecisions = normalized["actors"];
3129
- const propDecisions = normalized["props"];
3130
- const locationDecisions = normalized["locations"];
3131
- const droppedActorIds = new Set(actorDecisions.filter((it) => it["decision"] === "remove").map((it) => strOf(it["actor_id"])).filter((s) => s));
3132
- const droppedPropIds = new Set(propDecisions.filter((it) => it["decision"] === "remove").map((it) => strOf(it["prop_id"])).filter((s) => s));
3133
- if (droppedActorIds.size > 0) {
3134
- script["actors"] = asList(script["actors"]).filter((a) => isDict(a) && !droppedActorIds.has(strOf(a["actor_id"])));
6525
+ function renderStateCurationDecisionExpression(decision) {
6526
+ const kind = strOf(decision["kind"]);
6527
+ const assetId = strOf(decision["asset_id"]);
6528
+ const stateId = strOf(decision["state_id"]);
6529
+ const reason = strOf(decision["reason"]).trim() || "state curation decision";
6530
+ const action = strOf(decision["decision"]);
6531
+ if (action === "keep")
6532
+ return `KEEP_STATE ${kind} ${assetId} ${stateId} # ${reason}`;
6533
+ if (action === "merge")
6534
+ return `MERGE_STATE ${kind} ${assetId} ${stateId} -> ${strOf(decision["target_state_id"])} # ${reason}`;
6535
+ if (action === "drop")
6536
+ return `DROP_STATE ${kind} ${assetId} ${stateId} # ${reason}`;
6537
+ if (action === "action_desc")
6538
+ return `ACTION_STATE ${kind} ${assetId} ${stateId} # ${reason}`;
6539
+ if (action === "rename")
6540
+ return `RENAME_STATE ${kind} ${assetId} ${stateId} ${quoteCurationExpressionString(decision["new_name"])} # ${reason}`;
6541
+ stateCurationBlocked("Cannot render unknown state curation decision.", [`decision: ${action || "<empty>"}`]);
6542
+ }
6543
+ export function formatStateCurationDecisionExpressions(plan) {
6544
+ const decisions = asList(plan["decisions"]).filter(isDict);
6545
+ if (decisions.length === 0)
6546
+ return "<exp>NOOP # no state curation changes</exp>";
6547
+ return `${decisions.map((decision) => `<exp>${renderStateCurationDecisionExpression(decision)}</exp>`).join("\n")}\n`;
6548
+ }
6549
+ function stateCurationAllSourceKeys(script) {
6550
+ const keys = [];
6551
+ for (const ref of statefulAssetsForCuration(script)) {
6552
+ const asset = stateCurationAssetByRef(script, ref);
6553
+ if (!asset)
6554
+ continue;
6555
+ for (const state of asList(asset["states"])) {
6556
+ const stateId = strOf(state["state_id"]);
6557
+ if (stateId)
6558
+ keys.push(stateCurationSourceKey(ref.kind, ref.asset_id, stateId));
6559
+ }
3135
6560
  }
3136
- if (droppedPropIds.size > 0) {
3137
- script["props"] = asList(script["props"]).filter((p) => isDict(p) && !droppedPropIds.has(strOf(p["prop_id"])));
6561
+ return keys;
6562
+ }
6563
+ export function stateCurationSourceKeysForRefs(script, refs) {
6564
+ const keys = [];
6565
+ for (const ref of refs) {
6566
+ const asset = stateCurationAssetByRef(script, ref);
6567
+ if (!asset)
6568
+ continue;
6569
+ for (const state of asList(asset["states"])) {
6570
+ const stateId = strOf(state["state_id"]);
6571
+ if (stateId)
6572
+ keys.push(stateCurationSourceKey(ref.kind, ref.asset_id, stateId));
6573
+ }
3138
6574
  }
3139
- const locationReplacements = new Map();
3140
- for (const item of locationDecisions) {
3141
- if (item["decision"] === "merge") {
3142
- const fromId = strOf(item["location_id"]);
3143
- const toId = strOf(item["target_location_id"]);
3144
- if (fromId && toId)
3145
- locationReplacements.set(fromId, toId);
6575
+ return keys;
6576
+ }
6577
+ function stateCurationDecisionMap(decisions) {
6578
+ const map = new Map();
6579
+ for (const decision of decisions) {
6580
+ map.set(stateCurationDecisionSourceKey(decision), decision);
6581
+ }
6582
+ return map;
6583
+ }
6584
+ export function missingStateCurationRequiredSourceKeys(script, plan, requiredKeys) {
6585
+ const keys = requiredKeys && requiredKeys.length > 0 ? [...requiredKeys] : stateCurationAllSourceKeys(script);
6586
+ const decisions = stateCurationDecisionMap(asList(plan["decisions"]).filter(isDict));
6587
+ return keys.filter((key) => !decisions.has(key));
6588
+ }
6589
+ function assertStateExistsForCuration(script, kind, assetId, stateId) {
6590
+ const asset = assertAssetExists(script, kind, assetId);
6591
+ for (const state of asList(asset["states"])) {
6592
+ if (strOf(state["state_id"]) === stateId)
6593
+ return state;
6594
+ }
6595
+ stateCurationBlocked("State curation decision references an unknown state.", [stateCurationSourceKey(kind, assetId, stateId)]);
6596
+ }
6597
+ function validateStateCurationDecision(script, decision) {
6598
+ const kind = strOf(decision["kind"]);
6599
+ if (!isAssetKindValue(kind))
6600
+ stateCurationBlocked("Unknown state curation kind.", [`kind: ${kind || "<empty>"}`]);
6601
+ const assetId = strOf(decision["asset_id"]);
6602
+ const stateId = strOf(decision["state_id"]);
6603
+ const action = strOf(decision["decision"]);
6604
+ const reason = strOf(decision["reason"]);
6605
+ if (!assetId || !stateId)
6606
+ stateCurationBlocked("State curation decision is missing asset_id/state_id.", [`${kind}:${assetId}/${stateId}`]);
6607
+ if (!reason)
6608
+ stateCurationBlocked("State curation decision is missing reason.", [`${kind}:${assetId}/${stateId}`]);
6609
+ assertStateExistsForCuration(script, kind, assetId, stateId);
6610
+ if (action === "merge") {
6611
+ const targetStateId = strOf(decision["target_state_id"]);
6612
+ if (!targetStateId || targetStateId === stateId) {
6613
+ stateCurationBlocked("MERGE_STATE requires a different target_state_id.", [`${kind}:${assetId}/${stateId}`]);
3146
6614
  }
6615
+ assertStateExistsForCuration(script, kind, assetId, targetStateId);
6616
+ }
6617
+ else if (action === "rename") {
6618
+ const newName = strOf(decision["new_name"]).trim();
6619
+ if (!newName)
6620
+ stateCurationBlocked("RENAME_STATE requires a non-empty new_name.", [`${kind}:${assetId}/${stateId}`]);
6621
+ }
6622
+ else if (action !== "keep" && action !== "drop" && action !== "action_desc") {
6623
+ stateCurationBlocked("Unknown state curation decision.", [`decision: ${action || "<empty>"}`]);
6624
+ }
6625
+ }
6626
+ function normalizeStateCurationMergeTargets(decisions) {
6627
+ const bySource = stateCurationDecisionMap(decisions);
6628
+ for (const decision of decisions) {
6629
+ if (strOf(decision["decision"]) !== "merge")
6630
+ continue;
6631
+ const kind = strOf(decision["kind"]);
6632
+ const assetId = strOf(decision["asset_id"]);
6633
+ let targetStateId = strOf(decision["target_state_id"]);
6634
+ const seen = new Set([stateCurationDecisionSourceKey(decision)]);
6635
+ while (targetStateId) {
6636
+ const targetKey = stateCurationSourceKey(kind, assetId, targetStateId);
6637
+ if (seen.has(targetKey))
6638
+ stateCurationBlocked("State curation merge plan contains a cycle.", [`cycle at ${targetKey}`]);
6639
+ seen.add(targetKey);
6640
+ const targetDecision = bySource.get(targetKey);
6641
+ if (!targetDecision)
6642
+ break;
6643
+ const targetAction = strOf(targetDecision["decision"]);
6644
+ if (targetAction === "merge") {
6645
+ targetStateId = strOf(targetDecision["target_state_id"]);
6646
+ decision["target_state_id"] = targetStateId;
6647
+ continue;
6648
+ }
6649
+ if (targetAction === "drop" || targetAction === "action_desc") {
6650
+ decision["decision"] = targetAction;
6651
+ delete decision["target_state_id"];
6652
+ }
6653
+ break;
6654
+ }
6655
+ }
6656
+ }
6657
+ export function assertStateCurationPlan(script, plan, requiredKeys) {
6658
+ const rawDecisions = asList(plan["decisions"]).filter(isDict);
6659
+ const decisions = [...stateCurationDecisionMap(rawDecisions).values()];
6660
+ for (const decision of decisions)
6661
+ validateStateCurationDecision(script, decision);
6662
+ normalizeStateCurationMergeTargets(decisions);
6663
+ const missing = missingStateCurationRequiredSourceKeys(script, { decisions }, requiredKeys);
6664
+ if (missing.length > 0) {
6665
+ stateCurationBlocked("State curation plan is missing required state decisions.", missing.slice(0, 80));
3147
6666
  }
3148
- applyLocationMerges(script, locationReplacements);
3149
- applyAssetCurationRefs(script, locationReplacements, droppedPropIds, droppedActorIds);
3150
- const actorsAfter = asList(script["actors"]).length;
3151
- const propsAfter = asList(script["props"]).length;
3152
- const locationsAfter = asList(script["locations"]).length;
3153
6667
  return {
3154
- version: normalized["version"],
6668
+ ...plan,
6669
+ decisions,
6670
+ };
6671
+ }
6672
+ function mapStateForCuration(kind, assetId, stateId, replacements, removed) {
6673
+ const id = strOf(stateId);
6674
+ if (!id)
6675
+ return null;
6676
+ const key = stateCurationSourceKey(kind, assetId, id);
6677
+ const replacement = replacements.get(key);
6678
+ if (replacement)
6679
+ return replacement;
6680
+ if (removed.has(key))
6681
+ return null;
6682
+ return id;
6683
+ }
6684
+ function rewriteRefsAfterStateCuration(script, replacements, removed) {
6685
+ let sceneRefsCleared = 0;
6686
+ let sceneRefsMerged = 0;
6687
+ let stateChangesRemoved = 0;
6688
+ let stateChangesRewritten = 0;
6689
+ for (const ep of asList(script["episodes"])) {
6690
+ for (const scene of asList(ep["scenes"])) {
6691
+ const ctx = sceneContext(scene);
6692
+ for (const kind of ["actor", "location", "prop"]) {
6693
+ const idKey = assetIdKey(kind);
6694
+ const refKey = assetListKey(kind);
6695
+ for (const ref of asList(ctx[refKey])) {
6696
+ if (!isDict(ref))
6697
+ continue;
6698
+ const assetId = strOf(ref[idKey]);
6699
+ const prior = strOf(ref["state_id"]);
6700
+ const next = mapStateForCuration(kind, assetId, prior, replacements, removed);
6701
+ if (!next && prior) {
6702
+ ref["state_id"] = null;
6703
+ sceneRefsCleared += 1;
6704
+ }
6705
+ else if (next && next !== prior) {
6706
+ ref["state_id"] = next;
6707
+ sceneRefsMerged += 1;
6708
+ }
6709
+ }
6710
+ }
6711
+ setSceneContext(scene, ctx);
6712
+ for (const action of asList(scene["actions"])) {
6713
+ if (!isDict(action) || action["state_changes"] === undefined || action["state_changes"] === null)
6714
+ continue;
6715
+ const nextChanges = [];
6716
+ for (const change of asList(action["state_changes"])) {
6717
+ if (!isDict(change))
6718
+ continue;
6719
+ const kind = strOf(change["target_kind"]);
6720
+ if (!isAssetKindValue(kind))
6721
+ continue;
6722
+ const assetId = strOf(change["target_id"]);
6723
+ const toState = mapStateForCuration(kind, assetId, change["to_state_id"], replacements, removed);
6724
+ if (!toState) {
6725
+ stateChangesRemoved += 1;
6726
+ continue;
6727
+ }
6728
+ const fromState = mapStateForCuration(kind, assetId, change["from_state_id"], replacements, removed);
6729
+ if (fromState && fromState === toState) {
6730
+ stateChangesRemoved += 1;
6731
+ continue;
6732
+ }
6733
+ const next = { ...change, to_state_id: toState };
6734
+ if (fromState)
6735
+ next["from_state_id"] = fromState;
6736
+ else
6737
+ delete next["from_state_id"];
6738
+ if (toState !== strOf(change["to_state_id"]) || fromState !== strOf(change["from_state_id"]))
6739
+ stateChangesRewritten += 1;
6740
+ nextChanges.push(next);
6741
+ }
6742
+ if (nextChanges.length > 0)
6743
+ action["state_changes"] = nextChanges;
6744
+ else
6745
+ delete action["state_changes"];
6746
+ }
6747
+ }
6748
+ }
6749
+ return {
6750
+ scene_refs_cleared: sceneRefsCleared,
6751
+ scene_refs_merged: sceneRefsMerged,
6752
+ state_changes_removed: stateChangesRemoved,
6753
+ state_changes_rewritten: stateChangesRewritten,
6754
+ };
6755
+ }
6756
+ export function applyStateCurationPlan(script, rawPlan) {
6757
+ const plan = assertStateCurationPlan(script, rawPlan);
6758
+ const decisions = asList(plan["decisions"]).filter(isDict);
6759
+ const beforeByKind = { actor: 0, location: 0, prop: 0 };
6760
+ const afterByKind = { actor: 0, location: 0, prop: 0 };
6761
+ for (const kind of ["actor", "location", "prop"]) {
6762
+ for (const asset of asList(script[assetListKey(kind)]))
6763
+ beforeByKind[kind] += asList(asset["states"]).filter(isDict).length;
6764
+ }
6765
+ const replacements = new Map();
6766
+ const removed = new Set();
6767
+ const decisionSummary = {
6768
+ keep: 0,
6769
+ merge: 0,
6770
+ drop: 0,
6771
+ action_desc: 0,
6772
+ rename: 0,
6773
+ };
6774
+ for (const decision of decisions) {
6775
+ const kind = strOf(decision["kind"]);
6776
+ const assetId = strOf(decision["asset_id"]);
6777
+ const stateId = strOf(decision["state_id"]);
6778
+ const key = stateCurationSourceKey(kind, assetId, stateId);
6779
+ const action = strOf(decision["decision"]);
6780
+ decisionSummary[action] = Number(decisionSummary[action] ?? 0) + 1;
6781
+ if (action === "rename") {
6782
+ const state = assertStateExistsForCuration(script, kind, assetId, stateId);
6783
+ state["state_name"] = strOf(decision["new_name"]).trim();
6784
+ }
6785
+ else if (action === "merge") {
6786
+ replacements.set(key, strOf(decision["target_state_id"]));
6787
+ removed.add(key);
6788
+ }
6789
+ else if (action === "drop" || action === "action_desc") {
6790
+ removed.add(key);
6791
+ }
6792
+ }
6793
+ const rewriteSummary = rewriteRefsAfterStateCuration(script, replacements, removed);
6794
+ for (const kind of ["actor", "location", "prop"]) {
6795
+ for (const asset of asList(script[assetListKey(kind)])) {
6796
+ if (!isDict(asset))
6797
+ continue;
6798
+ const assetId = strOf(asset[assetIdKey(kind)]);
6799
+ asset["states"] = asList(asset["states"]).filter((state) => {
6800
+ if (!isDict(state))
6801
+ return false;
6802
+ const key = stateCurationSourceKey(kind, assetId, strOf(state["state_id"]));
6803
+ return !removed.has(key);
6804
+ });
6805
+ afterByKind[kind] += asList(asset["states"]).filter(isDict).length;
6806
+ }
6807
+ }
6808
+ const statesBefore = beforeByKind.actor + beforeByKind.location + beforeByKind.prop;
6809
+ const statesAfter = afterByKind.actor + afterByKind.location + afterByKind.prop;
6810
+ return {
6811
+ version: 1,
6812
+ format: "state-curation-exp-v1",
3155
6813
  summary: {
3156
- actors_before: actorsBefore,
3157
- actors_after: actorsAfter,
3158
- actors_removed: actorsBefore - actorsAfter,
3159
- props_before: propsBefore,
3160
- props_after: propsAfter,
3161
- props_removed: propsBefore - propsAfter,
3162
- locations_before: locationsBefore,
3163
- locations_after: locationsAfter,
3164
- locations_merged: locationsBefore - locationsAfter,
6814
+ states_before: statesBefore,
6815
+ states_after: statesAfter,
6816
+ states_removed: statesBefore - statesAfter,
6817
+ actor_states_before: beforeByKind.actor,
6818
+ actor_states_after: afterByKind.actor,
6819
+ location_states_before: beforeByKind.location,
6820
+ location_states_after: afterByKind.location,
6821
+ prop_states_before: beforeByKind.prop,
6822
+ prop_states_after: afterByKind.prop,
6823
+ decisions: decisionSummary,
6824
+ ...rewriteSummary,
3165
6825
  },
3166
- actors: actorDecisions,
3167
- props: propDecisions,
3168
- locations: locationDecisions,
6826
+ decisions,
6827
+ };
6828
+ }
6829
+ // ---------------------------------------------------------------------------
6830
+ // State binding
6831
+ // ---------------------------------------------------------------------------
6832
+ function stateBindingBlocked(message, received) {
6833
+ throw new CliError("DIRECT STATE BINDING BLOCKED: invalid provider plan", message, {
6834
+ exitCode: EXIT_NEEDS_AGENT,
6835
+ required: ["valid state binding plan referencing existing scene/action/asset/state ids"],
6836
+ received,
6837
+ nextSteps: ["Inspect state_binding.json, adjust the draft manually, or rerun direct init after provider plan fixes."],
6838
+ });
6839
+ }
6840
+ export function normalizeStateBindingPlanShape(rawPlan) {
6841
+ if (!isDict(rawPlan))
6842
+ return null;
6843
+ const scenes = rawPlan["scenes"];
6844
+ if (isList(scenes))
6845
+ return rawPlan;
6846
+ if (typeof scenes === "string") {
6847
+ const text = scenes.trim();
6848
+ if (!text)
6849
+ return rawPlan;
6850
+ try {
6851
+ const parsed = JSON.parse(text);
6852
+ if (isList(parsed))
6853
+ return { ...rawPlan, scenes: parsed };
6854
+ }
6855
+ catch {
6856
+ return rawPlan;
6857
+ }
6858
+ }
6859
+ return rawPlan;
6860
+ }
6861
+ function stateIdsByAssetId(script) {
6862
+ const out = new Map();
6863
+ for (const kind of ["actor", "location", "prop"]) {
6864
+ for (const asset of asList(script[assetListKey(kind)])) {
6865
+ const assetId = strOf(asset[assetIdKey(kind)]);
6866
+ if (!assetId)
6867
+ continue;
6868
+ out.set(assetId, new Set(asList(asset["states"]).map((state) => strOf(state["state_id"])).filter((id) => id)));
6869
+ }
6870
+ }
6871
+ return out;
6872
+ }
6873
+ function stateNameById(asset, stateId) {
6874
+ for (const state of asList(asset["states"])) {
6875
+ if (strOf(state["state_id"]) === stateId)
6876
+ return strOf(state["state_name"]);
6877
+ }
6878
+ return "";
6879
+ }
6880
+ function bindableDefaultStateId(asset) {
6881
+ const states = asList(asset["states"]);
6882
+ if (states.length === 0)
6883
+ return "";
6884
+ const defaultState = states.find((state) => strOf(state["state_name"]).trim() === "默认");
6885
+ if (defaultState)
6886
+ return strOf(defaultState["state_id"]);
6887
+ if (states.length === 1)
6888
+ return strOf(states[0]["state_id"]);
6889
+ return "";
6890
+ }
6891
+ export function ensureDefaultStates(script) {
6892
+ let added = 0;
6893
+ for (const kind of ["actor", "location", "prop"]) {
6894
+ for (const asset of asList(script[assetListKey(kind)])) {
6895
+ const states = asList(asset["states"]);
6896
+ if (states.length <= 1)
6897
+ continue;
6898
+ if (states.some((state) => strOf(state["state_name"]).trim() === "默认"))
6899
+ continue;
6900
+ appendState(asset, script, "默认", asset["description"]);
6901
+ added += 1;
6902
+ }
6903
+ }
6904
+ return { default_states_added: added };
6905
+ }
6906
+ export function bindDefaultStateRefsInScript(script) {
6907
+ const assetMaps = {
6908
+ actor: assetMapById(script, "actor"),
6909
+ location: assetMapById(script, "location"),
6910
+ prop: assetMapById(script, "prop"),
6911
+ };
6912
+ let bound = 0;
6913
+ let unresolved = 0;
6914
+ for (const ep of asList(script["episodes"])) {
6915
+ for (const scene of asList(ep["scenes"])) {
6916
+ const ctx = sceneContext(scene);
6917
+ for (const kind of ["actor", "location", "prop"]) {
6918
+ const idKey = assetIdKey(kind);
6919
+ const refKey = assetListKey(kind);
6920
+ for (const ref of asList(ctx[refKey])) {
6921
+ if (!isDict(ref) || ref["state_id"])
6922
+ continue;
6923
+ const asset = assetMaps[kind].get(strOf(ref[idKey]));
6924
+ if (!asset || asList(asset["states"]).length === 0)
6925
+ continue;
6926
+ const stateId = bindableDefaultStateId(asset);
6927
+ if (stateId) {
6928
+ ref["state_id"] = stateId;
6929
+ bound += 1;
6930
+ }
6931
+ else {
6932
+ unresolved += 1;
6933
+ }
6934
+ }
6935
+ }
6936
+ setSceneContext(scene, ctx);
6937
+ }
6938
+ }
6939
+ return { default_or_unique_refs_bound: bound, unresolved_stateful_refs: unresolved };
6940
+ }
6941
+ function compactStateBindingAsset(kind, asset) {
6942
+ return {
6943
+ kind,
6944
+ id: asset[assetIdKey(kind)],
6945
+ name: asset[assetNameKey(kind)],
6946
+ description: asset["description"],
6947
+ states: stateCatalog(asset),
6948
+ };
6949
+ }
6950
+ function stateBindingAssetRefIds(sceneContexts, refKey) {
6951
+ const ids = new Set();
6952
+ for (const scene of sceneContexts) {
6953
+ const refs = asDict(scene["refs"]);
6954
+ for (const ref of asList(refs[refKey])) {
6955
+ const id = strOf(ref["id"]);
6956
+ if (id)
6957
+ ids.add(id);
6958
+ }
6959
+ }
6960
+ return ids;
6961
+ }
6962
+ function sceneNeedsLlmStateBinding(sceneContext) {
6963
+ const refs = asDict(sceneContext["refs"]);
6964
+ for (const refKey of ["actors", "locations", "props"]) {
6965
+ for (const ref of asList(refs[refKey])) {
6966
+ if (asList(ref["states"]).length > 1)
6967
+ return true;
6968
+ }
6969
+ }
6970
+ return false;
6971
+ }
6972
+ function sceneBindingContext(script, ep, scene) {
6973
+ const actorMap = assetMapById(script, "actor");
6974
+ const locationMap = assetMapById(script, "location");
6975
+ const propMap = assetMapById(script, "prop");
6976
+ const ctx = sceneContext(scene);
6977
+ const refsFor = (kind) => {
6978
+ const idKey = assetIdKey(kind);
6979
+ const refKey = assetListKey(kind);
6980
+ const assets = kind === "actor" ? actorMap : kind === "location" ? locationMap : propMap;
6981
+ return asList(ctx[refKey]).map((ref) => {
6982
+ const asset = assets.get(strOf(ref[idKey]));
6983
+ return {
6984
+ id: ref[idKey],
6985
+ name: asset ? asset[assetNameKey(kind)] : "",
6986
+ current_state_id: ref["state_id"] ?? null,
6987
+ current_state_name: asset && ref["state_id"] ? stateNameById(asset, strOf(ref["state_id"])) : "",
6988
+ states: asset ? stateCatalog(asset) : [],
6989
+ };
6990
+ });
6991
+ };
6992
+ return {
6993
+ episode_id: ep["episode_id"],
6994
+ scene_id: scene["scene_id"],
6995
+ environment: scene["environment"],
6996
+ refs: {
6997
+ actors: refsFor("actor"),
6998
+ locations: refsFor("location"),
6999
+ props: refsFor("prop"),
7000
+ },
7001
+ actions: asList(scene["actions"]).map((action, index) => ({
7002
+ index,
7003
+ type: action["type"],
7004
+ content: action["content"],
7005
+ actor_id: action["actor_id"],
7006
+ speaker_id: action["speaker_id"],
7007
+ emotion: action["emotion"],
7008
+ })),
7009
+ };
7010
+ }
7011
+ export function buildStateBindingContexts(script, chunkSize = 10) {
7012
+ const scenes = [];
7013
+ for (const ep of asList(script["episodes"])) {
7014
+ for (const scene of asList(ep["scenes"])) {
7015
+ const sceneContext = sceneBindingContext(script, ep, scene);
7016
+ if (sceneNeedsLlmStateBinding(sceneContext))
7017
+ scenes.push(sceneContext);
7018
+ }
7019
+ }
7020
+ const actorMap = assetMapById(script, "actor");
7021
+ const locationMap = assetMapById(script, "location");
7022
+ const propMap = assetMapById(script, "prop");
7023
+ const chunks = [];
7024
+ const size = Math.max(1, Math.floor(chunkSize) || 1);
7025
+ for (let i = 0; i < scenes.length; i += size) {
7026
+ const chunkScenes = scenes.slice(i, i + size);
7027
+ const actorIds = stateBindingAssetRefIds(chunkScenes, "actors");
7028
+ const locationIds = stateBindingAssetRefIds(chunkScenes, "locations");
7029
+ const propIds = stateBindingAssetRefIds(chunkScenes, "props");
7030
+ const assetCatalog = {
7031
+ actors: [...actorIds].map((id) => actorMap.get(id)).filter(isDict).map((asset) => compactStateBindingAsset("actor", asset)),
7032
+ locations: [...locationIds].map((id) => locationMap.get(id)).filter(isDict).map((asset) => compactStateBindingAsset("location", asset)),
7033
+ props: [...propIds].map((id) => propMap.get(id)).filter(isDict).map((asset) => compactStateBindingAsset("prop", asset)),
7034
+ };
7035
+ chunks.push({
7036
+ title: script["title"],
7037
+ policy: [
7038
+ "Bind each scene asset ref to the best existing state_id when the actions/context show a durable visual state.",
7039
+ "Use null only when no existing state fits and no default/unique state should apply.",
7040
+ "Emit state_changes only for explicit transformation, outfit, damage, breakage, repair, or form-change actions.",
7041
+ "Do not invent new states or assets.",
7042
+ ],
7043
+ assets: assetCatalog,
7044
+ scenes: chunkScenes,
7045
+ });
7046
+ }
7047
+ return chunks;
7048
+ }
7049
+ function findSceneById(script, sceneId) {
7050
+ for (const ep of asList(script["episodes"])) {
7051
+ for (const scene of asList(ep["scenes"])) {
7052
+ if (strOf(scene["scene_id"]) === sceneId)
7053
+ return scene;
7054
+ }
7055
+ }
7056
+ return null;
7057
+ }
7058
+ function resolveStateForAsset(stateIdsByAsset, assetId, stateIdRaw, label, mode, stats) {
7059
+ if (stateIdRaw === null || stateIdRaw === undefined || stateIdRaw === "")
7060
+ return null;
7061
+ const stateId = strOf(stateIdRaw);
7062
+ if (!(stateIdsByAsset.get(assetId) ?? new Set()).has(stateId)) {
7063
+ if (mode === "skip") {
7064
+ if (label.startsWith("state_changes."))
7065
+ stats.invalid_state_changes_skipped += 1;
7066
+ else
7067
+ stats.invalid_scene_refs_skipped += 1;
7068
+ return undefined;
7069
+ }
7070
+ stateBindingBlocked("State binding references a state_id that does not belong to the asset.", [`${label}: ${assetId}/${stateId}`]);
7071
+ }
7072
+ return stateId;
7073
+ }
7074
+ function applyStateRefsForScene(ctx, planScene, refKey, idKey, stateIdsByAsset, mode, stats) {
7075
+ const refs = asList(ctx[refKey]);
7076
+ const byId = new Map(refs.map((ref) => [strOf(ref[idKey]), ref]));
7077
+ let bound = 0;
7078
+ for (const planned of asList(planScene[refKey])) {
7079
+ if (!isDict(planned))
7080
+ continue;
7081
+ const assetId = strOf(planned[idKey]);
7082
+ const ref = byId.get(assetId);
7083
+ if (!ref) {
7084
+ if (mode === "skip") {
7085
+ stats.invalid_scene_refs_skipped += 1;
7086
+ continue;
7087
+ }
7088
+ stateBindingBlocked("State binding references an asset not present in the scene.", [`${refKey}:${assetId}`]);
7089
+ }
7090
+ const stateId = resolveStateForAsset(stateIdsByAsset, assetId, planned["state_id"], `${refKey}.state_id`, mode, stats);
7091
+ if (stateId === undefined)
7092
+ continue;
7093
+ if (stateId && ref["state_id"] !== stateId) {
7094
+ ref["state_id"] = stateId;
7095
+ bound += 1;
7096
+ }
7097
+ }
7098
+ return bound;
7099
+ }
7100
+ function sceneHasAssetRef(ctx, kind, assetId) {
7101
+ const idKey = assetIdKey(kind);
7102
+ const refs = asList(ctx[assetListKey(kind)]);
7103
+ return refs.some((ref) => strOf(ref[idKey]) === assetId);
7104
+ }
7105
+ export function applyStateBindingPlan(script, rawPlan, options = {}) {
7106
+ const payload = isDict(rawPlan) ? rawPlan : {};
7107
+ const stateIdsByAsset = stateIdsByAssetId(script);
7108
+ const mode = options.invalidStateMode ?? "reject";
7109
+ const stats = { invalid_scene_refs_skipped: 0, invalid_state_changes_skipped: 0 };
7110
+ let sceneRefsBound = 0;
7111
+ let stateChangesApplied = 0;
7112
+ for (const planScene of asList(payload["scenes"])) {
7113
+ if (!isDict(planScene))
7114
+ continue;
7115
+ const sceneId = strOf(planScene["scene_id"]);
7116
+ const scene = findSceneById(script, sceneId);
7117
+ if (!scene)
7118
+ stateBindingBlocked("State binding references an unknown scene_id.", [`scene_id: ${sceneId || "<empty>"}`]);
7119
+ const ctx = sceneContext(scene);
7120
+ sceneRefsBound += applyStateRefsForScene(ctx, planScene, "actors", "actor_id", stateIdsByAsset, mode, stats);
7121
+ sceneRefsBound += applyStateRefsForScene(ctx, planScene, "locations", "location_id", stateIdsByAsset, mode, stats);
7122
+ sceneRefsBound += applyStateRefsForScene(ctx, planScene, "props", "prop_id", stateIdsByAsset, mode, stats);
7123
+ setSceneContext(scene, ctx);
7124
+ const actions = asList(scene["actions"]);
7125
+ for (const change of asList(planScene["state_changes"])) {
7126
+ if (!isDict(change))
7127
+ continue;
7128
+ const actionIndex = Number(change["action_index"]);
7129
+ const action = actions[actionIndex];
7130
+ if (!Number.isInteger(actionIndex) || !action) {
7131
+ if (mode === "skip") {
7132
+ stats.invalid_state_changes_skipped += 1;
7133
+ continue;
7134
+ }
7135
+ stateBindingBlocked("State binding references an unknown action_index.", [`${sceneId}#${change["action_index"]}`]);
7136
+ }
7137
+ const targetKind = strOf(change["target_kind"]);
7138
+ if (!SCRIPT_TARGET_KINDS.has(targetKind))
7139
+ stateBindingBlocked("State change target_kind is invalid.", [`target_kind: ${targetKind || "<empty>"}`]);
7140
+ const targetId = strOf(change["target_id"]);
7141
+ if (!sceneHasAssetRef(ctx, targetKind, targetId)) {
7142
+ if (mode === "skip") {
7143
+ stats.invalid_state_changes_skipped += 1;
7144
+ continue;
7145
+ }
7146
+ stateBindingBlocked("State change references an asset not present in the scene.", [`${sceneId}#${actionIndex}:${targetKind}:${targetId}`]);
7147
+ }
7148
+ const toStateId = resolveStateForAsset(stateIdsByAsset, targetId, change["to_state_id"], "state_changes.to_state_id", mode, stats);
7149
+ if (toStateId === undefined)
7150
+ continue;
7151
+ if (!toStateId) {
7152
+ if (mode === "skip") {
7153
+ stats.invalid_state_changes_skipped += 1;
7154
+ continue;
7155
+ }
7156
+ stateBindingBlocked("State change requires to_state_id.", [`${sceneId}#${actionIndex}`]);
7157
+ }
7158
+ const fromStateId = resolveStateForAsset(stateIdsByAsset, targetId, change["from_state_id"], "state_changes.from_state_id", mode, stats);
7159
+ if (fromStateId === undefined)
7160
+ continue;
7161
+ const effectiveFrom = strOf(change["effective_from"] || "after_action");
7162
+ if (effectiveFrom !== "before_action" && effectiveFrom !== "after_action")
7163
+ stateBindingBlocked("State change effective_from is invalid.", [`effective_from: ${effectiveFrom}`]);
7164
+ const nextChange = {
7165
+ target_kind: targetKind,
7166
+ target_id: targetId,
7167
+ from_state_id: fromStateId || "",
7168
+ to_state_id: toStateId,
7169
+ effective_from: effectiveFrom,
7170
+ };
7171
+ const existing = asList(action["state_changes"]).filter((item) => !(strOf(item["target_kind"]) === targetKind && strOf(item["target_id"]) === targetId));
7172
+ existing.push(nextChange);
7173
+ action["state_changes"] = existing;
7174
+ stateChangesApplied += 1;
7175
+ }
7176
+ }
7177
+ return {
7178
+ scene_refs_bound: sceneRefsBound,
7179
+ state_changes_applied: stateChangesApplied,
7180
+ invalid_scene_refs_skipped: stats.invalid_scene_refs_skipped,
7181
+ invalid_state_changes_skipped: stats.invalid_state_changes_skipped,
3169
7182
  };
3170
7183
  }
3171
7184
  function emptyAssetNormalizationReport() {