@mengine/medeo-tool 1.2.1-alpha.8 → 1.2.1-alpha.9

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.
package/README.md CHANGED
@@ -76,9 +76,31 @@ explicitly. Assets and media Entities are intentionally not one-to-one.
76
76
  The production tool exposes native Clip/Marker editing plus visual placement,
77
77
  voiceover, caption, and BGM helpers through its generated sandbox interface.
78
78
  They preserve structural and anchor relations in the same plan. Timeline targets
79
- are Entity IDs, never raw asset IDs or URLs. Asset import, media creation, factual `generated` relations, and Clip
80
- insertion belong in the **same entity plan**. `generated` retains output at
81
- endpoint 0 and input at endpoint 1, while lookup can use either endpoint.
79
+ are Entity IDs, never raw asset IDs or URLs. Asset import, media creation, and
80
+ Clip insertion belong in the **same entity plan**.
81
+
82
+ Generation lineage is program-synced, not model-authored. After a confirmed
83
+ entity commit the tool resolves the plan's diff against host-supplied generation
84
+ facts and commits missing `generated` Relations between fact-matched media
85
+ Entities already present in the document (endpoint 0 output, endpoint 1 input;
86
+ lookup can use either endpoint). The diff covers created media Entities, edits
87
+ that re-point an existing physical-asset binding or Asset external key, and
88
+ creations; pairs already fact-resolvable before the plan stay untouched. Models
89
+ do not pass generation history through inputs — the host queries it with
90
+ `loadGenerationFacts(docId, assetIds)`, returning every known generation record
91
+ involving the given asset ids in either role. Each record must carry an explicit
92
+ `inputAssetIds` array: an explicit empty array declares text-only generation
93
+ with no lineage edge, while a missing or non-array field is a malformed record
94
+ that fails the whole query instead of being silently read as text-only.
95
+ One-sided facts are skipped without creating entities or blocking the commit —
96
+ lineage sync never backfills a missing source or output Entity; entity creation
97
+ stays a model decision inside the edit plan. Repeated commits are idempotent,
98
+ and deletions, rebindings, and revision conflicts are respected: a CAS-conflict
99
+ retry re-derives the trigger keys from the fresh bindings and re-queries the
100
+ newly scoped facts, so a backfillable edge is never misreported as current.
101
+ An empty result array means no known lineage; a rejection means the lineage
102
+ query failed and is reported as `generation_sync: {status:'failed'}` plus a
103
+ `generation_sync_failed` warning — never as synced state.
82
104
 
83
105
  Entities own their facts: SequenceMarker owns source/target ranges, duration,
84
106
  and time remapping; Clip owns volume; Track owns role/visibility. Cross-entity
package/dist/index.d.mts CHANGED
@@ -209,6 +209,38 @@ type EditScriptResult = {
209
209
  /** Run `script` against a forked document snapshot; always resolves (never rejects). */
210
210
  declare function runEditScript(options: RunEditScriptOptions): Promise<EditScriptResult>;
211
211
  //#endregion
212
+ //#region src/entity/generation-sync.d.ts
213
+ /** Factual generation lineage for recalled Memota assets, supplied by the host. */
214
+ interface AssetGenerationFact {
215
+ /** External asset id of the generation output (memota asset or speech result id). */
216
+ readonly outputAssetId: string;
217
+ /** Factual input asset ids; empty for text-only generation. */
218
+ readonly inputAssetIds: readonly string[];
219
+ }
220
+ /**
221
+ * Host callback resolving lineage by external asset id. Implementations return
222
+ * every known generation record involving the given ids in either role; an
223
+ * empty array means no known lineage and a rejection means the lineage query
224
+ * failed. Entity and Relation semantics stay inside this package.
225
+ */
226
+ type GenerationFactsLoader = (docId: string, assetIds: readonly string[]) => Promise<readonly AssetGenerationFact[]>;
227
+ /**
228
+ * Outcome of the post-commit lineage sync. `failed` is always also surfaced as
229
+ * a `generation_sync_failed` warning so an unavailable lineage query is never
230
+ * presented as synced state.
231
+ */
232
+ interface GenerationSyncOutcome {
233
+ /**
234
+ * applied: new generated Relations were committed.
235
+ * current: the query succeeded and nothing was missing (no created asset,
236
+ * single side absent, text-only generation, or pair already linked).
237
+ * failed: the host query or the sync commit failed.
238
+ */
239
+ readonly status: 'applied' | 'current' | 'failed';
240
+ readonly created_relation_ids?: readonly string[];
241
+ readonly message?: string;
242
+ }
243
+ //#endregion
212
244
  //#region src/prompt.d.ts
213
245
  declare const MEDEO_TOOL_DESCRIPTION: string;
214
246
  //#endregion
@@ -245,7 +277,7 @@ declare const MEDEO_TOOL_PARAMETERS: {
245
277
  };
246
278
  readonly inputs: {
247
279
  readonly type: 'object';
248
- readonly description: 'Pre-materialized, side-effect-free values passed into the script, including recalled generation lineage and asset facts. Generation and network IO must happen in the host before this call.';
280
+ readonly description: 'Pre-materialized, side-effect-free values passed into the script, including recalled asset facts. Generation history is never an input: the host queries lineage itself and syncs generated Relations after each commit. Generation and network IO must happen in the host before this call.';
249
281
  };
250
282
  readonly asset_facts: {
251
283
  readonly type: 'array';
@@ -530,6 +562,15 @@ interface CreateMedeoToolOptions {
530
562
  * snapshot, and tolerates a concurrent creator winning the race.
531
563
  */
532
564
  loadInitialDraft?: (docId: string) => Promise<VideoDraft>;
565
+ /**
566
+ * Resolve factual generation lineage by external asset id after a confirmed
567
+ * entity commit. Return every known generation record involving the given
568
+ * ids in either role; an empty array means no known lineage and a rejection
569
+ * means the lineage query failed (surfaced as a warning, never as synced
570
+ * state). The package owns all Entity/Relation semantics: the host never
571
+ * names entities, relations, or endpoints.
572
+ */
573
+ loadGenerationFacts?: GenerationFactsLoader;
533
574
  fetchImpl?: typeof fetch;
534
575
  /** @deprecated ManualSyncDoc has no SSE or reconnect loop. */
535
576
  sseReconnectDelayMs?: number;
@@ -576,15 +617,20 @@ type MedeoToolInput = {
576
617
  plan_id: string;
577
618
  validation?: 'version' | 'preflight';
578
619
  };
579
- interface MedeoToolWarning {
620
+ type MedeoToolWarning = {
580
621
  kind: 'pull_failed';
581
622
  message: string;
582
- }
623
+ } | {
624
+ kind: 'generation_sync_failed';
625
+ message: string;
626
+ };
583
627
  type EntityCommitResult = {
584
628
  kind: 'committed';
585
629
  ops_applied: number;
586
630
  collaborated: false;
587
- entity_revision: number;
631
+ entity_revision: number; /** Present only when the host supplies loadGenerationFacts. */
632
+ generation_sync?: GenerationSyncOutcome;
633
+ warnings?: MedeoToolWarning[];
588
634
  } | {
589
635
  kind: 'unconfirmed';
590
636
  reason: 'push_failed';
@@ -682,5 +728,5 @@ type MedeoInitialDraft = VideoDraft;
682
728
  */
683
729
  declare function createMedeoTool(options: CreateMedeoToolOptions): MedeoTool;
684
730
  //#endregion
685
- export { type AuthorableRelationKind, type ChangePlan, type CommitPlan, type CommitPlanOptions, type CommitPlanResult, type CompactProjectionOptions, type ConsoleShim, type CreateEntityInput, type CreateMedeoToolOptions, type DeleteEntityInput, type EditFacade, EditSandboxSession, type EditSandboxSessionOptions, type EditScriptResult, type EntityCommand, type EntityCommitResult, type EntityFacade, type EntityPlanState, type EntityStoreSnapshot, type ImportAssetInput, type JsonObject, type JsonPrimitive, type JsonValue, type KnownEntityKind, type KnownRelationKind, type LinkGeneratedRelationInput, type LinkRelationInput, MEDEO_TOOL_DESCRIPTION, MEDEO_TOOL_NAME, MEDEO_TOOL_PARAMETERS, type MedeoCommitResult, type MedeoInitialDraft, type MedeoModelContext, type MedeoModelContextInput, type MedeoTool, type MedeoToolInput, type MedeoToolOp, type MedeoToolResult, type RelationFacade, type RunEditScriptOptions, type SandboxCheckpoint, type SandboxEntity, type SandboxRelation, type TimelineClipDescriptor, type TimelineFacade, type TimelinePartDescriptor, type UnlinkRelationInput, type UpdateEntityInput, collectAffectedPartIds, commitPlan, createMedeoTool, renderCompactProjection, renderPreview, runEditScript };
731
+ export { type AssetGenerationFact, type AuthorableRelationKind, type ChangePlan, type CommitPlan, type CommitPlanOptions, type CommitPlanResult, type CompactProjectionOptions, type ConsoleShim, type CreateEntityInput, type CreateMedeoToolOptions, type DeleteEntityInput, type EditFacade, EditSandboxSession, type EditSandboxSessionOptions, type EditScriptResult, type EntityCommand, type EntityCommitResult, type EntityFacade, type EntityPlanState, type EntityStoreSnapshot, type GenerationFactsLoader, type GenerationSyncOutcome, type ImportAssetInput, type JsonObject, type JsonPrimitive, type JsonValue, type KnownEntityKind, type KnownRelationKind, type LinkGeneratedRelationInput, type LinkRelationInput, MEDEO_TOOL_DESCRIPTION, MEDEO_TOOL_NAME, MEDEO_TOOL_PARAMETERS, type MedeoCommitResult, type MedeoInitialDraft, type MedeoModelContext, type MedeoModelContextInput, type MedeoTool, type MedeoToolInput, type MedeoToolOp, type MedeoToolResult, type RelationFacade, type RunEditScriptOptions, type SandboxCheckpoint, type SandboxEntity, type SandboxRelation, type TimelineClipDescriptor, type TimelineFacade, type TimelinePartDescriptor, type UnlinkRelationInput, type UpdateEntityInput, collectAffectedPartIds, commitPlan, createMedeoTool, renderCompactProjection, renderPreview, runEditScript };
686
732
  //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs CHANGED
@@ -276,9 +276,9 @@ var EntityHttpClient = class {
276
276
  }
277
277
  };
278
278
  function toSnapshot(value, expectedDocId) {
279
- if (!isRecord$1(value) || typeof value.doc_id !== "string" || !isNonNegativeInteger(value.revision)) throw new Error("invalid entity-state response envelope");
279
+ if (!isRecord$2(value) || typeof value.doc_id !== "string" || !isNonNegativeInteger(value.revision)) throw new Error("invalid entity-state response envelope");
280
280
  if (value.doc_id !== expectedDocId) throw new Error(`entity-state response doc_id mismatch: expected "${expectedDocId}"`);
281
- if (!isRecord$1(value.rows) || !Array.isArray(value.rows.entities) || !Array.isArray(value.rows.relations)) throw new Error("invalid entity-state response rows");
281
+ if (!isRecord$2(value.rows) || !Array.isArray(value.rows.entities) || !Array.isArray(value.rows.relations)) throw new Error("invalid entity-state response rows");
282
282
  const response = value;
283
283
  return {
284
284
  revision: response.revision,
@@ -287,15 +287,15 @@ function toSnapshot(value, expectedDocId) {
287
287
  };
288
288
  }
289
289
  function parseEntity(value) {
290
- if (!isRecord$1(value) || !isTrimmed(value.entity_id) || typeof value.entity_kind !== "string" || !entityKinds.has(value.entity_kind) || !isJsonObject(value.payload)) throw new Error("invalid Entity row in entity-state response");
290
+ if (!isRecord$2(value) || !isTrimmed(value.entity_id) || typeof value.entity_kind !== "string" || !entityKinds.has(value.entity_kind) || !isJsonObject(value.payload)) throw new Error("invalid Entity row in entity-state response");
291
291
  return structuredClone(value);
292
292
  }
293
293
  function parseRelation(value) {
294
- if (!isRecord$1(value) || !isTrimmed(value.relation_id) || typeof value.relation_kind !== "string" || !relationKinds.has(value.relation_kind) || !isTrimmed(value.endpoint_0_entity_id) || !isTrimmed(value.endpoint_1_entity_id) || !isJsonObject(value.metadata) || !isJsonObject(value.trace)) throw new Error("invalid Relation row in entity-state response");
294
+ if (!isRecord$2(value) || !isTrimmed(value.relation_id) || typeof value.relation_kind !== "string" || !relationKinds.has(value.relation_kind) || !isTrimmed(value.endpoint_0_entity_id) || !isTrimmed(value.endpoint_1_entity_id) || !isJsonObject(value.metadata) || !isJsonObject(value.trace)) throw new Error("invalid Relation row in entity-state response");
295
295
  return structuredClone(value);
296
296
  }
297
297
  function isJsonObject(value) {
298
- return isJsonValue(value, /* @__PURE__ */ new Set()) && isRecord$1(value);
298
+ return isJsonValue(value, /* @__PURE__ */ new Set()) && isRecord$2(value);
299
299
  }
300
300
  function isJsonValue(value, ancestors) {
301
301
  if (value === null || typeof value === "string" || typeof value === "boolean") return true;
@@ -308,7 +308,7 @@ function isJsonValue(value, ancestors) {
308
308
  ancestors.delete(value);
309
309
  return valid;
310
310
  }
311
- function isRecord$1(value) {
311
+ function isRecord$2(value) {
312
312
  return value !== null && typeof value === "object" && !Array.isArray(value);
313
313
  }
314
314
  function isTrimmed(value) {
@@ -327,6 +327,223 @@ async function safeReadJson(response) {
327
327
  }
328
328
  }
329
329
  //#endregion
330
+ //#region src/entity/generation-sync.ts
331
+ /**
332
+ * External systems whose asset entities carry a factual Memota identity.
333
+ * Voice results use the speech system; every other medium uses `memota`.
334
+ */
335
+ const ASSET_SYSTEMS = new Set(["memota", "memota-speech"]);
336
+ /** Entity kinds that may carry a generated Relation endpoint (DSL `GeneratedMedia`). */
337
+ const GENERATED_MEDIA_KINDS = new Set([
338
+ "video",
339
+ "image",
340
+ "audio",
341
+ "voice"
342
+ ]);
343
+ /** Bounded CAS retry budget for the sync commit after a concurrent winner. */
344
+ const MAX_COMMIT_ATTEMPTS = 3;
345
+ /** Validate host-supplied facts; a malformed record fails the whole query. */
346
+ function parseGenerationFacts(value) {
347
+ if (!Array.isArray(value)) throw new Error("generation facts must be an array");
348
+ return value.map((item) => {
349
+ if (!isRecord$1(item)) throw new Error("each generation fact must be an object");
350
+ const { outputAssetId, inputAssetIds } = item;
351
+ if (typeof outputAssetId !== "string" || outputAssetId.length === 0 || outputAssetId.trim() !== outputAssetId) throw new Error("generation fact outputAssetId must be a non-empty trimmed string");
352
+ if (!Array.isArray(inputAssetIds)) throw new Error("generation fact inputAssetIds must be an array (explicit [] means text-only)");
353
+ const inputs = inputAssetIds;
354
+ for (const input of inputs) if (typeof input !== "string" || input.length === 0 || input.trim() !== input) throw new Error("generation fact inputAssetIds entries must be non-empty trimmed strings");
355
+ return {
356
+ outputAssetId,
357
+ inputAssetIds: [...inputs]
358
+ };
359
+ });
360
+ }
361
+ function planGenerationScope(base, commands, state) {
362
+ const createdMediaIds = /* @__PURE__ */ new Set();
363
+ const keyChangedAssetIds = /* @__PURE__ */ new Set();
364
+ const bindingTouchedMediaIds = /* @__PURE__ */ new Set();
365
+ for (const command of commands) if (command.kind === "create-entity") {
366
+ if (command.entity.entity_kind === "asset") keyChangedAssetIds.add(command.entity.entity_id);
367
+ else if (GENERATED_MEDIA_KINDS.has(command.entity.entity_kind)) createdMediaIds.add(command.entity.entity_id);
368
+ } else if (command.kind === "update-entity") {
369
+ const baseEntity = base.entities.find((entity) => entity.entity_id === command.entity_id);
370
+ if (baseEntity?.entity_kind !== "asset") continue;
371
+ const before = assetKeyOf(baseEntity);
372
+ const after = assetKeyOf({
373
+ entity_id: command.entity_id,
374
+ entity_kind: "asset",
375
+ payload: command.payload
376
+ });
377
+ if (after !== void 0 && before !== after) keyChangedAssetIds.add(command.entity_id);
378
+ } else if (command.kind === "link-relation") {
379
+ if (command.relation.relation_kind === "physical-asset") bindingTouchedMediaIds.add(command.relation.endpoint_0_entity_id);
380
+ } else if (command.kind === "unlink-relation") {
381
+ const removed = base.relations.find((relation) => relation.relation_id === command.relation_id);
382
+ if (removed?.relation_kind === "physical-asset") bindingTouchedMediaIds.add(removed.endpoint_0_entity_id);
383
+ }
384
+ const scoped = new Set([...createdMediaIds, ...bindingTouchedMediaIds]);
385
+ const assetsById = new Map(state.entities.filter((entity) => entity.entity_kind === "asset").map((entity) => [entity.entity_id, entity]));
386
+ for (const relation of state.relations) {
387
+ if (relation.relation_kind !== "physical-asset") continue;
388
+ if (!keyChangedAssetIds.has(relation.endpoint_1_entity_id)) continue;
389
+ scoped.add(relation.endpoint_0_entity_id);
390
+ }
391
+ const queryKeys = /* @__PURE__ */ new Set();
392
+ for (const relation of state.relations) {
393
+ if (relation.relation_kind !== "physical-asset") continue;
394
+ if (!scoped.has(relation.endpoint_0_entity_id)) continue;
395
+ const asset = assetsById.get(relation.endpoint_1_entity_id);
396
+ const key = asset === void 0 ? void 0 : assetKeyOf(asset);
397
+ if (key !== void 0) queryKeys.add(key);
398
+ }
399
+ return {
400
+ scopedMediaIds: scoped,
401
+ queryAssetKeys: [...queryKeys].sort()
402
+ };
403
+ }
404
+ /**
405
+ * Ordered generated(output,input) Relations missing from `state` for the given
406
+ * factual records. Both endpoints must already exist and fact-match through
407
+ * physical-asset bindings, and the pair must involve a media Entity the plan
408
+ * newly fact-exposed (`scopedMediaIds`): lineage scopes to the commit's diff,
409
+ * so a pair the user deleted between untouched entities stays deleted. A pair
410
+ * the facts already resolved against the plan's base state is likewise skipped.
411
+ * One-sided facts, text-only records, self pairs, and already-linked pairs are
412
+ * skipped. Duplicate records collapse to one Relation.
413
+ */
414
+ function planGeneratedRelations(input) {
415
+ const { state, facts } = input;
416
+ const scoped = input.scopedMediaIds;
417
+ const mediaByAssetKey = resolveMediaByAssetKey(state);
418
+ const baseResolvable = new Set(resolvablePairs(resolveMediaByAssetKey(input.baseState), facts));
419
+ const linkedPairs = new Set(state.relations.filter((relation) => relation.relation_kind === "generated").map((relation) => pairKey(relation.endpoint_0_entity_id, relation.endpoint_1_entity_id)));
420
+ const relations = [];
421
+ for (const fact of facts) for (const outputId of mediaByAssetKey.get(fact.outputAssetId) ?? []) for (const inputAssetId of fact.inputAssetIds) for (const inputId of mediaByAssetKey.get(inputAssetId) ?? []) {
422
+ if (outputId === inputId) continue;
423
+ if (!scoped.has(outputId) && !scoped.has(inputId)) continue;
424
+ const pair = pairKey(outputId, inputId);
425
+ if (linkedPairs.has(pair) || baseResolvable.has(pair)) continue;
426
+ linkedPairs.add(pair);
427
+ relations.push({
428
+ relation_id: input.newRelationId(),
429
+ relation_kind: "generated",
430
+ endpoint_0_entity_id: outputId,
431
+ endpoint_1_entity_id: inputId,
432
+ metadata: {},
433
+ trace: { synced_by: "generation-sync" }
434
+ });
435
+ }
436
+ return relations;
437
+ }
438
+ /**
439
+ * Sync generation lineage after a confirmed entity commit. Any failure is
440
+ * returned as a `failed` outcome instead of thrown, so the already-durable
441
+ * commit result is never masked; a successful query that finds nothing is
442
+ * `current`. A revision conflict re-reads, re-derives the trigger keys from
443
+ * the fresh bindings, queries any newly scoped facts, re-plans, and retries
444
+ * within `MAX_COMMIT_ATTEMPTS` before reporting failure — a stale asset query
445
+ * must never let a backfillable edge be reported as `current`.
446
+ */
447
+ async function syncGeneratedRelations(input) {
448
+ const { client, docId, baseState, entityCommands, loadFacts } = input;
449
+ let state;
450
+ let facts = [];
451
+ const queriedKeys = /* @__PURE__ */ new Set();
452
+ try {
453
+ state = await client.fetchState();
454
+ let scope = planGenerationScope(baseState, entityCommands, state);
455
+ for (let attempt = 1; attempt <= MAX_COMMIT_ATTEMPTS; attempt++) {
456
+ const unseenKeys = scope.queryAssetKeys.filter((key) => !queriedKeys.has(key));
457
+ for (const key of unseenKeys) queriedKeys.add(key);
458
+ if (unseenKeys.length > 0) facts = [...facts, ...parseGenerationFacts(await loadFacts(docId, unseenKeys))];
459
+ if (scope.queryAssetKeys.length === 0) return { status: "current" };
460
+ const relations = planGeneratedRelations({
461
+ baseState,
462
+ state,
463
+ scopedMediaIds: scope.scopedMediaIds,
464
+ facts,
465
+ newRelationId: mintRelationId
466
+ });
467
+ if (relations.length === 0) return { status: "current" };
468
+ try {
469
+ await client.commit(state.revision, {
470
+ ...state,
471
+ relations: [...state.relations, ...relations]
472
+ });
473
+ return {
474
+ status: "applied",
475
+ created_relation_ids: relations.map((relation) => relation.relation_id)
476
+ };
477
+ } catch (error) {
478
+ if (!(error instanceof MengineEntityHttpRequestError && error.status === 409) || attempt === MAX_COMMIT_ATTEMPTS) return {
479
+ status: "failed",
480
+ message: `generation lineage sync commit failed: ${errorMessage(error)}`
481
+ };
482
+ state = await client.fetchState();
483
+ scope = planGenerationScope(baseState, entityCommands, state);
484
+ }
485
+ }
486
+ return {
487
+ status: "failed",
488
+ message: "generation lineage sync exhausted its retry budget"
489
+ };
490
+ } catch (error) {
491
+ return {
492
+ status: "failed",
493
+ message: `generation lineage query failed: ${errorMessage(error)}`
494
+ };
495
+ }
496
+ }
497
+ function assetKeyOf(entity) {
498
+ if (entity.entity_kind !== "asset") return void 0;
499
+ const external = entity.payload?.external;
500
+ if (external == null || typeof external !== "object" || Array.isArray(external)) return void 0;
501
+ const { system, key } = external;
502
+ if (typeof system !== "string" || !ASSET_SYSTEMS.has(system)) return void 0;
503
+ if (typeof key !== "string" || key.length === 0 || key.trim() !== key) return void 0;
504
+ return key;
505
+ }
506
+ /**
507
+ * Media Entity ids fact-matched to each external asset key. Bindings follow
508
+ * the canonical physical-asset direction (endpoint 0 = sequence media,
509
+ * endpoint 1 = Asset) that the DSL spec enforces.
510
+ */
511
+ function resolveMediaByAssetKey(state) {
512
+ const assetsById = new Map(state.entities.filter((entity) => entity.entity_kind === "asset").map((entity) => [entity.entity_id, entity]));
513
+ const mediaKinds = new Set([...GENERATED_MEDIA_KINDS]);
514
+ const mediaIds = new Set(state.entities.filter((entity) => mediaKinds.has(entity.entity_kind)).map((entity) => entity.entity_id));
515
+ const resolved = /* @__PURE__ */ new Map();
516
+ for (const relation of state.relations) {
517
+ if (relation.relation_kind !== "physical-asset") continue;
518
+ if (!mediaIds.has(relation.endpoint_0_entity_id)) continue;
519
+ const asset = assetsById.get(relation.endpoint_1_entity_id);
520
+ const key = asset === void 0 ? void 0 : assetKeyOf(asset);
521
+ if (key === void 0) continue;
522
+ const media = resolved.get(key);
523
+ if (media === void 0) resolved.set(key, [relation.endpoint_0_entity_id]);
524
+ else if (!media.includes(relation.endpoint_0_entity_id)) media.push(relation.endpoint_0_entity_id);
525
+ }
526
+ return resolved;
527
+ }
528
+ function pairKey(endpoint0, endpoint1) {
529
+ return `${endpoint0}\u0000${endpoint1}`;
530
+ }
531
+ /** Pair keys the facts already resolve to under the given base bindings. */
532
+ function resolvablePairs(mediaByAssetKey, facts) {
533
+ const pairs = [];
534
+ for (const fact of facts) for (const outputId of mediaByAssetKey.get(fact.outputAssetId) ?? []) for (const inputAssetId of fact.inputAssetIds) for (const inputId of mediaByAssetKey.get(inputAssetId) ?? []) if (outputId !== inputId) pairs.push(pairKey(outputId, inputId));
535
+ return pairs;
536
+ }
537
+ function mintRelationId() {
538
+ return `relation_${randomUUID()}`;
539
+ }
540
+ function errorMessage(error) {
541
+ return error instanceof Error ? error.message : String(error);
542
+ }
543
+ function isRecord$1(value) {
544
+ return value !== null && typeof value === "object" && !Array.isArray(value);
545
+ }
546
+ //#endregion
330
547
  //#region src/migration-input.ts
331
548
  /** Validate host-recalled facts without accepting a caller-controlled snapshot or VV. */
332
549
  function parseMigrationAssetFacts(value) {
@@ -951,20 +1168,20 @@ Edit the authoritative Medeo Entity/Relation graph through a deterministic, side
951
1168
  Operations:
952
1169
  - snapshot: return the Entity/Relation state summary and opaque base version.
953
1170
  - migrate-legacy: explicitly migrate an existing legacy timeline using recalled asset_facts. MEngine reads the canonical document and version, verifies that editing facts are preserved, and commits migration alone. Then take a fresh snapshot before any edit; never pass a caller-created legacy snapshot or version.
954
- - run-edit-script: inspect timeline.snapshot(), entities.*, and relations.*; edit.* operates existing Entity ids and creates the required Clip/SequenceMarker structural graph. Asset import, media Entity creation, generated Relations and timeline edits belong in ONE plan. The sandbox has no network, storage or generation access. Pass recalled generation/asset facts through inputs. A successful run returns preview, logs, base revision and plan_id.
1171
+ - run-edit-script: inspect timeline.snapshot(), entities.*, and relations.*; edit.* operates existing Entity ids and creates the required Clip/SequenceMarker structural graph. Asset import, media Entity creation and timeline edits belong in ONE plan. The sandbox has no network, storage or generation access. Pass recalled asset facts through inputs; generation history is not a script input — the host program queries it itself after each commit. A successful run returns preview, logs, base revision and plan_id.
955
1172
  - commit-plan: commit the complete Entity/Relation plan through revision CAS. The server derives the read-only timeline projection in the same transaction. There is no separate writable timeline plan and no preflight replay into a legacy editor. A failed transport is unconfirmed, never committed; retry the same plan_id.
956
1173
 
957
1174
  Default flow: snapshot → run-edit-script with auto_commit=false → inspect preview → commit-plan. Use auto_commit=true only for low-risk edits when the host does not need user confirmation. On version mismatch, rerun snapshot and the script; never try to patch a rejected journal by hand.
958
1175
 
959
- Generating an Asset alone does not require an Entity. Using that resource in the editor DOES: recall the Asset facts, reuse or create the appropriate media Entity and physical-asset Relation, then pass the media Entity id to edit.insertClip. A raw asset id or URL is not valid contentEntityId. Asset and media Entity identity are not one-to-one. Recall generation history and author known generated(output,input) relations in the same plan; do not invent an input for text-only generation. relations.of(entityId) is endpoint-agnostic.
1176
+ Generating an Asset alone does not require an Entity. Using that resource in the editor DOES: recall the Asset facts, reuse or create the appropriate media Entity and physical-asset Relation, then pass the media Entity id to edit.insertClip. A raw asset id or URL is not valid contentEntityId. Asset and media Entity identity are not one-to-one. Generation lineage is program-synced: after each successful commit the tool connects existing media Entities from host-recalled generation facts (endpoint 0 output, endpoint 1 input) that the host queries itself — do not pass generation history through inputs. Do not author generated Relations yourself, and never create an Entity merely to backfill or represent lineage; media Entities the edit itself legitimately needs (for example placing a recalled input asset) are still created normally. Text-only generation has no input and no lineage edge. relations.of(entityId) is endpoint-agnostic.
960
1177
  `.trim();
961
1178
  const MEDEO_TOOL_EXECUTION_RULES = `
962
1179
  The host supplies the current document. Do not ask for, invent, or pass a document id.
963
1180
  timeline.snapshot() returns the Entity/Relation graph with its revision, not a legacy VideoDraft. Inspect Timeline, Track, Clip, SequenceMarker and their relations to plan edits.
964
- Generation lineage and Memota asset facts are host-provided through inputs. Never invent an asset id, Entity kind, or peer Entity id.
1181
+ Generation lineage is not a model input: the host program queries it via loadGenerationFacts and syncs generated Relations after each successful commit. Memota asset facts are host-provided through inputs. Never invent an asset id, Entity kind, or peer Entity id.
965
1182
  Before importing an Asset, call entities.findByAssetId(assetId), inspect every match, and decide whether an existing Entity represents the intended logical asset. Multiple matches are valid; do not assume Asset↔media is one-to-one.
966
1183
  For recalled video/audio/voice, create a bounded/native payload whose extent end comes from factual media duration/coordinates in inputs; never fabricate a duration. Image uses unbounded/constant semantics and has no invented end. If required facts are absent, do not create the media Entity yet.
967
- For physical-asset authoring, use sequence media as endpoint_0_entity_id and Asset as endpoint_1_entity_id. For generated lineage, use linkGenerated so endpoint 0 is output and endpoint 1 is input. relations.of remains endpoint-agnostic for lookup.
1184
+ For physical-asset authoring, use sequence media as endpoint_0_entity_id and Asset as endpoint_1_entity_id. Generated lineage Relations are not model-authored: the tool derives them from host generation facts after a successful commit, so do not call relations.linkGenerated to record provenance. relations.of remains endpoint-agnostic for lookup.
968
1185
  The compatibility reader supports the existing four Track roles: video_clip (Image/Video), speech (Voice), caption (Caption), and bgm (Audio), one of each. Clip.volume is decibels (-60 to 20, 0 = original). Marker.sourceRange is the selected source interval; Marker.duration is effective display/playback duration. Coordinates and duration are whole milliseconds for this reader, not a global DSL restriction. Placement is exactly one of Clip.order, Marker.targetRange, or clip-anchor(child,host) plus Marker.anchorOffset. Use the native move/delete/voiceover helpers so placement and cascade decisions are in the same entity plan. Reading the graph never rebinds anchors or invents empty clips. Linear timeRemapping is {kind:'linear',rate:2,mode:'constant'} and agrees with rounded source span divided by rate. Image remains unbounded/constant; an explicit linear rate scales its display window, not an invented media extent. Nonlinear speed and multiple visual overlay tracks are unsupported.
969
1186
  Voice links to AudioScript through audio-script-render(output,script). Caption owns text/style and is placed through a Clip anchored to the Voice Clip; caption alignment/provenance agree with that Voice/AudioScript. BGM Audio keeps factual source duration and its Marker declares durationPolicy:'timeline'. External Asset identity and storageKey are distinct from the placed Clip identity. Never introduce a speech entity kind.
970
1187
  Create only the known entity kinds. On an empty document, explicitly create Timeline and Track(role='video_clip') and connect timeline-track before inserting a Clip. If snapshot reports legacy migration is required, recall the listed asset facts and call migrate-legacy first. Missing facts, unsupported layouts, and version conflicts fail closed; never fall back to an old timeline method or raw update endpoint.
@@ -1064,7 +1281,7 @@ const MEDEO_TOOL_PARAMETERS = {
1064
1281
  },
1065
1282
  inputs: {
1066
1283
  type: "object",
1067
- description: "Pre-materialized, side-effect-free values passed into the script, including recalled generation lineage and asset facts. Generation and network IO must happen in the host before this call."
1284
+ description: "Pre-materialized, side-effect-free values passed into the script, including recalled asset facts. Generation history is never an input: the host queries lineage itself and syncs generated Relations after each commit. Generation and network IO must happen in the host before this call."
1068
1285
  },
1069
1286
  asset_facts: {
1070
1287
  type: "array",
@@ -1569,11 +1786,12 @@ function createMedeoTool(options) {
1569
1786
  ...peerId !== void 0 ? { peerId } : {}
1570
1787
  });
1571
1788
  }
1572
- function rememberPlan(docId, plan) {
1789
+ function rememberPlan(docId, plan, baseState) {
1573
1790
  const planId = randomUUID();
1574
1791
  plans.set(planId, {
1575
1792
  docId,
1576
- plan
1793
+ plan,
1794
+ ...plan.plan_kind === "entities" ? { baseState: baseState && structuredClone(baseState) } : {}
1577
1795
  });
1578
1796
  while (plans.size > maxPlans) {
1579
1797
  const protectedPlanIds = new Set([...pendingPushes.values()].map((pending) => pending.planId));
@@ -1588,7 +1806,7 @@ function createMedeoTool(options) {
1588
1806
  const pending = pendingPushes.get(docId);
1589
1807
  if (pending != null) throw new Error(`doc ${docId} has an unconfirmed push; retry plan_id ${pending.planId} before continuing`);
1590
1808
  }
1591
- function recordPushResult(docId, planId, plan, result) {
1809
+ function recordPushResult(docId, planId, plan, result, baseState) {
1592
1810
  if (result.kind === "unconfirmed") {
1593
1811
  pendingPushes.set(docId, plan.plan_kind === "timeline" ? {
1594
1812
  kind: "timeline",
@@ -1598,7 +1816,8 @@ function createMedeoTool(options) {
1598
1816
  } : {
1599
1817
  kind: "entities",
1600
1818
  planId,
1601
- plan
1819
+ plan,
1820
+ ...baseState !== void 0 ? { baseState } : {}
1602
1821
  });
1603
1822
  return;
1604
1823
  }
@@ -1608,11 +1827,49 @@ function createMedeoTool(options) {
1608
1827
  async function fetchEntityStateForSandbox(docId) {
1609
1828
  return await getEntityClient(docId).fetchState();
1610
1829
  }
1611
- async function commitCachedPlan(docId, _doc, plan, validation) {
1830
+ async function commitCachedPlan(docId, _doc, plan, validation, baseState) {
1612
1831
  if (plan.plan_kind === "timeline") throw new Error("Legacy timeline plans are not editable; use an Entity/Relation plan");
1613
1832
  if (validation === "preflight") throw new Error("Entity plans use revision CAS; validation=preflight is not supported");
1614
1833
  if (plan.entity_rows === void 0) throw new Error("entity plan is missing its authoritative rows");
1615
- return await commitEntityPlan(getEntityClient(docId), plan);
1834
+ const client = getEntityClient(docId);
1835
+ const preCommitState = baseState ?? (options.loadGenerationFacts !== void 0 ? await client.fetchState() : void 0);
1836
+ return await attachGenerationSync(docId, plan, await commitEntityPlan(client, plan), preCommitState);
1837
+ }
1838
+ /**
1839
+ * After a confirmed entity commit, connect fact-matched generated Relations
1840
+ * from host-recalled lineage. The commit is already durable, so a sync
1841
+ * failure never fails the op; it is attached to the result and surfaced as a
1842
+ * warning instead. The plan's diff against `baseState` scopes the sync:
1843
+ * created media Entities, edits that re-point an existing physical-asset
1844
+ * binding or Asset external key, and creations — not untouched pairs.
1845
+ * One-sided facts are skipped silently inside the sync.
1846
+ */
1847
+ async function attachGenerationSync(docId, plan, result, baseState) {
1848
+ if (result.kind !== "committed" || options.loadGenerationFacts === void 0 || baseState === void 0) return result;
1849
+ let outcome;
1850
+ try {
1851
+ outcome = await syncGeneratedRelations({
1852
+ client: getEntityClient(docId),
1853
+ docId,
1854
+ baseState,
1855
+ entityCommands: plan.entity_commands,
1856
+ loadFacts: options.loadGenerationFacts
1857
+ });
1858
+ } catch (error) {
1859
+ outcome = {
1860
+ status: "failed",
1861
+ message: error instanceof Error ? error.message : String(error)
1862
+ };
1863
+ }
1864
+ const warnings = outcome.status === "failed" ? [{
1865
+ kind: "generation_sync_failed",
1866
+ message: outcome.message ?? "generation lineage sync failed"
1867
+ }] : void 0;
1868
+ return {
1869
+ ...result,
1870
+ generation_sync: outcome,
1871
+ ...warnings !== void 0 ? { warnings } : {}
1872
+ };
1616
1873
  }
1617
1874
  async function observePull(doc) {
1618
1875
  const result = await doc.pull();
@@ -1745,7 +2002,7 @@ function createMedeoTool(options) {
1745
2002
  ...result.plan,
1746
2003
  doc_id: input.doc_id
1747
2004
  };
1748
- const planId = rememberPlan(input.doc_id, plan);
2005
+ const planId = rememberPlan(input.doc_id, plan, entityState);
1749
2006
  const base = {
1750
2007
  ok: true,
1751
2008
  op: "run-edit-script",
@@ -1762,8 +2019,8 @@ function createMedeoTool(options) {
1762
2019
  ...pull.warnings !== void 0 ? { warnings: pull.warnings } : {}
1763
2020
  };
1764
2021
  if (input.auto_commit !== true) return base;
1765
- const commit = await commitCachedPlan(input.doc_id, doc, plan);
1766
- recordPushResult(input.doc_id, planId, plan, commit);
2022
+ const commit = await commitCachedPlan(input.doc_id, doc, plan, void 0, entityState);
2023
+ recordPushResult(input.doc_id, planId, plan, commit, entityState);
1767
2024
  const warnings = mergeWarnings(pull.warnings, commitWarnings(commit));
1768
2025
  return {
1769
2026
  ...base,
@@ -1779,8 +2036,8 @@ function createMedeoTool(options) {
1779
2036
  const pending = pendingPushes.get(input.doc_id);
1780
2037
  if (pending != null) {
1781
2038
  if (pending.planId !== input.plan_id) throw new Error(`doc ${input.doc_id} has an unconfirmed push for plan_id ${pending.planId}; retry it before ${input.plan_id}`);
1782
- const result = pending.kind === "timeline" ? await retryPlanPush(doc, pending.opsApplied) : await commitCachedPlan(input.doc_id, doc, pending.plan, input.validation);
1783
- recordPushResult(input.doc_id, input.plan_id, pending.plan, result);
2039
+ const result = pending.kind === "timeline" ? await retryPlanPush(doc, pending.opsApplied) : await commitCachedPlan(input.doc_id, doc, pending.plan, input.validation, pending.baseState);
2040
+ recordPushResult(input.doc_id, input.plan_id, pending.plan, result, pending.kind === "entities" ? pending.baseState : void 0);
1784
2041
  const warnings = commitWarnings(result);
1785
2042
  return {
1786
2043
  ok: true,
@@ -1797,8 +2054,8 @@ function createMedeoTool(options) {
1797
2054
  const cached = plans.get(input.plan_id);
1798
2055
  if (cached == null || cached.docId !== input.doc_id) throw new Error(`plan_id ${input.plan_id} is not available for doc ${input.doc_id}`);
1799
2056
  const pull = cached.plan.plan_kind === "timeline" ? await observePull(doc) : { collaborated: false };
1800
- const result = await commitCachedPlan(input.doc_id, doc, cached.plan, input.validation);
1801
- recordPushResult(input.doc_id, input.plan_id, cached.plan, result);
2057
+ const result = await commitCachedPlan(input.doc_id, doc, cached.plan, input.validation, cached.baseState);
2058
+ recordPushResult(input.doc_id, input.plan_id, cached.plan, result, cached.baseState);
1802
2059
  const warnings = mergeWarnings(pull.warnings, commitWarnings(result));
1803
2060
  return {
1804
2061
  ok: true,